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:
2026-06-19 15:11:43 +09:00
parent 8453c425f8
commit 99eea01fc1
19 changed files with 1868 additions and 0 deletions
@@ -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 %}