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:
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user