diff --git a/app/modules/cupang/router.py b/app/modules/cupang/router.py index cb19511..5faaa80 100644 --- a/app/modules/cupang/router.py +++ b/app/modules/cupang/router.py @@ -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]]: diff --git a/app/modules/cupang/templates/cupang/detail.html b/app/modules/cupang/templates/cupang/detail.html deleted file mode 100644 index 7f3dca6..0000000 --- a/app/modules/cupang/templates/cupang/detail.html +++ /dev/null @@ -1,76 +0,0 @@ -{% extends "erp_base.html" %} - -{% block head_extra %}{% endblock %} - -{% block content %} -
- -
- ◀◀ 달력 - -
- -
- -
- -
- - 수정 -
- - -
-
-

출고 #{{ shipment.id }} - {% set badge = 'erp-badge-neutral' %} - {% if shipment.status == '출고완료' %}{% set badge = 'erp-badge-inverse' %} - {% elif shipment.status == '센터입고완료' %}{% set badge = 'erp-badge-success' %} - {% elif shipment.status == '취소' %}{% set badge = 'erp-badge-danger' %}{% endif %} - {{ shipment.status }} -

-
-
-
작성일
{{ shipment.document_date }}
-
출고일
{{ shipment.ship_date }}
-
센터입고일
{{ shipment.center_arrival_date }}
-
입고센터
{{ shipment.center_name_snapshot or '—' }}
-
출고방식
{{ shipment.ship_method }}
-
작업자
{{ shipment.worker or '—' }}
-
출고/박스 요약
{{ shipment.outbound_summary or '—' }}
-
메모
{{ shipment.memo or '—' }}
-
-
- - -
-

품목 ({{ shipment.lines|length }})

-
- - - - - - - {% for ln in shipment.lines %} - - - - - - - - - - - - {% endfor %} - -
#제품코드제품명수량입수량필요박스잔량수동보정메모
{{ ln.line_no }}{{ ln.product_code }}{{ ln.product_name_snapshot }}{{ ln.quantity }}{{ ln.units_per_box if ln.units_per_box else '미설정' }}{{ ln.calculated_boxes if ln.calculated_boxes is not none else '—' }}{{ ln.remainder_units if ln.remainder_units is not none else '—' }}{{ ln.manual_box_text or '—' }}{{ ln.memo or '—' }}
-
-
- -
-{% endblock %} diff --git a/app/modules/cupang/templates/cupang/form.html b/app/modules/cupang/templates/cupang/form.html deleted file mode 100644 index 7f6a1c8..0000000 --- a/app/modules/cupang/templates/cupang/form.html +++ /dev/null @@ -1,112 +0,0 @@ -{% extends "erp_base.html" %} - -{% block head_extra %}{% endblock %} - -{% block content %} -
- - {% set action = '/cupang/new' if mode == 'new' else '/cupang/' ~ shipment.id ~ '/edit' %} -
- - -
- - -
-

공통 헤더

-
- - - - - - - - - - - -
- - - - -
- - {% if mode == 'edit' %} - 취소 - {% else %} - 취소 - {% endif %} -
-
- - -
-
-

품목 라인

