feat(cupang): 출고 박스 구성(box_plan) 저장 + 달력 상세를 센터 분배 형식으로

- 마이그레이션 005: cupang_shipments.box_plan JSONB (확정 당시 박스 구성)
  확정 payload 에서 제품별/혼합 상자와 내용물·상자 종류를 받아 저장
- 달력 오른쪽 목록을 ③ 센터 분배 카드 형식으로 변경 — 혼합 상자는
  내용물 트리까지 표시. box_plan 이 없는 예전 출고는 기존 합계 표시로 폴백
- 출고 보기(view.html) ③ 목록도 동일하게 box_plan 기준

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 15:58:29 +09:00
parent 8c8f6aebb2
commit 1c1ca69feb
12 changed files with 226 additions and 36 deletions
+11 -3
View File
@@ -496,8 +496,9 @@ class CupangDBStore:
INSERT INTO cupang_shipments
(created_by, document_date, ship_date,
center_arrival_date, center_id, center_name_snapshot,
ship_method, outbound_summary, worker, status, memo)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
ship_method, outbound_summary, worker, status, memo,
box_plan)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING *
""",
(
@@ -512,6 +513,7 @@ class CupangDBStore:
h["worker"],
h["status"],
h["memo"],
Jsonb(h["box_plan"]),
),
).fetchone()
shipment_id = row["id"]
@@ -533,7 +535,8 @@ class CupangDBStore:
SET document_date = %s, ship_date = %s,
center_arrival_date = %s, center_id = %s,
center_name_snapshot = %s, ship_method = %s,
outbound_summary = %s, worker = %s, memo = %s
outbound_summary = %s, worker = %s, memo = %s,
box_plan = %s
WHERE id = %s
RETURNING *
""",
@@ -547,6 +550,7 @@ class CupangDBStore:
h["outbound_summary"],
h["worker"],
h["memo"],
Jsonb(h["box_plan"]),
shipment_id,
),
).fetchone()
@@ -690,6 +694,8 @@ class CupangDBStore:
"worker": str(header.get("worker") or "").strip(),
"status": status,
"memo": str(header.get("memo") or "").strip(),
# 확정 당시 박스 구성(제품별/혼합). 화면 표시 전용이라 형태만 검사한다.
"box_plan": header.get("box_plan") if isinstance(header.get("box_plan"), list) else [],
}
@staticmethod
@@ -793,6 +799,8 @@ class CupangDBStore:
out = dict(row)
out["id"] = int(out["id"])
out["center_id"] = int(out["center_id"]) if out.get("center_id") is not None else None
if not isinstance(out.get("box_plan"), list):
out["box_plan"] = []
for k in ("document_date", "ship_date", "center_arrival_date"):
v = out.get(k)
if isinstance(v, date):
+63 -1
View File
@@ -1244,6 +1244,59 @@ async def product_delete(
# ════════════════════════════════════════════════════════════
# 분배 확정 — 센터별 출고 묶음 생성
# ════════════════════════════════════════════════════════════
def _clean_box_plan(raw: Any) -> list[dict[str, Any]]:
"""화면이 보낸 박스 구성을 표시용으로만 정리해서 저장한다.
수량·박스 수는 라인(제품 합계)이 정답이고, 이 값은 "어느 상자에 무엇이
담겼는지"를 보여주기 위한 스냅샷이다.
"""
if not isinstance(raw, list):
return []
out: list[dict[str, Any]] = []
for entry in raw[:200]:
if not isinstance(entry, dict):
continue
kind = "mix" if str(entry.get("kind")) == "mix" else "product"
try:
count = max(int(entry.get("count") or 0), 0)
units = max(int(entry.get("units") or 0), 0)
except (TypeError, ValueError):
continue
if count <= 0:
continue
item: dict[str, Any] = {
"kind": kind,
"name": str(entry.get("name") or "")[:120],
"count": count,
"units": units,
"quantity": count * units,
}
if kind == "product":
item["product_code"] = str(entry.get("product_code") or "")[:60]
else:
item["box_type"] = str(entry.get("box_type") or "")[:40]
contents = []
for it in (entry.get("items") or [])[:50]:
if not isinstance(it, dict):
continue
try:
qty = max(int(it.get("quantity") or 0), 0)
except (TypeError, ValueError):
continue
if qty <= 0:
continue
contents.append(
{
"product_name": str(it.get("product_name") or "")[:120],
"product_code": str(it.get("product_code") or "")[:60],
"quantity": qty,
}
)
item["items"] = contents
out.append(item)
return out
@router.post("/api/box-calc/confirm")
async def box_calc_confirm(
request: Request,
@@ -1334,7 +1387,15 @@ async def box_calc_confirm(
if not summary:
summary = f"{boxes}박스 · {pieces}" if boxes else f"{pieces}"
plans.append({"center": center, "method": method, "lines": lines, "summary": summary})
plans.append(
{
"center": center,
"method": method,
"lines": lines,
"summary": summary,
"box_plan": _clean_box_plan(raw.get("box_plan")),
}
)
if not plans:
raise HTTPException(status_code=400, detail="담긴 품목이 없습니다.")
@@ -1357,6 +1418,7 @@ async def box_calc_confirm(
"worker": worker,
"status": "출고준비",
"memo": "박스 계산에서 분배 확정",
"box_plan": plan["box_plan"],
},
lines=plan["lines"],
)
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901m" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901n" />{% endblock %}
{% block content %}
<section class="cpg">
@@ -1254,11 +1254,39 @@
return t + ", " + byType[t] + "개";
}).join(" / ");
// 확정 후 상세 화면에서 "어느 상자에 무엇이" 를 보여주기 위한 스냅샷
var boxPlan = rows.map(function (a) {
if (a.key.indexOf("m:") === 0) {
return {
kind: "mix",
name: labels[a.key] || a.key,
box_type: mixTypeOf(a.key),
count: a.count,
units: units[a.key] || 0,
items: (contents[a.key] || []).map(function (it) {
return {
product_code: it.product_code,
product_name: it.product_name,
quantity: it.quantity * a.count
};
})
};
}
return {
kind: "product",
name: labels[a.key] || a.key,
product_code: a.key.slice(2),
count: a.count,
units: units[a.key] || 0
};
});
return {
center_id: cid,
ship_method: methods[String(cid)] || "",
boxes: boxes,
box_summary: boxSummary,
box_plan: boxPlan,
items: items
};
});
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901m" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901n" />{% endblock %}
{% block content %}
<section class="cpg">
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901m" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901n" />{% endblock %}
{% block content %}
<section class="cpg">
+42 -18
View File
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901m" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901n" />{% endblock %}
{% block content %}
<section class="cpg">
@@ -91,27 +91,51 @@
{% if sel_shipments %}
<ul class="cpg-list">
{% for s in sel_shipments %}
<li class="cpg-list-item">
<div class="cpg-ship-card">
<div class="cpg-ship-head">
<a class="cpg-ship-name" href="/cupang/{{ s.id }}">{{ s.center_name_snapshot or '센터 미지정' }}</a>
<span class="erp-badge erp-badge-neutral cpg-mini">{{ s.ship_method }}</span>
{# ③ 센터 분배 카드와 같은 형식 — 혼합 상자는 내용물까지 펼쳐 보여준다 #}
<li class="cpg-list-item cpg-dist-center has-items">
<div class="cpg-dist-head">
<a class="cpg-ship-name" href="/cupang/{{ s.id }}">{{ s.center_name_snapshot or '센터 미지정' }}</a>
<span class="erp-badge erp-badge-neutral">{{ s.ship_method }}</span>
<span class="cpg-dist-sum">
<span class="erp-badge erp-badge-neutral">{{ s.total_boxes }}박스</span>
<span class="erp-badge erp-badge-neutral">{{ s.total_qty }}개</span>
<button type="button" class="cpg-icon-btn is-danger" title="이 출고 삭제"
data-del="{{ s.id }}" data-name="{{ s.center_name_snapshot }}">삭제</button>
</div>
<div class="cpg-ship-meta erp-muted">
출고 {{ s.ship_date }} · {{ s.total_boxes }}박스 · {{ s.total_qty }}개
</div>
<ul class="cpg-ship-items">
{% for it in s['items'] %}
<li>
<span class="cpg-ship-iname">{{ it.name }}</span>
<span class="cpg-ship-iqty">{{ it.qty }}</span>
<span class="cpg-ship-ibox">{{ it.boxes }}박스</span>
</span>
</div>
<ul class="cpg-dist-items">
{% if s.box_plan %}
{% for e in s.box_plan %}
<li class="cpg-dist-item{% if e.kind == 'mix' %} is-mix{% endif %}">
<div class="cpg-dist-line">
<span class="cpg-dist-name">{{ e.name }}</span>
<span class="cpg-dist-unit">{{ e.count }}박스 · {{ e.quantity }}개</span>
</div>
{% if e.kind == 'mix' and e['items'] %}
<ul class="cpg-dist-tree">
{% for it in e['items'] %}
<li><span class="cpg-tree-name">{{ it.product_name }}</span>
<span class="cpg-tree-qty">{{ it.quantity }}개</span></li>
{% endfor %}
</ul>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% else %}
{# 예전에 만든 출고(박스 구성 미저장) — 제품 합계만 표시 #}
{% for it in s['items'] %}
<li class="cpg-dist-item">
<div class="cpg-dist-line">
<span class="cpg-dist-name">{{ it.name }}</span>
<span class="cpg-dist-unit">{{ it.boxes }}박스 · {{ it.qty }}개</span>
</div>
</li>
{% endfor %}
{% endif %}
</ul>
<div class="cpg-ship-meta erp-muted">출고 {{ s.ship_date }} · 센터입고 {{ s.center_arrival_date }}</div>
</li>
{% endfor %}
</ul>
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901m" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901n" />{% endblock %}
{% block content %}
<section class="cpg">
+28 -9
View File
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901m" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901n" />{% endblock %}
{% block content %}
{# 출고 묶음 보기 — 박스 계산 화면과 같은 3열 구성. 읽기 전용(수정 없음). #}
@@ -140,14 +140,33 @@
</span>
</div>
<ul class="cpg-dist-items">
{% for it in items %}
<li class="cpg-dist-item">
<div class="cpg-dist-line">
<span class="cpg-dist-name">{{ it.product_name }}</span>
<span class="cpg-dist-unit">{{ it.quantity }}</span>
</div>
</li>
{% endfor %}
{% if shipment.box_plan %}
{% for e in shipment.box_plan %}
<li class="cpg-dist-item{% if e.kind == 'mix' %} is-mix{% endif %}">
<div class="cpg-dist-line">
<span class="cpg-dist-name">{{ e.name }}</span>
<span class="cpg-dist-unit">{{ e.count }}박스 · {{ e.quantity }}개</span>
</div>
{% if e.kind == 'mix' and e['items'] %}
<ul class="cpg-dist-tree">
{% for it in e['items'] %}
<li><span class="cpg-tree-name">{{ it.product_name }}</span>
<span class="cpg-tree-qty">{{ it.quantity }}개</span></li>
{% endfor %}
</ul>
{% endif %}
</li>
{% endfor %}
{% else %}
{% for it in items %}
<li class="cpg-dist-item">
<div class="cpg-dist-line">
<span class="cpg-dist-name">{{ it.product_name }}</span>
<span class="cpg-dist-unit">{{ it.quantity }}개</span>
</div>
</li>
{% endfor %}
{% endif %}
</ul>
</div>