diff --git a/app/modules/dispatch/db.py b/app/modules/dispatch/db.py index 8b75722..09d9a6d 100644 --- a/app/modules/dispatch/db.py +++ b/app/modules/dispatch/db.py @@ -286,6 +286,29 @@ class DispatchStore: ) return len(ids) + def stock_aggregate_for_date(self, *, dispatch_date: str) -> list[dict[str, Any]]: + """해당 날짜 모든 배치의 SKU별 합산 수량. + + 반환: [{"seller_sku", "qty"}]. _N 배수/콤보 분해는 호출부(export)에서 적용. + """ + with self._pool.connection() as conn: + rows = conn.execute( + """ + SELECT i.seller_sku, SUM(i.quantity)::int AS qty + FROM dispatch_items i + JOIN dispatch_parcels p ON p.id = i.parcel_id + JOIN dispatch_batches b ON b.id = p.batch_id + WHERE b.dispatch_date = %s + GROUP BY i.seller_sku + ORDER BY i.seller_sku ASC + """, + (dispatch_date,), + ).fetchall() + return [ + {"seller_sku": r["seller_sku"] or "", "qty": int(r["qty"] or 0)} + for r in rows + ] + def parcel_batch_id(self, *, parcel_id: int) -> int | None: with self._pool.connection() as conn: row = conn.execute( diff --git a/app/modules/dispatch/export.py b/app/modules/dispatch/export.py index 2fbddfd..931ce8e 100644 --- a/app/modules/dispatch/export.py +++ b/app/modules/dispatch/export.py @@ -136,6 +136,90 @@ def build_workbook_bytes( return buf.getvalue() +# ── 사방넷 재고 업로드 엑셀(낱개 분해) ── +_STOCK_HEADERS: tuple[str, ...] = ("상품코드[필수]", "가용수량", "불용수량", "바코드") +_LID_PREFIX = "MD-" # 뚜껑 — 재고 집계 제외 + + +def explode_to_singles( + rows: list[dict[str, Any]], + set_bom: dict[str, list[dict[str, Any]]], +) -> dict[str, int]: + """SKU별 합산 행 → 낱개 코드별 수량. + + - seller_sku 의 _N 접미사는 배수(같은 상품 N개)로 곱한다. + - 콤보(세트, set_bom 에 존재)는 구성 낱개로 분해해 (배수 × 구성수량) 더한다. + - MD-(뚜껑)은 재고 집계에서 제외한다. + 반환: {낱개코드(대문자): 수량}. + """ + singles: dict[str, int] = {} + for r in rows: + base, mult = split_sku(r.get("seller_sku", "")) + base = base.upper() + if not base: + continue + pieces = int(r.get("qty", 0)) * mult + if pieces <= 0 or base.startswith(_LID_PREFIX): + continue + comps = set_bom.get(base) + if comps: # 콤보 → 낱개 분해 + for c in comps: + cc = str(c.get("component_code", "")).strip().upper() + if not cc or cc.startswith(_LID_PREFIX): + continue + singles[cc] = singles.get(cc, 0) + pieces * int(c.get("component_qty", 0) or 0) + else: + singles[base] = singles.get(base, 0) + pieces + return {k: v for k, v in singles.items() if v > 0} + + +def build_stock_xlsx( + *, + rows: list[dict[str, Any]], + set_bom: dict[str, list[dict[str, Any]]], + sabangnet_map: dict[str, str], +) -> bytes: + """사방넷 재고 업로드용 xlsx(낱개 분해). 4열: 상품코드[필수]/가용수량/불용수량/바코드. + + A=사방넷코드, B=수량, C=0(불용), D=아이템코드(바코드). 코드 오름차순. + """ + from openpyxl import Workbook # noqa: WPS433 + from openpyxl.styles import Alignment, Font, PatternFill + + singles = explode_to_singles(rows, set_bom) + + wb = Workbook() + ws = wb.active + ws.title = "재고" + ws.append(list(_STOCK_HEADERS)) + head_fill = PatternFill("solid", fgColor="C55A11") # 사방넷 템플릿 주황 + head_font = Font(bold=True, color="FFFFFF") + for cell in ws[1]: + cell.fill = head_fill + cell.font = head_font + cell.alignment = Alignment(horizontal="center", vertical="center") + + for code in sorted(singles): + sabangnet = sabangnet_map.get(code, "") + ws.append([sabangnet, singles[code], 0, code]) + + widths = [22, 12, 12, 18] + from openpyxl.utils import get_column_letter + for i, w in enumerate(widths, start=1): + ws.column_dimensions[get_column_letter(i)].width = w + + buf = io.BytesIO() + wb.save(buf) + return buf.getvalue() + + +def stock_filename(dispatch_date: str) -> str: + """재고 엑셀 파일명: 2026.06.22(Mon)_sabangnet.xlsx.""" + d = _parse_date(dispatch_date) + stamp = d.strftime("%Y.%m.%d(%a)") if d else (dispatch_date or "stock").strip() + return f"{stamp}_sabangnet.xlsx" + + def _cell(value: Any) -> Any: """송장/전화번호 등이 숫자로 보이면 엑셀이 과학표기/반올림 하므로 문자열 보존.""" if isinstance(value, int): diff --git a/app/modules/dispatch/router.py b/app/modules/dispatch/router.py index 30ce37c..e41682c 100644 --- a/app/modules/dispatch/router.py +++ b/app/modules/dispatch/router.py @@ -76,6 +76,24 @@ def _itemcode_name_map(request: Request) -> dict[str, str]: return {} +def _itemcode_bom_and_sabangnet(request: Request) -> tuple[dict[str, Any], dict[str, str]]: + """(세트 BOM map, item_code→사방넷코드 map). 미설정/실패 시 빈 dict.""" + reader = getattr(request.app.state, "malaysia_itemcode", None) + if reader is None or not getattr(reader, "enabled", False): + return {}, {} + bom: dict[str, Any] = {} + sab: dict[str, str] = {} + try: + bom = reader.set_bom_map() + except Exception: # noqa: BLE001 + logger.exception("itemcode set_bom_map 조회 실패") + try: + sab = reader.sabangnet_code_map() + except Exception: # noqa: BLE001 + logger.exception("itemcode sabangnet_code_map 조회 실패") + return bom, sab + + def _require_user(request: Request) -> dict[str, Any]: from app.main import get_current_user_record # noqa: WPS433 from app.store import has_module # noqa: WPS433 @@ -180,12 +198,14 @@ async def batches_index(request: Request) -> HTMLResponse: if not isinstance(guard, tuple): return guard st, user = guard + batches = st.list_batches() ctx = _base_ctx(request, user) ctx.update( { "page_title": "말레이시아 배송 — 출고 배치", "page_subtitle": "TikTok 일일 출고 배치 목록", - "batches": st.list_batches(), + "batches": batches, + "batch_dates": sorted({b["dispatch_date"] for b in batches if b.get("dispatch_date")}), "active_tab": "batches", } ) @@ -382,6 +402,42 @@ async def batch_download( ) +@router.get("/stock-export") +async def stock_export( + request: Request, date: str, user: dict[str, Any] = Depends(_require_user) +) -> Response: + """선택한 날짜의 전 배치 출고 수량을 낱개로 분해해 사방넷 재고 업로드 xlsx 다운로드. + + 콤보(세트)는 BOM 으로 낱개 분해, _N 배수 반영, MD-(뚜껑) 제외. + A=사방넷코드 / B=수량 / C=불용수량(0) / D=아이템코드. + """ + st = _store(request) + if st is None: + raise HTTPException(status_code=503, detail="dispatch_db 미설정") + d = (date or "").strip() + if not d: + raise HTTPException(status_code=400, detail="날짜가 필요합니다.") + + rows = st.stock_aggregate_for_date(dispatch_date=d) + if not rows: + raise HTTPException(status_code=404, detail="해당 날짜의 출고 데이터가 없습니다.") + + set_bom, sabangnet_map = _itemcode_bom_and_sabangnet(request) + xlsx_bytes = export.build_stock_xlsx(rows=rows, set_bom=set_bom, sabangnet_map=sabangnet_map) + fname = export.stock_filename(d) + headers = { + "Content-Disposition": ( + f"attachment; filename=\"stock_{d}.xlsx\"; " + f"filename*=UTF-8''{quote(fname)}" + ) + } + return Response( + content=xlsx_bytes, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers=headers, + ) + + # ════════════════════════════════════════════════════════════ # 출고 작업 리스트 (1박스 = 1카드) # ════════════════════════════════════════════════════════════ diff --git a/app/modules/dispatch/templates/dispatch/batches.html b/app/modules/dispatch/templates/dispatch/batches.html index 2b12f66..560f64b 100644 --- a/app/modules/dispatch/templates/dispatch/batches.html +++ b/app/modules/dispatch/templates/dispatch/batches.html @@ -8,6 +8,18 @@ /* 플랫폼 로고: 아이콘마다 여백이 달라 시각 크기를 맞춘다 */ .dsp-pf-logo { height:26px;width:auto;max-width:120px;vertical-align:middle;object-fit:contain; } .dsp-pf-logo[src*="shopee"] { height:34px; } + /* 좌(리스트) / 우(달력) 2단 */ + .dsp-layout { display:flex;gap:16px;align-items:flex-start;flex-wrap:wrap; } + .dsp-list { flex:1 1 620px;min-width:320px; } + .dsp-calwrap { flex:0 1 340px;min-width:280px; } + /* 달력 */ + .dsp-cal-head { display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px; } + .dsp-cal-grid { display:grid;grid-template-columns:repeat(7,1fr);gap:4px; } + .dsp-cal-wd { text-align:center;font-size:12px;color:#94a3b8;padding:2px 0;font-weight:600; } + .dsp-cal-day { text-align:center;padding:8px 0;border-radius:8px;font-size:14px;color:#cbd5e1; } + .dsp-cal-day.has { color:#0f172a;background:#eef2ff;font-weight:700;cursor:pointer;border:1px solid #c7d2fe; } + .dsp-cal-day.has:hover { background:#e0e7ff; } + .dsp-cal-day.sel { background:#16a34a !important;border-color:#16a34a !important;color:#fff !important; } {% endblock %} @@ -15,7 +27,8 @@
{% include "dispatch/_nav.html" %} -
+
+

출고 배치 ({{ batches|length }})

+ 새 업로드 @@ -72,5 +85,101 @@
+ +
+

재고 다운로드 (사방넷)

+

날짜를 클릭하면 그날 출고를 낱개로 분해한 사방넷 재고 엑셀을 받습니다.

+ +
+
+ + + +
+
+
+ +
+
+ 선택한 날짜: + +
+ 📥 사방넷 재고 엑셀 다운로드 +
+
+
{% endblock %} + +{% block scripts %} + +{% endblock %}