- - 제품명 선택 시 제품코드 자동 입력. 수량 입력 시 박스 수 자동 계산. - {% if not products %}설정에서 제품명 먼저 등록{% endif %} - -
- - -
-
- -
- - - - - - - - - -
제품명제품코드수량입수량박스 계산라인메모
-
-
- -
-
-
- - - - -{% endblock %} - -{% block scripts %}{% endblock %} diff --git a/app/modules/cupang/templates/cupang/view.html b/app/modules/cupang/templates/cupang/view.html new file mode 100644 index 0000000..2470cd3 --- /dev/null +++ b/app/modules/cupang/templates/cupang/view.html @@ -0,0 +1,166 @@ +{% extends "erp_base.html" %} + +{% block head_extra %}{% endblock %} + +{% block content %} +{# 출고 묶음 보기 — 박스 계산 화면과 같은 3열 구성. 읽기 전용(수정 없음). #} +
+ +
+ ◀◀ 달력 + {{ shipment.status }} +
+ +
+ + +
+
+

① 품목

+ {{ items|length }}종 · {{ total_qty }}개 +
+ +
+ + + + + + + + + + {% for it in items %} + + + + + {% endfor %} + {% if not items %} + + {% endif %} + +
제품명수량
{{ it.product_name }}{{ it.quantity }}
품목이 없습니다.
+
+
+ + +
+
+

② 박스 요약

+ 확정 당시 수량 기준으로 다시 계산한 값 +
+ +
+
+
+ 총 박스 + {{ calc.grand_total_boxes }}박스 + 제품별 + 혼합 +
+
+ 출고 + {{ shipment.outbound_summary or '—' }} + 확정 시 기록 +
+
+ 총 수량 + {{ total_qty }}개 + {{ items|length }}종 +
+
+ +
+

제품별 박스

+
+ {% for r in calc.results if r.configured %} +
+
+ {{ r.product_name }} +
+
+ {{ r.full_boxes }}박스 + {{ r.quantity }}개 · 입수량 {{ r.units_per_box }} + {% if r.remainder_units %} + 자투리 {{ r.remainder_units }}개 + {% else %} + 딱 맞음 + {% endif %} +
+
+ {% endfor %} + {% set unconfigured = calc.results | rejectattr('configured') | list %} + {% if unconfigured %} +

박스 입수량 미설정: + {% for r in unconfigured %}{{ r.product_name }}{% if not loop.last %}, {% endif %}{% endfor %} +

+ {% endif %} +
+ +

자투리 혼합 박스 + {% if not calc.mixes %}— 자투리 없음{% else %}— 1장 = 1박스{% endif %} +

+
+ {% for m in calc.mixes %} + {% for b in m.boxes %} +
+
+
{{ m.box_name }} 혼합 #{{ loop.index }}
+
    + {% for it in b['items'] %} +
  • {{ it.product_name }}{{ it.quantity }}개
  • + {% endfor %} +
+
+
{{ b.fill_percent }}% 채움
+
+
+ {% endfor %} + {% endfor %} +
+
+
+
+ + +
+
+

③ 센터

+ 출고 #{{ shipment.id }} +
+ +
+
+
+ {{ shipment.center_name_snapshot or '센터 미지정' }} + {{ shipment.ship_method }} + + {{ calc.grand_total_boxes }}박스 + {{ total_qty }}개 + +
+
    + {% for it in items %} +
  • +
    + {{ it.product_name }} + {{ it.quantity }}개 +
    +
  • + {% endfor %} +
+
+ +
+
작성일
{{ shipment.document_date }}
+
출고일
{{ shipment.ship_date }}
+
센터입고일
{{ shipment.center_arrival_date }}
+
작업자
{{ shipment.worker or '—' }}
+ {% if shipment.memo %}
메모
{{ shipment.memo }}
{% endif %} +
+
+
+ +
+
+{% endblock %} diff --git a/app/static/cupang.css b/app/static/cupang.css index 2aa146d..e3d75f3 100644 --- a/app/static/cupang.css +++ b/app/static/cupang.css @@ -1393,3 +1393,13 @@ body.cpg-dragging { cursor: grabbing; user-select: none; } /* ① 직접 입력 — 수량 입력란 70px 고정 */ .cpg-calc-card .cpg-calc-table col.cpg-col-qty { width: 70px; } .cpg-calc-card .cpg-calc-table .cpg-calc-qty { width: 70px; max-width: 70px; margin-left: auto; } + +/* 출고 보기(읽기 전용) — ③ 하단 메타 정보 */ +.cpg-view-meta { + margin: 12px 0 0; padding: 10px 12px; + border: 1px solid var(--color-subtle-ash); border-radius: 10px; + display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 12px; +} +.cpg-view-meta dt { font-size: 11px; color: var(--color-midtone-gray); } +.cpg-view-meta dd { margin: 2px 0 0; font-size: 13px; } +.cpg-view-row td { padding: 4px 6px; }