change(cupang): 출고 상세를 읽기 전용 3열 보기로 교체, 수정 기능 제거

- detail.html / form.html 삭제, GET·POST /{id}/edit 라우트와
  _form_context / _parse_lines 헬퍼 제거
- GET /{id} → view.html: ① 품목 / ② 박스 요약(서버 재계산) / ③ 센터
  (박스 계산 화면과 같은 3열 구성, 편집 불가)
- 박스 계산 로직을 _compute_boxes 로 분리해 API 와 보기 화면이 공유

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 15:48:05 +09:00
parent da90d1eec8
commit 8c8f6aebb2
5 changed files with 213 additions and 296 deletions
+37 -108
View File
@@ -93,16 +93,6 @@ def _guard(request: Request) -> tuple[Any, dict[str, Any]] | HTMLResponse | Redi
return store, user
def _parse_lines(lines_json: str) -> list[dict[str, Any]]:
try:
data = json.loads(lines_json or "[]")
except (json.JSONDecodeError, TypeError):
raise HTTPException(status_code=400, detail="라인 데이터 형식 오류")
if not isinstance(data, list):
raise HTTPException(status_code=400, detail="라인 데이터는 배열이어야 합니다.")
return data
def _ym(request: Request) -> tuple[int, int]:
today = today_kst()
try:
@@ -342,23 +332,6 @@ async def export_shipments_xlsx(request: Request, date: str = "") -> Any:
# ════════════════════════════════════════════════════════════
# 출고 묶음 — 등록 / 수정 / 상세
# ════════════════════════════════════════════════════════════
def _form_context(request: Request, store: Any, user: dict[str, Any]) -> dict[str, Any]:
from app.main import build_erp_nav # noqa: WPS433
from app.store import is_admin # noqa: WPS433
reader = _itemcode(request)
return {
"user": user,
"is_admin": is_admin(user),
"nav_items": build_erp_nav(user, active="cupang"),
"centers": sorted(store.list_centers(), key=lambda c: c["name"]),
"box_rules": store.list_box_rules(),
"products": store.list_products(),
"ship_methods": list(SHIP_METHODS),
"search_enabled": bool(reader and reader.enabled),
}
@router.get("/new")
async def new_form(request: Request) -> RedirectResponse:
"""신규 등록 폼은 없앴다. 출고 묶음은 박스 계산의 [분배 확정] 으로만 만든다.
@@ -370,6 +343,10 @@ async def new_form(request: Request) -> RedirectResponse:
@router.get("/{shipment_id:int}", response_class=HTMLResponse)
async def detail(request: Request, shipment_id: int) -> HTMLResponse:
"""출고 묶음 보기 — 박스 계산 화면과 같은 3열 구성(읽기 전용).
수정 기능은 없앴다. 잘못 만들었으면 달력에서 삭제하고 다시 확정한다.
"""
from app.main import build_erp_nav, render_template # noqa: WPS433
from app.store import is_admin # noqa: WPS433
@@ -384,9 +361,22 @@ async def detail(request: Request, shipment_id: int) -> HTMLResponse:
{"reason": "출고 묶음을 찾을 수 없습니다.", "is_admin": is_admin(user)},
status_code=404,
)
lines = ship.get("lines") or []
items = [
{
"product_code": ln.get("product_code"),
"product_name": ln.get("product_name_snapshot") or ln.get("product_code"),
"quantity": int(ln.get("quantity") or 0),
}
for ln in lines
]
calc = _compute_boxes(store, items)
total_qty = sum(it["quantity"] for it in items)
return render_template(
request,
"cupang/detail.html",
"cupang/view.html",
{
"user": user,
"is_admin": is_admin(user),
@@ -394,80 +384,13 @@ async def detail(request: Request, shipment_id: int) -> HTMLResponse:
"page_title": f"출고 #{ship['id']}",
"page_subtitle": f"{ship['ship_date']} · {ship['center_name_snapshot']}",
"shipment": ship,
"items": items,
"calc": calc,
"total_qty": total_qty,
},
)
@router.get("/{shipment_id:int}/edit", response_class=HTMLResponse)
async def edit_form(request: Request, shipment_id: int) -> HTMLResponse:
from app.main import render_template # noqa: WPS433
from app.store import is_admin # noqa: WPS433
guard = _guard(request)
if not isinstance(guard, tuple):
return guard
store, user = guard
ship = store.get_shipment(shipment_id=shipment_id)
if not ship:
return render_template(
request, "denied.html",
{"reason": "출고 묶음을 찾을 수 없습니다.", "is_admin": is_admin(user)},
status_code=404,
)
ctx = _form_context(request, store, user)
ctx.update(
{
"page_title": f"출고 #{ship['id']} 수정",
"page_subtitle": "헤더/라인 수정 후 저장",
"mode": "edit",
"shipment": ship,
"default_date": ship["document_date"],
}
)
return render_template(request, "cupang/form.html", ctx)
@router.post("/{shipment_id:int}/edit")
async def update(
request: Request,
shipment_id: int,
lines_json: str = Form("[]"),
document_date: str = Form(...),
ship_date: str = Form(...),
center_arrival_date: str = Form(...),
center_id: str = Form(""),
center_name_snapshot: str = Form(""),
ship_method: str = Form("택배"),
outbound_summary: str = Form(""),
worker: str = Form(""),
memo: str = Form(""),
user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse:
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="cupang_db 미설정")
header = {
"document_date": document_date,
"ship_date": ship_date,
"center_arrival_date": center_arrival_date,
"center_id": center_id,
"center_name_snapshot": center_name_snapshot,
"ship_method": ship_method,
"outbound_summary": outbound_summary,
"worker": worker,
"memo": memo,
}
try:
store.update_shipment(
shipment_id=shipment_id, header=header, lines=_parse_lines(lines_json)
)
except KeyError:
raise HTTPException(status_code=404, detail="출고 묶음을 찾을 수 없습니다.")
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return RedirectResponse(url=f"/cupang/{shipment_id}", status_code=303)
def _safe_next(raw: str, fallback: str) -> str:
"""열린 리다이렉트 방지 — /cupang/ 안쪽 경로만 허용."""
nxt = (raw or "").strip()
@@ -1008,8 +931,6 @@ async def box_calc_api(
클라이언트 계산을 신뢰하지 않고 store.compute_boxes 로 서버에서 계산한다.
"""
from .store import compute_boxes # noqa: WPS433
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="cupang_db 미설정")
@@ -1018,6 +939,16 @@ async def box_calc_api(
if not isinstance(raw_items, list):
raise HTTPException(status_code=400, detail="items 는 배열이어야 합니다.")
return JSONResponse(_compute_boxes(store, raw_items))
def _compute_boxes(store: Any, raw_items: list[Any]) -> dict[str, Any]:
"""[{product_code, quantity}] → 제품별 박스 + 자투리 혼합 박스 + 합계.
박스 계산 화면(API)과 출고 상세 보기가 같은 결과를 쓰도록 한 곳에 둔다.
"""
from .store import compute_boxes # noqa: WPS433
rules = {r["product_code"]: r for r in store.list_box_rules()}
results: list[dict[str, Any]] = []
@@ -1063,14 +994,12 @@ async def box_calc_api(
grand_total = sum(r["full_boxes"] or 0 for r in results if r["configured"])
grand_total += sum(m["box_count"] for m in mixes)
return JSONResponse(
{
"results": results,
"totals": sorted(totals.values(), key=lambda t: t["box_name"]),
"mixes": mixes,
"grand_total_boxes": grand_total,
}
)
return {
"results": results,
"totals": sorted(totals.values(), key=lambda t: t["box_name"]),
"mixes": mixes,
"grand_total_boxes": grand_total,
}
def _pack_leftovers(results: list[dict[str, Any]]) -> list[dict[str, Any]]: