diff --git a/app/modules/dispatch/db.py b/app/modules/dispatch/db.py index 09d9a6d..4940005 100644 --- a/app/modules/dispatch/db.py +++ b/app/modules/dispatch/db.py @@ -309,6 +309,55 @@ class DispatchStore: for r in rows ] + # ════════════════════════════════════════════════════════════ + # 말레이시아 재고 차감 1회성 가드 + # ════════════════════════════════════════════════════════════ + def deduction_exists(self, *, dispatch_date: str) -> bool: + with self._pool.connection() as conn: + row = conn.execute( + "SELECT 1 FROM dispatch_stock_deductions WHERE dispatch_date = %s", + (dispatch_date,), + ).fetchone() + return row is not None + + def list_deducted_dates(self) -> list[str]: + with self._pool.connection() as conn: + rows = conn.execute( + "SELECT dispatch_date FROM dispatch_stock_deductions " + "ORDER BY dispatch_date DESC" + ).fetchall() + out: list[str] = [] + for r in rows: + d = r["dispatch_date"] + out.append(d.isoformat() if isinstance(d, date) else str(d)) + return out + + def record_deduction( + self, + *, + dispatch_date: str, + warehouse_code: str, + single_lines: int, + combo_lines: int, + worker_name: str = "", + ) -> None: + """차감 완료 기록. 이미 있으면 충돌(재차감 차단).""" + with self._pool.connection() as conn: + conn.execute( + """ + INSERT INTO dispatch_stock_deductions + (dispatch_date, warehouse_code, single_lines, combo_lines, worker_name) + VALUES (%s,%s,%s,%s,%s) + """, + ( + dispatch_date, + (warehouse_code or "").strip(), + int(single_lines), + int(combo_lines), + (worker_name or "").lower().strip(), + ), + ) + 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 5c37f58..255d81d 100644 --- a/app/modules/dispatch/export.py +++ b/app/modules/dispatch/export.py @@ -139,6 +139,32 @@ def build_workbook_bytes( # ── 사방넷 재고 업로드 엑셀(낱개 분해) ── _STOCK_HEADERS: tuple[str, ...] = ("상품코드[필수]", "가용수량", "불용수량", "바코드") _LID_PREFIX = "MD-" # 뚜껑 — 재고 집계 제외 +_SET_PREFIX = "MY-" # 콤보(세트) + + +def split_singles_combos( + rows: list[dict[str, Any]], +) -> tuple[dict[str, int], dict[str, int]]: + """SKU별 합산 행 → (낱개 {code: qty}, 콤보 {code: qty}). + + _N 배수 반영, MD-(뚜껑) 제외. 콤보는 MY- 접두사. 분해는 하지 않는다 + (말레이시아 재고관리가 세트 OUT 시 BOM 으로 분해하므로 콤보 그대로 넘긴다). + """ + singles: dict[str, int] = {} + combos: 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 + if base.startswith(_SET_PREFIX): + combos[base] = combos.get(base, 0) + pieces + else: + singles[base] = singles.get(base, 0) + pieces + return singles, combos def explode_to_singles( diff --git a/app/modules/dispatch/router.py b/app/modules/dispatch/router.py index e41682c..f644425 100644 --- a/app/modules/dispatch/router.py +++ b/app/modules/dispatch/router.py @@ -206,6 +206,7 @@ async def batches_index(request: Request) -> HTMLResponse: "page_subtitle": "TikTok 일일 출고 배치 목록", "batches": batches, "batch_dates": sorted({b["dispatch_date"] for b in batches if b.get("dispatch_date")}), + "deducted_dates": st.list_deducted_dates(), "active_tab": "batches", } ) @@ -438,6 +439,91 @@ async def stock_export( ) +@router.post("/stock-deduct") +async def stock_deduct( + request: Request, + date: str = Form(...), + user: dict[str, Any] = Depends(_require_user), +) -> JSONResponse: + """선택 날짜의 출고를 말레이시아 재고관리에 OUT 으로 반영(1회성). + + - 낱개(MT/MX/MZ)는 낱개 OUT, 콤보(MY-)는 세트 OUT(BOM 으로 낱개 분해). + - _N 배수 반영, MD-(뚜껑) 제외. movement_date = 해당 날짜. + - dispatch_stock_deductions 로 날짜당 1회만 — 재실행 시 재고 이중 차감 방지. + """ + 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="날짜가 필요합니다.") + + if st.deduction_exists(dispatch_date=d): + raise HTTPException(status_code=409, detail=f"{d} 는 이미 재고 차감되었습니다.") + + ms = getattr(request.app.state, "malaysia_store", None) + if ms is None: + raise HTTPException(status_code=503, detail="말레이시아 재고관리(MALAYSIA_STOCK_DB_URL) 미설정") + + # 낱개/콤보 분리(_N 배수, MD- 제외) + rows = st.stock_aggregate_for_date(dispatch_date=d) + singles, combos = export.split_singles_combos(rows) + if not singles and not combos: + raise HTTPException(status_code=404, detail="해당 날짜의 출고 데이터가 없습니다.") + + # 창고 결정(첫 활성 창고) + warehouses = ms.list_warehouses() + if not warehouses: + raise HTTPException(status_code=400, detail="등록된 창고가 없습니다.") + wh = warehouses[0]["warehouse_code"] + + # 콤보 BOM 사전검증(누락 시 아무 것도 쓰지 않고 중단) + bom_map, _sab = _itemcode_bom_and_sabangnet(request) + missing = [c for c in combos if c not in bom_map] + if missing: + raise HTTPException( + status_code=400, + detail="콤보 BOM 이 없어 차감할 수 없습니다(세트구성 먼저 등록): " + ", ".join(sorted(missing)), + ) + + # 마커 선점(레이스/재실행 차단). 충돌 시 이미 차감됨. + try: + st.record_deduction( + dispatch_date=d, warehouse_code=wh, + single_lines=len(singles), combo_lines=len(combos), + worker_name=user.get("email", ""), + ) + except Exception: # noqa: BLE001 — unique 위반 등 = 이미 차감 + raise HTTPException(status_code=409, detail=f"{d} 는 이미 재고 차감되었습니다.") + + # 실제 OUT 반영. 실패해도 마커는 남겨 재고 이중 차감을 막는다(원인은 로그). + memo = f"배송 출고 {d}" + try: + for code, qty in combos.items(): + ms.create_set_out( + movement_date=d, warehouse_code=wh, set_code=code, set_qty=qty, + bom_map=bom_map, ref_type="dispatch", ref_no=d, memo=memo, + created_by=user.get("email", ""), + ) + for code, qty in singles.items(): + ms.create_movement( + movement_date=d, warehouse_code=wh, item_code=code, + movement_type="OUT", qty=qty, ref_type="dispatch", ref_no=d, + memo=memo, created_by=user.get("email", ""), + ) + except Exception as exc: # noqa: BLE001 + logger.exception("재고 차감 중 오류 date=%s", d) + raise HTTPException( + status_code=500, + detail=f"일부 반영 중 오류가 발생했습니다(재차감 방지를 위해 차감완료로 표시됨). 재고관리 이동이력을 확인하세요: {type(exc).__name__}", + ) + + return JSONResponse({ + "ok": True, "date": d, "warehouse": wh, + "singles": len(singles), "combos": len(combos), + }) + + # ════════════════════════════════════════════════════════════ # 출고 작업 리스트 (1박스 = 1카드) # ════════════════════════════════════════════════════════════ diff --git a/app/modules/dispatch/templates/dispatch/batches.html b/app/modules/dispatch/templates/dispatch/batches.html index dd7822f..a1fc865 100644 --- a/app/modules/dispatch/templates/dispatch/batches.html +++ b/app/modules/dispatch/templates/dispatch/batches.html @@ -109,6 +109,10 @@ 📥 사방넷 출고 엑셀 다운로드 + +
@@ -120,6 +124,8 @@ (function () { // 배치가 있는 날짜(YYYY-MM-DD) var BATCH_DATES = {{ batch_dates|tojson }}; + var DEDUCTED = {}; + ({{ deducted_dates|tojson }}).forEach(function (d) { DEDUCTED[d] = true; }); var dateSet = {}; BATCH_DATES.forEach(function (d) { dateSet[d] = true; }); @@ -127,6 +133,8 @@ var title = document.getElementById('dsp-cal-title'); var selLabel = document.getElementById('dsp-cal-sel'); var dlBtn = document.getElementById('dsp-cal-dl'); + var dedBtn = document.getElementById('dsp-cal-deduct'); + var dedNote = document.getElementById('dsp-cal-deduct-note'); var selected = null; // 초기 표시 월: 가장 최근 배치 날짜 기준(없으면 오늘) @@ -169,16 +177,62 @@ dlBtn.href = '/dispatch/stock-export?date=' + encodeURIComponent(iso); dlBtn.style.pointerEvents = ''; dlBtn.style.opacity = ''; + updateDeductBtn(); render(); } + function updateDeductBtn() { + var en = (window.dspLang && window.dspLang() === 'en'); + if (!selected) { + dedBtn.style.pointerEvents = 'none'; dedBtn.style.opacity = '.5'; + dedNote.textContent = ''; + return; + } + if (DEDUCTED[selected]) { + dedBtn.style.pointerEvents = 'none'; dedBtn.style.opacity = '.5'; + dedNote.textContent = en ? 'Already deducted (cannot run again).' : '이미 차감됨 (재실행 불가).'; + } else { + dedBtn.style.pointerEvents = ''; dedBtn.style.opacity = ''; + dedNote.textContent = ''; + } + } + + window.dspDeduct = function () { + if (!selected || DEDUCTED[selected]) return; + var en = (window.dspLang && window.dspLang() === 'en'); + var msg = en + ? ('Deduct Malaysia stock for ' + selected + '? This runs ONCE only and cannot be undone.') + : (selected + ' 출고를 말레이시아 재고에서 차감할까요?\n한 번만 실행되며 되돌릴 수 없습니다.'); + if (!confirm(msg)) return; + dedBtn.style.pointerEvents = 'none'; dedBtn.style.opacity = '.5'; + dedNote.textContent = en ? 'Processing…' : '처리 중…'; + var body = new URLSearchParams(); body.set('date', selected); + fetch('/dispatch/stock-deduct', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }) + .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); }) + .then(function (res) { + if (!res.ok) throw new Error((res.j && res.j.detail) || '실패'); + DEDUCTED[selected] = true; + updateDeductBtn(); + alert((en ? 'Done. singles ' : '완료. 낱개 ') + res.j.singles + (en ? ', combos ' : '종, 콤보 ') + res.j.combos + (en ? '' : '종 반영')); + }) + .catch(function (e) { + dedNote.textContent = (e && e.message) || '실패'; + updateDeductBtn(); + alert((en ? 'Failed: ' : '실패: ') + ((e && e.message) || '')); + }); + }; + document.getElementById('dsp-cal-prev').onclick = function () { viewM--; if (viewM < 0) { viewM = 11; viewY--; } render(); }; document.getElementById('dsp-cal-next').onclick = function () { viewM++; if (viewM > 11) { viewM = 0; viewY++; } render(); }; - document.addEventListener('dsp:lang', render); + document.addEventListener('dsp:lang', function () { render(); updateDeductBtn(); }); render(); })(); diff --git a/scripts/sql/dispatch_add_deductions.sql b/scripts/sql/dispatch_add_deductions.sql new file mode 100644 index 0000000..32a0765 --- /dev/null +++ b/scripts/sql/dispatch_add_deductions.sql @@ -0,0 +1,27 @@ +-- ===================================================================== +-- dispatch_db 마이그레이션 — 말레이시아 재고 차감 1회성 기록 +-- ===================================================================== +-- 멱등(idempotent): 여러 번 실행해도 안전. +-- +-- 배경: 배송 배치의 그날 출고를 말레이시아 재고관리(OUT)로 1회만 반영하기 위해, +-- 날짜 단위로 차감 완료를 기록한다. 같은 날짜를 다시 차감하면 재고가 +-- 이중으로 빠지므로 이 표의 존재 여부로 재실행을 차단한다. +-- +-- 실행: +-- docker exec -i postgres-db psql -U postgres -d dispatch_db \ +-- < scripts/sql/dispatch_add_deductions.sql +-- ===================================================================== + +\set ON_ERROR_STOP on +\connect dispatch_db + +CREATE TABLE IF NOT EXISTS dispatch_stock_deductions ( + dispatch_date DATE PRIMARY KEY, + warehouse_code TEXT NOT NULL DEFAULT '', + single_lines INTEGER NOT NULL DEFAULT 0, + combo_lines INTEGER NOT NULL DEFAULT 0, + worker_name TEXT NOT NULL DEFAULT '', + deducted_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +SELECT 'dispatch_stock_deductions ready' AS status; diff --git a/scripts/sql/dispatch_db_init.sql b/scripts/sql/dispatch_db_init.sql index 0b5bcd6..71d0183 100644 --- a/scripts/sql/dispatch_db_init.sql +++ b/scripts/sql/dispatch_db_init.sql @@ -142,11 +142,25 @@ CREATE TABLE IF NOT EXISTS dispatch_logs ( CREATE INDEX IF NOT EXISTS idx_dispatch_logs_parcel ON dispatch_logs (parcel_id); -- ════════════════════════════════════════════════════════════ --- 5) 권한 (dispatch_app: CRUD only) +-- 5) 말레이시아 재고 차감 1회성 기록 (날짜 단위 멱등 가드) +-- 그날 출고를 말레이시아 재고관리(OUT)로 1회만 반영. 존재 시 재차감 차단. +-- ════════════════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS dispatch_stock_deductions ( + dispatch_date DATE PRIMARY KEY, + warehouse_code TEXT NOT NULL DEFAULT '', + single_lines INTEGER NOT NULL DEFAULT 0, + combo_lines INTEGER NOT NULL DEFAULT 0, + worker_name TEXT NOT NULL DEFAULT '', + deducted_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- ════════════════════════════════════════════════════════════ +-- 6) 권한 (dispatch_app: CRUD only) -- ════════════════════════════════════════════════════════════ GRANT USAGE ON SCHEMA public TO dispatch_app; GRANT SELECT, INSERT, UPDATE, DELETE ON - dispatch_batches, dispatch_parcels, dispatch_items, dispatch_logs + dispatch_batches, dispatch_parcels, dispatch_items, dispatch_logs, + dispatch_stock_deductions TO dispatch_app; GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO dispatch_app; ALTER DEFAULT PRIVILEGES IN SCHEMA public