feat(dispatch): 말레이시아 재고 차감 버튼(날짜 1회성)

- 달력 다운로드 아래 '말레이시아 재고 차감' 버튼. 선택 날짜 출고를
  말레이시아 재고관리에 OUT 으로 반영(movement_date=해당 날짜)
  · 낱개(MT/MX/MZ)=낱개 OUT, 콤보(MY-)=세트 OUT(재고관리가 BOM 분해)
  · _N 배수 반영, MD-(뚜껑) 제외
- 날짜당 1회만: dispatch_stock_deductions 마커로 재차감 차단(이중 출고 방지)
  · 마커 선점 후 반영, 콤보 BOM 누락은 사전검증으로 차단
- export.split_singles_combos, db 차감 가드 메서드, 라우터 POST /dispatch/stock-deduct
- 스키마: dispatch_stock_deductions (init + 마이그레이션 dispatch_add_deductions.sql)
- 이미 차감된 날짜는 버튼 비활성 + 안내

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 18:07:37 +09:00
parent 27e2c17606
commit b583627bd3
6 changed files with 259 additions and 3 deletions
+86
View File
@@ -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카드)
# ════════════════════════════════════════════════════════════