feat(cupang): 상자 목록을 번호·제품명·제품코드·수량 표로, 엑셀 다운로드 추가

- ② 상자 목록: 카드 대신 표. 한 상자에 여러 제품이면 상자번호 칸 rowspan 병합
- GET /cupang/{id}/boxes.xlsx — 같은 표를 xlsx 로 (export.build_box_list_workbook)
- box_list 항목에 product_code 포함

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 19:55:06 +09:00
parent 15947e73ed
commit 3926b6a431
9 changed files with 174 additions and 61 deletions
+63
View File
@@ -188,3 +188,66 @@ def build_workbook(ship_date: str, shipments: list[dict[str, Any]]) -> Any:
ws.row_dimensions[TITLE_ROW].height = 28
return wb
# ── 상자 목록(상자 번호별 내용물) ──────────────────────────────
BOX_HEADERS = ["상자번호", "제품명", "제품코드", "수량"]
BOX_WIDTHS_PX = {1: 70, 2: 240, 3: 110, 4: 70}
def build_box_list_workbook(shipment: dict[str, Any], box_list: list[dict[str, Any]]) -> Any:
"""상자 번호 · 제품명 · 제품코드 · 수량 한 줄씩. 시트명 = YYYYMMDD(요일)."""
from openpyxl import Workbook
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter
ship_date = str(shipment.get("ship_date") or "")
center = str(shipment.get("center_name_snapshot") or "")
wb = Workbook()
ws = wb.active
ws.title = sheet_title(ship_date) if ship_date else "상자목록"
thin = Side(style="thin", color="000000")
box = Border(left=thin, right=thin, top=thin, bottom=thin)
center_align = Alignment(horizontal="center", vertical="center")
left_align = Alignment(horizontal="left", vertical="center")
ws.cell(row=1, column=1, value=f"{with_dow(ship_date)} {center} 상자 목록")
ws.cell(row=1, column=1).font = Font(size=14, bold=True)
ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=len(BOX_HEADERS))
header_fill = PatternFill("solid", fgColor=HEADER_BG)
for idx, name in enumerate(BOX_HEADERS, start=1):
cell = ws.cell(row=2, column=idx, value=name)
cell.font = Font(bold=True)
cell.alignment = center_align
cell.border = box
cell.fill = header_fill
row = 3
for entry in box_list:
contents = entry.get("items") or []
first = row
for it in contents:
ws.cell(row=row, column=1, value=entry.get("no"))
ws.cell(row=row, column=2, value=it.get("product_name") or "")
ws.cell(row=row, column=3, value=it.get("product_code") or "")
ws.cell(row=row, column=4, value=int(it.get("quantity") or 0))
row += 1
# 한 상자에 여러 제품이면 상자번호 칸을 세로로 합친다.
if row - first > 1:
ws.merge_cells(start_row=first, start_column=1, end_row=row - 1, end_column=1)
last = row - 1
for r in range(3, last + 1):
for col in range(1, len(BOX_HEADERS) + 1):
cell = ws.cell(row=r, column=col)
cell.border = box
cell.alignment = left_align if col == 2 else center_align
for col, px in BOX_WIDTHS_PX.items():
ws.column_dimensions[get_column_letter(col)].width = round(px / PX_PER_CHAR, 2)
ws.row_dimensions[1].height = 24
return wb
+53 -2
View File
@@ -431,6 +431,47 @@ async def detail(request: Request, shipment_id: int) -> HTMLResponse:
)
@router.get("/{shipment_id:int}/boxes.xlsx")
async def export_box_list_xlsx(request: Request, shipment_id: int) -> Any:
"""상자 목록(상자번호·제품명·제품코드·수량) 엑셀 다운로드."""
from io import BytesIO # noqa: WPS433
from fastapi.responses import StreamingResponse # noqa: WPS433
from .export import build_box_list_workbook # 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:
raise HTTPException(status_code=404, detail="출고 묶음을 찾을 수 없습니다.")
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 (ship.get("lines") or [])
]
box_list = _expand_box_list(ship, _compute_boxes(store, items), items)
wb = build_box_list_workbook(ship, box_list)
buf = BytesIO()
wb.save(buf)
buf.seek(0)
stamp = str(ship.get("ship_date") or "").replace("-", "")
filename = f"{stamp}_cupang_boxes_{shipment_id}.xlsx"
return StreamingResponse(
buf,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
def _safe_next(raw: str, fallback: str) -> str:
"""열린 리다이렉트 방지 — /cupang/ 안쪽 경로만 허용."""
nxt = (raw or "").strip()
@@ -1077,6 +1118,7 @@ def _expand_box_list(
contents = [
{
"product_name": it.get("product_name") or it.get("product_code") or "",
"product_code": it.get("product_code") or "",
"quantity": int(it.get("quantity") or 0),
}
for it in (entry.get("items") or [])
@@ -1087,7 +1129,11 @@ def _expand_box_list(
else:
code = str(entry.get("product_code") or "")
contents = [
{"product_name": entry.get("name") or code, "quantity": units}
{
"product_name": entry.get("name") or code,
"product_code": code,
"quantity": units,
}
]
box_name = box_name_by_code.get(code, "")
for _ in range(count):
@@ -1102,7 +1148,11 @@ def _expand_box_list(
"kind": "product",
"box_name": r.get("box_name") or "",
"items": [
{"product_name": r["product_name"], "quantity": r["units_per_box"]}
{
"product_name": r["product_name"],
"product_code": r["product_code"],
"quantity": r["units_per_box"],
}
],
}
)
@@ -1115,6 +1165,7 @@ def _expand_box_list(
"items": [
{
"product_name": it.get("product_name") or "",
"product_code": it.get("product_code") or "",
"quantity": int(it.get("quantity") or 0),
}
for it in (box.get("items") or [])
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260903a" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260903b" />{% 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=20260903a" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260903b" />{% 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=20260903a" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260903b" />{% 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=20260903a" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260903b" />{% 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=20260903a" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260903b" />{% endblock %}
{% block content %}
<section class="cpg">
+29 -19
View File
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260903a" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260903b" />{% endblock %}
{% block content %}
{# 출고 묶음 보기 — 상자 계산 화면과 같은 3열 구성. 읽기 전용(수정 없음). #}
@@ -49,6 +49,8 @@
<div class="cpg-card-head">
<h2>② 상자 목록</h2>
<span class="erp-muted">상자마다 담긴 제품과 수량</span>
<a class="erp-btn erp-btn-ghost cpg-boxno-dl"
href="/cupang/{{ shipment.id }}/boxes.xlsx">엑셀 다운로드</a>
</div>
<div id="cpg-sum-body">
@@ -71,26 +73,34 @@
</div>
<div class="cpg-sum-scroll">
<ul class="cpg-boxno-list">
{% for b in box_list %}
<li class="cpg-boxno{% if b.kind == 'mix' %} is-mix{% endif %}">
<div class="cpg-boxno-head">
<span class="cpg-boxno-no">{{ b.no }}</span>
<span class="cpg-boxno-name">{{ b.box_name or '상자' }}</span>
{% if b.kind == 'mix' %}<span class="cpg-boxno-tag">혼합</span>{% endif %}
<span class="cpg-boxno-qty">{{ b.quantity }}개</span>
</div>
<ul class="cpg-boxno-items">
<table class="erp-table cpg-boxno-table">
<colgroup>
<col class="cpg-boxno-c-no" />
<col class="cpg-boxno-c-name" />
<col class="cpg-boxno-c-code" />
<col class="cpg-boxno-c-qty" />
</colgroup>
<thead>
<tr><th>상자번호</th><th>제품명</th><th>제품코드</th><th>수량</th></tr>
</thead>
<tbody>
{% for b in box_list %}
{% for it in b['items'] %}
<li><span>{{ it.product_name }}</span><b>{{ it.quantity }}개</b></li>
<tr class="{% if loop.first %}is-boxtop{% endif %}">
{% if loop.first %}
<td class="cpg-boxno-cell" rowspan="{{ b['items']|length }}">{{ b.no }}</td>
{% endif %}
<td title="{{ it.product_name }}">{{ it.product_name }}</td>
<td class="cpg-boxno-code">{{ it.product_code }}</td>
<td class="cpg-calc-num">{{ it.quantity }}</td>
</tr>
{% endfor %}
</ul>
</li>
{% endfor %}
{% if not box_list %}
<li class="erp-muted">상자 정보가 없습니다.</li>
{% endif %}
</ul>
{% endfor %}
{% if not box_list %}
<tr><td colspan="4" class="erp-muted">상자 정보가 없습니다.</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
+24 -35
View File
@@ -1399,43 +1399,32 @@ body.cpg-dragging { cursor: grabbing; user-select: none; }
.cpg-calc-card .cpg-calc-table .cpg-calc-qty { width: 70px; max-width: 70px; margin-left: auto; }
/* 출고 보기(읽기 전용) — ③ 하단 메타 정보 */
/* ── 출고 보기 ② 상자 목록 — 상자마다 번호 + 내용물 ── */
.cpg-boxno-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
.cpg-boxno {
border: 1px solid var(--color-cloud-gray);
border-radius: 10px;
padding: 8px 10px;
background: #fff;
}
.cpg-boxno.is-mix { border-color: var(--color-deep-black); }
.cpg-boxno-head { display: flex; align-items: center; gap: 6px; }
.cpg-boxno-no {
display: inline-flex; align-items: center; justify-content: center;
min-width: 24px; height: 24px; padding: 0 6px;
border-radius: 999px;
background: var(--color-deep-black); color: #fff;
font-size: 12px; font-weight: 700; font-family: var(--font-geist-mono);
}
.cpg-boxno-name { font-size: 13px; font-weight: 600; }
.cpg-boxno-tag {
font-size: 10px; padding: 1px 6px; border-radius: 999px;
background: var(--color-ghost-gray); color: var(--color-midtone-gray);
}
.cpg-boxno-qty {
margin-left: auto;
font-size: 12px; color: var(--color-midtone-gray);
font-family: var(--font-geist-mono);
}
.cpg-boxno-items { list-style: none; margin: 6px 0 0; padding: 0 0 0 30px; }
.cpg-boxno-items li {
display: flex; align-items: baseline; gap: 8px;
padding: 3px 0;
/* ── 출고 보기 ② 상자 목록 — 상자번호 · 제품명 · 제품코드 · 수량 ── */
.cpg-boxno-dl { margin-left: auto; font-size: 12px; padding: 4px 10px; }
.cpg-boxno-table { table-layout: fixed; width: 100%; }
.cpg-boxno-table th, .cpg-boxno-table td {
box-sizing: border-box;
padding: 4px 8px;
font-size: 12px;
border-bottom: 1px dashed var(--color-cloud-gray);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
border-bottom: 1px solid var(--color-cloud-gray);
}
.cpg-boxno-items li:last-child { border-bottom: 0; }
.cpg-boxno-items li span { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cpg-boxno-items li b { flex: 0 0 auto; font-family: var(--font-geist-mono); }
.cpg-boxno-table thead th {
position: sticky; top: 0; z-index: 2;
background: var(--color-ghost-gray);
}
.cpg-boxno-c-no { width: 66px; }
.cpg-boxno-c-name { width: auto; }
.cpg-boxno-c-code { width: 92px; }
.cpg-boxno-c-qty { width: 58px; }
.cpg-boxno-cell {
text-align: center; font-weight: 700;
font-family: var(--font-geist-mono);
border-right: 1px solid var(--color-cloud-gray);
vertical-align: middle;
}
.cpg-boxno-code { color: var(--color-midtone-gray); font-family: var(--font-geist-mono); }
.cpg-boxno-table tbody tr.is-boxtop td { border-top: 1px solid var(--color-cloud-gray); }
.cpg-view-meta {
margin: 12px 0 0; padding: 10px 12px;