From 3926b6a431d838869840c70a9ea41dbb2248a5ee Mon Sep 17 00:00:00 2001 From: king Date: Tue, 1 Sep 2026 19:55:06 +0900 Subject: [PATCH] =?UTF-8?q?feat(cupang):=20=EC=83=81=EC=9E=90=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=EC=9D=84=20=EB=B2=88=ED=98=B8=C2=B7=EC=A0=9C=ED=92=88?= =?UTF-8?q?=EB=AA=85=C2=B7=EC=A0=9C=ED=92=88=EC=BD=94=EB=93=9C=C2=B7?= =?UTF-8?q?=EC=88=98=EB=9F=89=20=ED=91=9C=EB=A1=9C,=20=EC=97=91=EC=85=80?= =?UTF-8?q?=20=EB=8B=A4=EC=9A=B4=EB=A1=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ② 상자 목록: 카드 대신 표. 한 상자에 여러 제품이면 상자번호 칸 rowspan 병합 - GET /cupang/{id}/boxes.xlsx — 같은 표를 xlsx 로 (export.build_box_list_workbook) - box_list 항목에 product_code 포함 Co-Authored-By: Claude Opus 5 --- app/modules/cupang/export.py | 63 +++++++++++++++++++ app/modules/cupang/router.py | 55 +++++++++++++++- .../cupang/templates/cupang/box_calc.html | 2 +- .../cupang/templates/cupang/box_rules.html | 2 +- .../cupang/templates/cupang/centers.html | 2 +- .../cupang/templates/cupang/index.html | 2 +- .../cupang/templates/cupang/products.html | 2 +- app/modules/cupang/templates/cupang/view.html | 48 ++++++++------ app/static/cupang.css | 59 +++++++---------- 9 files changed, 174 insertions(+), 61 deletions(-) diff --git a/app/modules/cupang/export.py b/app/modules/cupang/export.py index ad1a7d2..f6b85b6 100644 --- a/app/modules/cupang/export.py +++ b/app/modules/cupang/export.py @@ -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 diff --git a/app/modules/cupang/router.py b/app/modules/cupang/router.py index 73a836b..1cca447 100644 --- a/app/modules/cupang/router.py +++ b/app/modules/cupang/router.py @@ -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 []) diff --git a/app/modules/cupang/templates/cupang/box_calc.html b/app/modules/cupang/templates/cupang/box_calc.html index ff3ea8a..ad8e3b2 100644 --- a/app/modules/cupang/templates/cupang/box_calc.html +++ b/app/modules/cupang/templates/cupang/box_calc.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %}
diff --git a/app/modules/cupang/templates/cupang/box_rules.html b/app/modules/cupang/templates/cupang/box_rules.html index c238572..dfb8e53 100644 --- a/app/modules/cupang/templates/cupang/box_rules.html +++ b/app/modules/cupang/templates/cupang/box_rules.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %}
diff --git a/app/modules/cupang/templates/cupang/centers.html b/app/modules/cupang/templates/cupang/centers.html index 0d4f267..9a9badf 100644 --- a/app/modules/cupang/templates/cupang/centers.html +++ b/app/modules/cupang/templates/cupang/centers.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %}
diff --git a/app/modules/cupang/templates/cupang/index.html b/app/modules/cupang/templates/cupang/index.html index 4b7fe14..01f85e8 100644 --- a/app/modules/cupang/templates/cupang/index.html +++ b/app/modules/cupang/templates/cupang/index.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %}
diff --git a/app/modules/cupang/templates/cupang/products.html b/app/modules/cupang/templates/cupang/products.html index 164bb12..9db77dc 100644 --- a/app/modules/cupang/templates/cupang/products.html +++ b/app/modules/cupang/templates/cupang/products.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %}
diff --git a/app/modules/cupang/templates/cupang/view.html b/app/modules/cupang/templates/cupang/view.html index f9cfed0..d707244 100644 --- a/app/modules/cupang/templates/cupang/view.html +++ b/app/modules/cupang/templates/cupang/view.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %} {# 출고 묶음 보기 — 상자 계산 화면과 같은 3열 구성. 읽기 전용(수정 없음). #} @@ -49,6 +49,8 @@

② 상자 목록

상자마다 담긴 제품과 수량 + 엑셀 다운로드
@@ -71,26 +73,34 @@
-
    - {% for b in box_list %} -
  • -
    - {{ b.no }} - {{ b.box_name or '상자' }} - {% if b.kind == 'mix' %}혼합{% endif %} - {{ b.quantity }}개 -
    -
      + + + + + + + + + + + + {% for b in box_list %} {% for it in b['items'] %} -
    • {{ it.product_name }}{{ it.quantity }}개
    • + + {% if loop.first %} + + {% endif %} + + + + {% endfor %} - - - {% endfor %} - {% if not box_list %} -
    • 상자 정보가 없습니다.
    • - {% endif %} - + {% endfor %} + {% if not box_list %} + + {% endif %} + +
      상자번호제품명제품코드수량
      {{ b.no }}{{ it.product_name }}{{ it.product_code }}{{ it.quantity }}
      상자 정보가 없습니다.
diff --git a/app/static/cupang.css b/app/static/cupang.css index 8b67500..b4bb3ed 100644 --- a/app/static/cupang.css +++ b/app/static/cupang.css @@ -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;