feat(dispatch): 말레이시아 TikTok 출고관리 모듈 추가
03_TikTok_Order_Export.xlsx 업로드 → 1박스=1카드 출고 작업 리스트, SKU 피킹 요약, Kagayaku 전달표(A4 인쇄)를 자동 생성. - 1박스 묶음 기준: Package ID > Tracking ID > Order ID, 같은 박스 같은 SKU 합산 - 작업 상태 5단계 토글(AJAX 즉시 저장) + dispatch_logs 기록 - 고객 이름/주소/전화 미저장(파싱 시 폐기, 스키마에 컬럼 없음) - 엑셀은 openpyxl 파싱(pandas 미사용), DB는 dispatch_db 전용 - 인증은 기존 Google OAuth + 신규 권한키 dispatch 재사용 - scripts/sql/dispatch_db_init.sql, docs/DISPATCH_MODULE.md 동봉 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
.dsp-toolbar { display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:12px 0; }
|
||||
.dsp-filter { display:flex;gap:6px;flex-wrap:wrap; }
|
||||
.dsp-search { flex:1 1 220px;min-width:180px; }
|
||||
.dsp-cards { display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:14px; }
|
||||
.dsp-card { border:1px solid #e2e8f0;border-radius:12px;padding:14px;background:#fff;display:flex;flex-direction:column;gap:10px; }
|
||||
.dsp-card.is-done { border-color:#86efac;background:#f0fdf4; }
|
||||
.dsp-no { font-size:20px;font-weight:700;color:#0f172a; }
|
||||
.dsp-meta { font-size:13px;line-height:1.5;color:#334155;word-break:break-all; }
|
||||
.dsp-meta b { color:#0f172a; }
|
||||
.dsp-items { list-style:none;margin:0;padding:8px 0;border-top:1px dashed #e2e8f0;border-bottom:1px dashed #e2e8f0; }
|
||||
.dsp-items li { font-size:16px;font-weight:600;color:#0f172a;padding:2px 0; }
|
||||
.dsp-items .qty { color:#2563eb; }
|
||||
.dsp-status { display:grid;grid-template-columns:1fr 1fr;gap:6px; }
|
||||
.dsp-status .full { grid-column:1 / -1; }
|
||||
.dsp-btn-status { border:1px solid #cbd5e1;border-radius:8px;padding:8px 6px;background:#f1f5f9;color:#475569;
|
||||
font-weight:600;cursor:pointer;text-align:center;line-height:1.25;transition:background .12s,border-color .12s,color .12s; }
|
||||
.dsp-btn-status small { display:block;font-size:10px;font-weight:500;opacity:.8; }
|
||||
.dsp-btn-status.on { background:#16a34a;border-color:#16a34a;color:#fff; }
|
||||
.dsp-btn-status:disabled { opacity:.6;cursor:wait; }
|
||||
.dsp-hidden { display:none !important; }
|
||||
@media (max-width:480px){ .dsp-cards{grid-template-columns:1fr;} }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="dsp" data-batch="{{ batch.id }}">
|
||||
{% include "dispatch/_nav.html" %}
|
||||
|
||||
<div class="erp-card">
|
||||
<div class="cpg-card-head" style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;">
|
||||
<h2>출고 작업 — 박스 {{ parcels|length }}개</h2>
|
||||
<span class="erp-muted" id="dsp-done-label">완료 {{ done_count }} / {{ parcels|length }}</span>
|
||||
</div>
|
||||
|
||||
<div class="dsp-toolbar">
|
||||
<div class="dsp-filter" id="dsp-filter">
|
||||
<button class="erp-btn erp-btn-primary" data-filter="all" type="button">전체</button>
|
||||
<button class="erp-btn erp-btn-outline" data-filter="unpacked" type="button">미포장</button>
|
||||
<button class="erp-btn erp-btn-outline" data-filter="packed" type="button">포장완료</button>
|
||||
<button class="erp-btn erp-btn-outline" data-filter="label_attached" type="button">라벨부착</button>
|
||||
<button class="erp-btn erp-btn-outline" data-filter="handed_to_kagayaku" type="button">Kagayaku 전달</button>
|
||||
<button class="erp-btn erp-btn-outline" data-filter="courier_scanned" type="button">택배스캔 확인</button>
|
||||
</div>
|
||||
<input class="erp-input dsp-search" id="dsp-search" type="search"
|
||||
placeholder="검색: Order ID / Tracking ID / Package ID / SKU" />
|
||||
</div>
|
||||
|
||||
<div class="dsp-cards" id="dsp-cards">
|
||||
{% for p in parcels %}
|
||||
{% set is_done = p.product_ready and p.packed and p.label_attached and p.handed_to_kagayaku and p.courier_scanned %}
|
||||
<article class="dsp-card {% if is_done %}is-done{% endif %}"
|
||||
data-parcel="{{ p.id }}"
|
||||
data-product_ready="{{ p.product_ready|lower }}"
|
||||
data-packed="{{ p.packed|lower }}"
|
||||
data-label_attached="{{ p.label_attached|lower }}"
|
||||
data-handed_to_kagayaku="{{ p.handed_to_kagayaku|lower }}"
|
||||
data-courier_scanned="{{ p.courier_scanned|lower }}"
|
||||
data-search="{{ (p.order_id ~ ' ' ~ p.package_id ~ ' ' ~ p.tracking_id ~ ' ' ~ (p['items']|map(attribute='seller_sku')|join(' ')))|lower }}">
|
||||
<div style="display:flex;justify-content:space-between;align-items:baseline;">
|
||||
<span class="dsp-no">No. {{ p.seq }}</span>
|
||||
{% if p.shipping_provider %}<span class="erp-badge">{{ p.shipping_provider }}</span>{% endif %}
|
||||
</div>
|
||||
<div class="dsp-meta">
|
||||
<div><b>Order</b> {{ p.order_id or '—' }}</div>
|
||||
<div><b>Package</b> {{ p.package_id or '—' }}</div>
|
||||
<div><b>Tracking</b> {{ p.tracking_id or '—' }}</div>
|
||||
</div>
|
||||
<ul class="dsp-items">
|
||||
{% for it in p['items'] %}
|
||||
<li>{{ it.seller_sku }} <span class="qty">× {{ it.quantity }}</span></li>
|
||||
{% else %}
|
||||
<li class="erp-muted" style="font-weight:400;">상품 없음</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="dsp-status">
|
||||
{% for field in status_fields %}
|
||||
<button type="button"
|
||||
class="dsp-btn-status {% if (field == status_fields[-1]) and (status_fields|length % 2 == 1) %}full{% endif %} {% if p[field] %}on{% endif %}"
|
||||
data-field="{{ field }}"
|
||||
onclick="dspToggle(this)">
|
||||
{{ status_labels[field].ko }}
|
||||
<small>{{ status_labels[field].en }}</small>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
{% if not parcels %}
|
||||
<p class="erp-muted">이 배치에는 박스가 없습니다.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(function () {
|
||||
var STATUS_FIELDS = {{ status_fields|list|tojson }};
|
||||
|
||||
// ── 상태 토글 (즉시 DB 저장) ──
|
||||
window.dspToggle = function (btn) {
|
||||
var card = btn.closest('.dsp-card');
|
||||
var parcelId = card.getAttribute('data-parcel');
|
||||
var field = btn.getAttribute('data-field');
|
||||
btn.disabled = true;
|
||||
var body = new URLSearchParams();
|
||||
body.set('field', field);
|
||||
fetch('/dispatch/parcels/' + parcelId + '/toggle', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
})
|
||||
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
||||
.then(function (data) {
|
||||
btn.classList.toggle('on', data.value);
|
||||
card.setAttribute('data-' + field, data.value ? 'true' : 'false');
|
||||
refreshDone(card);
|
||||
applyFilter();
|
||||
})
|
||||
.catch(function () { alert('저장에 실패했습니다. 다시 시도하세요.'); })
|
||||
.finally(function () { btn.disabled = false; });
|
||||
};
|
||||
|
||||
function refreshDone(card) {
|
||||
var done = STATUS_FIELDS.every(function (f) {
|
||||
return card.getAttribute('data-' + f) === 'true';
|
||||
});
|
||||
card.classList.toggle('is-done', done);
|
||||
updateDoneLabel();
|
||||
}
|
||||
|
||||
function updateDoneLabel() {
|
||||
var cards = document.querySelectorAll('.dsp-card');
|
||||
var done = document.querySelectorAll('.dsp-card.is-done').length;
|
||||
var label = document.getElementById('dsp-done-label');
|
||||
if (label) label.textContent = '완료 ' + done + ' / ' + cards.length;
|
||||
}
|
||||
|
||||
// ── 필터 ──
|
||||
var currentFilter = 'all';
|
||||
function matchesFilter(card) {
|
||||
switch (currentFilter) {
|
||||
case 'all': return true;
|
||||
case 'unpacked': return card.getAttribute('data-packed') !== 'true';
|
||||
default: return card.getAttribute('data-' + currentFilter) === 'true';
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilter() {
|
||||
var term = (document.getElementById('dsp-search').value || '').trim().toLowerCase();
|
||||
document.querySelectorAll('.dsp-card').forEach(function (card) {
|
||||
var ok = matchesFilter(card) &&
|
||||
(term === '' || (card.getAttribute('data-search') || '').indexOf(term) !== -1);
|
||||
card.classList.toggle('dsp-hidden', !ok);
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('dsp-filter').addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('[data-filter]');
|
||||
if (!btn) return;
|
||||
currentFilter = btn.getAttribute('data-filter');
|
||||
this.querySelectorAll('[data-filter]').forEach(function (b) {
|
||||
b.classList.toggle('erp-btn-primary', b === btn);
|
||||
b.classList.toggle('erp-btn-outline', b !== btn);
|
||||
});
|
||||
applyFilter();
|
||||
});
|
||||
|
||||
document.getElementById('dsp-search').addEventListener('input', applyFilter);
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user