feat(dispatch): 메뉴얼 오더 플랫폼 추가(엑셀 단독)
- 플랫폼 Manual 추가. 엑셀 첫 시트(Receiver Name/Phone/Address/Product Name/ Item Code/Quantity)를 전용 파서로 읽어 1행=1박스 생성 · 받는 사람 정보가 엑셀에 직접 있어 PDF 불필요(라벨 슬롯 없음) · 묶음키 없음 → 행마다 별도 박스 - 업로드 화면: Manual 선택 시 라벨 PDF 칸 숨김 + 안내문 - 출고 카드: Order ID/Tracking 은 값 있을 때만 표시(Manual 은 숨김), Manual 은 상품명 표기(받는사람/전화/주소/상품명/수량) - 재고 차감/사방넷 출고는 기존 집계 로직 그대로(콤보 _N 분해 포함) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -35,13 +35,99 @@ def parse_export(path: str, platform: str = "") -> dict[str, Any]:
|
|||||||
"""출고 XLSX 경로 → {"parcels": [...], "row_count": int}.
|
"""출고 XLSX 경로 → {"parcels": [...], "row_count": int}.
|
||||||
|
|
||||||
parcels 는 store.group_parcels 결과(1박스=1항목). 실패 시 ParseError.
|
parcels 는 store.group_parcels 결과(1박스=1항목). 실패 시 ParseError.
|
||||||
Shopee 는 SKU/수량이 product_info 한 셀에 묶여 있어 별도 경로로 파싱한다.
|
- Shopee: SKU/수량이 product_info 한 셀에 묶여 있어 별도 경로.
|
||||||
|
- Manual: 받는 사람/상품/수량이 첫 시트 한 행에 있는 수동 오더(1행=1박스).
|
||||||
"""
|
"""
|
||||||
if (platform or "").strip() == store.PLATFORM_SHOPEE:
|
p = (platform or "").strip()
|
||||||
|
if p == store.PLATFORM_SHOPEE:
|
||||||
return _parse_shopee(path)
|
return _parse_shopee(path)
|
||||||
|
if p == store.PLATFORM_MANUAL:
|
||||||
|
return _parse_manual(path)
|
||||||
return _parse_generic(path)
|
return _parse_generic(path)
|
||||||
|
|
||||||
|
|
||||||
|
# 메뉴얼 오더 엑셀 컬럼 별칭(헤더 정규화 후 비교). 받는 사람 정보가 엑셀에 직접 있다.
|
||||||
|
_MANUAL_ALIASES: dict[str, tuple[str, ...]] = {
|
||||||
|
"recipient_name": ("receiver name", "recipient name", "recipient", "받는 사람", "받는사람", "수취인", "이름", "name"),
|
||||||
|
"recipient_phone": ("phone", "phone number", "phone #", "contact", "전화", "전화번호", "연락처"),
|
||||||
|
"recipient_address": ("address", "delivery address", "주소", "배송지", "배송주소"),
|
||||||
|
"product_name": ("product name", "item name", "product", "상품명", "상품이름"),
|
||||||
|
"seller_sku": ("item code", "seller sku", "sku", "아이템코드", "아이템 코드", "상품코드", "코드"),
|
||||||
|
"quantity": ("quantity", "qty", "수량", "주문수량"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_manual(path: str) -> dict[str, Any]:
|
||||||
|
"""메뉴얼 오더 엑셀(첫 시트). 1행 = 1박스.
|
||||||
|
|
||||||
|
컬럼: Receiver Name / Phone / Address / Product Name / Item Code / Quantity.
|
||||||
|
받는 사람 정보가 엑셀에 직접 있어 PDF 없이 카드/엑셀에 표시된다. 묶음키
|
||||||
|
(Order/Tracking/Package)가 없으므로 행마다 별도 박스(seq)로 만든다.
|
||||||
|
"""
|
||||||
|
all_rows = _load_rows(path)
|
||||||
|
|
||||||
|
header_idx = None
|
||||||
|
mapping: dict[str, int] = {}
|
||||||
|
for idx, raw in enumerate(all_rows[:_HEADER_SCAN_ROWS]):
|
||||||
|
if raw is None:
|
||||||
|
continue
|
||||||
|
m = {
|
||||||
|
key: store.find_column(raw, aliases)
|
||||||
|
for key, aliases in _MANUAL_ALIASES.items()
|
||||||
|
}
|
||||||
|
# 최소: 아이템코드 컬럼이 잡혀야 한다.
|
||||||
|
if m.get("seller_sku") is not None:
|
||||||
|
header_idx = idx
|
||||||
|
mapping = {k: v for k, v in m.items() if v is not None}
|
||||||
|
break
|
||||||
|
|
||||||
|
if header_idx is None:
|
||||||
|
raise ParseError(
|
||||||
|
"메뉴얼 오더 엑셀에서 컬럼을 찾지 못했습니다. "
|
||||||
|
"필요 컬럼: Item Code(필수), Receiver Name, Phone, Address, Product Name, Quantity"
|
||||||
|
)
|
||||||
|
|
||||||
|
def cell(raw: Any, key: str) -> str:
|
||||||
|
idx = mapping.get(key)
|
||||||
|
if idx is None or idx >= len(raw):
|
||||||
|
return ""
|
||||||
|
return store.clean_text(raw[idx])
|
||||||
|
|
||||||
|
parcels: list[dict[str, Any]] = []
|
||||||
|
seq = 0
|
||||||
|
for raw in all_rows[header_idx + 1:]:
|
||||||
|
if raw is None:
|
||||||
|
continue
|
||||||
|
sku = cell(raw, "seller_sku")
|
||||||
|
name = cell(raw, "recipient_name")
|
||||||
|
if not sku and not name:
|
||||||
|
continue # 빈 행
|
||||||
|
if not sku:
|
||||||
|
continue # 아이템코드 없으면 출고 대상 아님
|
||||||
|
seq += 1
|
||||||
|
parcels.append(
|
||||||
|
{
|
||||||
|
"seq": seq,
|
||||||
|
"order_id": "",
|
||||||
|
"package_id": "",
|
||||||
|
"tracking_id": "",
|
||||||
|
"shipping_provider": "",
|
||||||
|
"recipient_name": name,
|
||||||
|
"recipient_phone": cell(raw, "recipient_phone"),
|
||||||
|
"recipient_address": cell(raw, "recipient_address"),
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"seller_sku": sku,
|
||||||
|
"product_name": cell(raw, "product_name"),
|
||||||
|
"quantity": store.normalize_quantity(cell(raw, "quantity")),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"parcels": parcels, "row_count": len(parcels)}
|
||||||
|
|
||||||
|
|
||||||
def _load_rows(path: str) -> list:
|
def _load_rows(path: str) -> list:
|
||||||
"""첫 시트 전체 행(values_only). 열기/빈 파일 예외는 ParseError 로."""
|
"""첫 시트 전체 행(values_only). 열기/빈 파일 예외는 ParseError 로."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ router = APIRouter(prefix="/dispatch", tags=["dispatch"])
|
|||||||
_DATA_SLOTS: dict[str, tuple[str, tuple[str, ...]]] = {
|
_DATA_SLOTS: dict[str, tuple[str, tuple[str, ...]]] = {
|
||||||
store.PLATFORM_TIKTOK: ("03_TikTok_Order_Export.xlsx", (".xlsx",)),
|
store.PLATFORM_TIKTOK: ("03_TikTok_Order_Export.xlsx", (".xlsx",)),
|
||||||
store.PLATFORM_SHOPEE: ("Packing_List.Doorstep_Delivery.xlsx", (".xlsx",)),
|
store.PLATFORM_SHOPEE: ("Packing_List.Doorstep_Delivery.xlsx", (".xlsx",)),
|
||||||
|
store.PLATFORM_MANUAL: ("Manual_Order.xlsx", (".xlsx",)),
|
||||||
}
|
}
|
||||||
|
# 라벨(PDF) 슬롯이 없는 플랫폼(Manual)은 받는 사람 정보가 데이터 엑셀에 직접 있다.
|
||||||
_LABEL_SLOTS: dict[str, tuple[str, tuple[str, ...]]] = {
|
_LABEL_SLOTS: dict[str, tuple[str, tuple[str, ...]]] = {
|
||||||
store.PLATFORM_TIKTOK: ("02_Shipping_Label_Packing_Slip.pdf", (".pdf",)),
|
store.PLATFORM_TIKTOK: ("02_Shipping_Label_Packing_Slip.pdf", (".pdf",)),
|
||||||
store.PLATFORM_SHOPEE: ("Shopee_Seller_Centre.pdf", (".pdf",)),
|
store.PLATFORM_SHOPEE: ("Shopee_Seller_Centre.pdf", (".pdf",)),
|
||||||
@@ -259,7 +261,7 @@ async def batch_create(
|
|||||||
if platform not in _DATA_SLOTS:
|
if platform not in _DATA_SLOTS:
|
||||||
raise HTTPException(status_code=400, detail=f"지원하지 않는 플랫폼입니다: {platform}")
|
raise HTTPException(status_code=400, detail=f"지원하지 않는 플랫폼입니다: {platform}")
|
||||||
data_slot = _DATA_SLOTS[platform]
|
data_slot = _DATA_SLOTS[platform]
|
||||||
label_slot = _LABEL_SLOTS[platform]
|
label_slot = _LABEL_SLOTS.get(platform) # Manual 은 라벨 없음(None)
|
||||||
|
|
||||||
if data_xlsx is None or not (data_xlsx.filename or "").strip():
|
if data_xlsx is None or not (data_xlsx.filename or "").strip():
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -272,7 +274,7 @@ async def batch_create(
|
|||||||
stamp = now_kst().strftime("%H%M%S")
|
stamp = now_kst().strftime("%H%M%S")
|
||||||
dest_dir = _data_dir(request) / "dispatch" / dispatch_date.strip() / f"{_slugify(name)}_{stamp}"
|
dest_dir = _data_dir(request) / "dispatch" / dispatch_date.strip() / f"{_slugify(name)}_{stamp}"
|
||||||
|
|
||||||
label_path = _save_upload(label_pdf, dest_dir=dest_dir, slot=label_slot)
|
label_path = _save_upload(label_pdf, dest_dir=dest_dir, slot=label_slot) if label_slot else ""
|
||||||
order_path = _save_upload(data_xlsx, dest_dir=dest_dir, slot=data_slot)
|
order_path = _save_upload(data_xlsx, dest_dir=dest_dir, slot=data_slot)
|
||||||
|
|
||||||
# 엑셀 파싱 — 실패 시 사용자에게 한국어 메시지 그대로 보여준다.
|
# 엑셀 파싱 — 실패 시 사용자에게 한국어 메시지 그대로 보여준다.
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ STATUS_LABELS: dict[str, dict[str, str]] = {
|
|||||||
# 주문처(엑셀 '주문처' 컬럼)이자 업로드 슬롯/파서 선택의 기준값.
|
# 주문처(엑셀 '주문처' 컬럼)이자 업로드 슬롯/파서 선택의 기준값.
|
||||||
PLATFORM_TIKTOK = "TikTok"
|
PLATFORM_TIKTOK = "TikTok"
|
||||||
PLATFORM_SHOPEE = "Shopee"
|
PLATFORM_SHOPEE = "Shopee"
|
||||||
PLATFORMS: tuple[str, ...] = (PLATFORM_TIKTOK, PLATFORM_SHOPEE)
|
PLATFORM_MANUAL = "Manual"
|
||||||
|
PLATFORMS: tuple[str, ...] = (PLATFORM_TIKTOK, PLATFORM_SHOPEE, PLATFORM_MANUAL)
|
||||||
|
|
||||||
|
|
||||||
# ── 엑셀 컬럼명(앞뒤 공백 제거 후, 소문자 비교). 표준키 → 가능한 헤더들 ──
|
# ── 엑셀 컬럼명(앞뒤 공백 제거 후, 소문자 비교). 표준키 → 가능한 헤더들 ──
|
||||||
|
|||||||
@@ -89,14 +89,18 @@
|
|||||||
<span class="dsp-courier-text erp-badge">{{ p.shipping_provider }}</span>
|
<span class="dsp-courier-text erp-badge">{{ p.shipping_provider }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="dsp-no">No. {{ p.seq }}</div>
|
<div class="dsp-no">No. {{ p.seq }}</div>
|
||||||
<div class="dsp-order"><b>Order ID</b><span>{{ p.order_id or '—' }}</span></div>
|
{% if p.order_id %}
|
||||||
|
<div class="dsp-order"><b>Order ID</b><span>{{ p.order_id }}</span></div>
|
||||||
|
{% endif %}
|
||||||
|
{% if p.tracking_id %}
|
||||||
<div class="dsp-meta">
|
<div class="dsp-meta">
|
||||||
<div><b>Tracking</b>
|
<div><b>Tracking</b>
|
||||||
{% set turl = p.tracking_id | courier_track(p.shipping_provider) %}
|
{% set turl = p.tracking_id | courier_track(p.shipping_provider) %}
|
||||||
{% if turl %}<a class="dsp-track-link" href="{{ turl }}" target="_blank" rel="noopener noreferrer">{{ p.tracking_id }}</a>
|
{% if turl %}<a class="dsp-track-link" href="{{ turl }}" target="_blank" rel="noopener noreferrer">{{ p.tracking_id }}</a>
|
||||||
{% else %}{{ p.tracking_id or '—' }}{% endif %}
|
{% else %}{{ p.tracking_id }}{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div class="dsp-recipient">
|
<div class="dsp-recipient">
|
||||||
<span class="dsp-rname">{{ p.recipient_name or '—' }}</span>
|
<span class="dsp-rname">{{ p.recipient_name or '—' }}</span>
|
||||||
{% if p.recipient_address %}<div class="dsp-raddr">{{ p.recipient_address }}</div>{% endif %}
|
{% if p.recipient_address %}<div class="dsp-raddr">{{ p.recipient_address }}</div>{% endif %}
|
||||||
@@ -104,7 +108,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<ul class="dsp-items">
|
<ul class="dsp-items">
|
||||||
{% for it in p['items'] %}
|
{% for it in p['items'] %}
|
||||||
<li>{{ it.seller_sku }} <span class="qty">× {{ it.quantity }}</span></li>
|
<li>{% if batch.platform == 'Manual' %}{{ it.product_name or it.seller_sku }}{% else %}{{ it.seller_sku }}{% endif %} <span class="qty">× {{ it.quantity }}</span></li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li class="erp-muted" style="font-weight:400;" data-ko="상품 없음" data-en="No items">상품 없음</li>
|
<li class="erp-muted" style="font-weight:400;" data-ko="상품 없음" data-en="No items">상품 없음</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
placeholder="오전 출고 / 오후 출고 등으로 구분" />
|
placeholder="오전 출고 / 오후 출고 등으로 구분" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label style="display:flex;flex-direction:column;gap:4px;">
|
<label id="dsp-label-row" style="display:flex;flex-direction:column;gap:4px;">
|
||||||
<span id="dsp-label-lbl"></span>
|
<span id="dsp-label-lbl"></span>
|
||||||
<input class="erp-input" type="file" name="label_pdf" id="dsp-label" accept="application/pdf" />
|
<input class="erp-input" type="file" name="label_pdf" id="dsp-label" accept="application/pdf" />
|
||||||
</label>
|
</label>
|
||||||
@@ -66,6 +66,11 @@
|
|||||||
intro: "Shopee: Packing List.Doorstep Delivery.xlsx 로 출고 리스트가 생성됩니다. 받는 사람 이름/주소는 Shopee Seller Centre.pdf 에서 가져오니 PDF 도 함께 올리세요.",
|
intro: "Shopee: Packing List.Doorstep Delivery.xlsx 로 출고 리스트가 생성됩니다. 받는 사람 이름/주소는 Shopee Seller Centre.pdf 에서 가져오니 PDF 도 함께 올리세요.",
|
||||||
label: "📄 Shopee Seller Centre.pdf <span class='erp-muted'>(받는 사람 이름/주소 추출 · 권장)</span>",
|
label: "📄 Shopee Seller Centre.pdf <span class='erp-muted'>(받는 사람 이름/주소 추출 · 권장)</span>",
|
||||||
data: "📊 Packing List.Doorstep Delivery.xlsx <span style='color:#dc2626;'>*</span> <span class='erp-muted'>(자동 출고 리스트 기준)</span>"
|
data: "📊 Packing List.Doorstep Delivery.xlsx <span style='color:#dc2626;'>*</span> <span class='erp-muted'>(자동 출고 리스트 기준)</span>"
|
||||||
|
},
|
||||||
|
"Manual": {
|
||||||
|
intro: "메뉴얼 오더: 엑셀 파일만 올리세요. 첫 시트의 받는 사람/전화/주소/상품/수량을 읽어 출고 리스트를 만듭니다(PDF 불필요).",
|
||||||
|
label: "",
|
||||||
|
data: "📊 Manual Order.xlsx <span style='color:#dc2626;'>*</span> <span class='erp-muted'>(받는사람·상품·수량 포함)</span>"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
@@ -78,6 +83,11 @@
|
|||||||
intro: "Shopee: the dispatch list is built from Packing List.Doorstep Delivery.xlsx. Recipient name/address come from Shopee Seller Centre.pdf, so please upload the PDF too.",
|
intro: "Shopee: the dispatch list is built from Packing List.Doorstep Delivery.xlsx. Recipient name/address come from Shopee Seller Centre.pdf, so please upload the PDF too.",
|
||||||
label: "📄 Shopee Seller Centre.pdf <span class='erp-muted'>(recipient name/address · recommended)</span>",
|
label: "📄 Shopee Seller Centre.pdf <span class='erp-muted'>(recipient name/address · recommended)</span>",
|
||||||
data: "📊 Packing List.Doorstep Delivery.xlsx <span style='color:#dc2626;'>*</span> <span class='erp-muted'>(dispatch list source)</span>"
|
data: "📊 Packing List.Doorstep Delivery.xlsx <span style='color:#dc2626;'>*</span> <span class='erp-muted'>(dispatch list source)</span>"
|
||||||
|
},
|
||||||
|
"Manual": {
|
||||||
|
intro: "Manual order: upload the Excel file only. Recipient/phone/address/product/qty are read from the first sheet (no PDF needed).",
|
||||||
|
label: "",
|
||||||
|
data: "📊 Manual Order.xlsx <span style='color:#dc2626;'>*</span> <span class='erp-muted'>(includes recipient, product, qty)</span>"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -88,6 +98,8 @@
|
|||||||
document.getElementById('dsp-intro').textContent = c.intro;
|
document.getElementById('dsp-intro').textContent = c.intro;
|
||||||
document.getElementById('dsp-label-lbl').innerHTML = c.label;
|
document.getElementById('dsp-label-lbl').innerHTML = c.label;
|
||||||
document.getElementById('dsp-data-lbl').innerHTML = c.data;
|
document.getElementById('dsp-data-lbl').innerHTML = c.data;
|
||||||
|
// 라벨(PDF) 슬롯이 없는 플랫폼(Manual)은 라벨 업로드 칸을 숨긴다.
|
||||||
|
document.getElementById('dsp-label-row').style.display = c.label ? '' : 'none';
|
||||||
}
|
}
|
||||||
sel.addEventListener('change', apply);
|
sel.addEventListener('change', apply);
|
||||||
document.addEventListener('dsp:lang', apply); // 언어 토글 시 재적용
|
document.addEventListener('dsp:lang', apply); // 언어 토글 시 재적용
|
||||||
|
|||||||
Reference in New Issue
Block a user