feat(cupang): 센터별 출고방식 + 분배 확정 → 출고 묶음 생성
③ 센터 분배 헤더에 [분배 확정] 을 두고, 조건이 갖춰질 때만 활성화한다. 조건은 두 가지다. 남은 박스가 0 이어야 하고, 담긴 센터마다 출고방식 (택배/파렛트)이 골라져 있어야 한다. 비활성일 때는 버튼 툴팁에 막힌 사유를 적는다(남은 박스 수, 출고방식 미선택 센터명). 확정을 누르면 달력이 바로 뜨는 팝업에서 출고일자를 고르고, 확인 시 센터마다 출고 묶음 1건(status=출고준비)을 만들어 쿠팡 달력의 그 날짜로 이동한다. 혼합 박스는 내용물 제품으로 풀어 담고 같은 제품끼리 합산한다. 박스 수는 화면 값을 믿지 않고 저장 시 서버가 수량·입수량으로 다시 계산한다. - router: POST /cupang/api/box-calc/confirm - box_calc.html: 센터 헤더 출고방식 select(미선택은 빨간 배경), 확정 팝업 달력 - 임시 저장 스냅샷에 출고방식 포함 - 센터입고일은 출고일과 같게 저장(상세에서 수정)
This commit is contained in:
@@ -16,6 +16,8 @@ from fractions import Fraction
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from datetime import date as _date
|
||||
|
||||
from app.timezone import today_kst
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Form, HTTPException, Request
|
||||
@@ -908,6 +910,128 @@ async def product_delete(
|
||||
return RedirectResponse(url="/cupang/products", status_code=303)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 분배 확정 — 센터별 출고 묶음 생성
|
||||
# ════════════════════════════════════════════════════════════
|
||||
@router.post("/api/box-calc/confirm")
|
||||
async def box_calc_confirm(
|
||||
request: Request,
|
||||
payload: dict[str, Any] = Body(...),
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> JSONResponse:
|
||||
"""{ship_date, centers:[{center_id, ship_method, boxes, items:[...]}]} → 센터마다 출고 묶음 1건 생성.
|
||||
|
||||
화면에서 보낸 박스 수는 요약 표시용이고, 라인의 박스 수는 저장 시
|
||||
서버(store.compute_boxes)가 수량과 입수량으로 다시 계산한다.
|
||||
"""
|
||||
from .store import SHIP_METHODS as _METHODS # noqa: WPS433
|
||||
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="cupang_db 미설정")
|
||||
|
||||
ship_date = str(payload.get("ship_date") or "").strip()
|
||||
try:
|
||||
_date.fromisoformat(ship_date)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="출고일자를 올바르게 선택하세요.")
|
||||
|
||||
raw_centers = payload.get("centers")
|
||||
if not isinstance(raw_centers, list) or not raw_centers:
|
||||
raise HTTPException(status_code=400, detail="확정할 센터가 없습니다.")
|
||||
|
||||
rules = {r["product_code"]: r for r in store.list_box_rules()}
|
||||
known_centers = {str(c["id"]): c for c in store.list_centers(include_inactive=True)}
|
||||
today = today_kst().isoformat()
|
||||
worker = str(user.get("name") or user.get("email") or "")
|
||||
|
||||
plans: list[dict[str, Any]] = []
|
||||
for raw in raw_centers:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
cid = str(raw.get("center_id") or "").strip()
|
||||
center = known_centers.get(cid)
|
||||
if center is None:
|
||||
raise HTTPException(status_code=400, detail=f"알 수 없는 센터입니다: {cid}")
|
||||
|
||||
method = str(raw.get("ship_method") or "").strip()
|
||||
if method not in _METHODS:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"{center['name']} 의 출고방식을 선택하세요."
|
||||
)
|
||||
|
||||
# 같은 제품이 여러 박스로 나뉘어 담겼을 수 있으므로 제품코드로 합친다.
|
||||
merged: dict[str, int] = {}
|
||||
for it in raw.get("items") or []:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
code = str(it.get("product_code") or "").strip()
|
||||
if not code:
|
||||
continue
|
||||
try:
|
||||
qty = int(it.get("quantity") or 0)
|
||||
except (TypeError, ValueError):
|
||||
qty = 0
|
||||
if qty <= 0:
|
||||
continue
|
||||
merged[code] = merged.get(code, 0) + qty
|
||||
|
||||
if not merged:
|
||||
continue
|
||||
|
||||
lines = []
|
||||
for code, qty in merged.items():
|
||||
rule = rules.get(code)
|
||||
lines.append(
|
||||
{
|
||||
"product_code": code,
|
||||
"product_name_snapshot": (rule or {}).get("product_name_snapshot") or code,
|
||||
"quantity": qty,
|
||||
"units_per_box": (rule or {}).get("units_per_box"),
|
||||
"box_rule_id": (rule or {}).get("id"),
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
boxes = max(int(raw.get("boxes") or 0), 0)
|
||||
except (TypeError, ValueError):
|
||||
boxes = 0
|
||||
pieces = sum(merged.values())
|
||||
summary = f"{boxes}박스 · {pieces}개" if boxes else f"{pieces}개"
|
||||
|
||||
plans.append({"center": center, "method": method, "lines": lines, "summary": summary})
|
||||
|
||||
if not plans:
|
||||
raise HTTPException(status_code=400, detail="담긴 품목이 없습니다.")
|
||||
|
||||
created: list[dict[str, Any]] = []
|
||||
for plan in plans:
|
||||
center = plan["center"]
|
||||
try:
|
||||
ship = store.create_shipment(
|
||||
created_by=str(user.get("email") or ""),
|
||||
header={
|
||||
"document_date": today,
|
||||
"ship_date": ship_date,
|
||||
# 센터입고일은 출고일과 같게 두고, 필요하면 출고 상세에서 고친다.
|
||||
"center_arrival_date": ship_date,
|
||||
"center_id": center["id"],
|
||||
"center_name_snapshot": center["name"],
|
||||
"ship_method": plan["method"],
|
||||
"outbound_summary": plan["summary"],
|
||||
"worker": worker,
|
||||
"status": "출고준비",
|
||||
"memo": "박스 계산에서 분배 확정",
|
||||
},
|
||||
lines=plan["lines"],
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
created.append({"id": ship["id"], "center_name": center["name"]})
|
||||
|
||||
return JSONResponse({"created": created, "ship_date": ship_date})
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 박스 계산 임시 저장 (화면 상태 스냅샷)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user