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
|
ws.row_dimensions[TITLE_ROW].height = 28
|
||||||
|
|
||||||
return wb
|
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:
|
def _safe_next(raw: str, fallback: str) -> str:
|
||||||
"""열린 리다이렉트 방지 — /cupang/ 안쪽 경로만 허용."""
|
"""열린 리다이렉트 방지 — /cupang/ 안쪽 경로만 허용."""
|
||||||
nxt = (raw or "").strip()
|
nxt = (raw or "").strip()
|
||||||
@@ -1077,6 +1118,7 @@ def _expand_box_list(
|
|||||||
contents = [
|
contents = [
|
||||||
{
|
{
|
||||||
"product_name": it.get("product_name") or it.get("product_code") or "",
|
"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),
|
"quantity": int(it.get("quantity") or 0),
|
||||||
}
|
}
|
||||||
for it in (entry.get("items") or [])
|
for it in (entry.get("items") or [])
|
||||||
@@ -1087,7 +1129,11 @@ def _expand_box_list(
|
|||||||
else:
|
else:
|
||||||
code = str(entry.get("product_code") or "")
|
code = str(entry.get("product_code") or "")
|
||||||
contents = [
|
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, "")
|
box_name = box_name_by_code.get(code, "")
|
||||||
for _ in range(count):
|
for _ in range(count):
|
||||||
@@ -1102,7 +1148,11 @@ def _expand_box_list(
|
|||||||
"kind": "product",
|
"kind": "product",
|
||||||
"box_name": r.get("box_name") or "",
|
"box_name": r.get("box_name") or "",
|
||||||
"items": [
|
"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": [
|
"items": [
|
||||||
{
|
{
|
||||||
"product_name": it.get("product_name") or "",
|
"product_name": it.get("product_name") or "",
|
||||||
|
"product_code": it.get("product_code") or "",
|
||||||
"quantity": int(it.get("quantity") or 0),
|
"quantity": int(it.get("quantity") or 0),
|
||||||
}
|
}
|
||||||
for it in (box.get("items") or [])
|
for it in (box.get("items") or [])
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% 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 %}
|
{% block content %}
|
||||||
<section class="cpg">
|
<section class="cpg">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% 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 %}
|
{% block content %}
|
||||||
<section class="cpg">
|
<section class="cpg">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% 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 %}
|
{% block content %}
|
||||||
<section class="cpg">
|
<section class="cpg">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% 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 %}
|
{% block content %}
|
||||||
<section class="cpg">
|
<section class="cpg">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% 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 %}
|
{% block content %}
|
||||||
<section class="cpg">
|
<section class="cpg">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% 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 %}
|
{% block content %}
|
||||||
{# 출고 묶음 보기 — 상자 계산 화면과 같은 3열 구성. 읽기 전용(수정 없음). #}
|
{# 출고 묶음 보기 — 상자 계산 화면과 같은 3열 구성. 읽기 전용(수정 없음). #}
|
||||||
@@ -49,6 +49,8 @@
|
|||||||
<div class="cpg-card-head">
|
<div class="cpg-card-head">
|
||||||
<h2>② 상자 목록</h2>
|
<h2>② 상자 목록</h2>
|
||||||
<span class="erp-muted">상자마다 담긴 제품과 수량</span>
|
<span class="erp-muted">상자마다 담긴 제품과 수량</span>
|
||||||
|
<a class="erp-btn erp-btn-ghost cpg-boxno-dl"
|
||||||
|
href="/cupang/{{ shipment.id }}/boxes.xlsx">엑셀 다운로드</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="cpg-sum-body">
|
<div id="cpg-sum-body">
|
||||||
@@ -71,26 +73,34 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="cpg-sum-scroll">
|
<div class="cpg-sum-scroll">
|
||||||
<ul class="cpg-boxno-list">
|
<table class="erp-table cpg-boxno-table">
|
||||||
{% for b in box_list %}
|
<colgroup>
|
||||||
<li class="cpg-boxno{% if b.kind == 'mix' %} is-mix{% endif %}">
|
<col class="cpg-boxno-c-no" />
|
||||||
<div class="cpg-boxno-head">
|
<col class="cpg-boxno-c-name" />
|
||||||
<span class="cpg-boxno-no">{{ b.no }}</span>
|
<col class="cpg-boxno-c-code" />
|
||||||
<span class="cpg-boxno-name">{{ b.box_name or '상자' }}</span>
|
<col class="cpg-boxno-c-qty" />
|
||||||
{% if b.kind == 'mix' %}<span class="cpg-boxno-tag">혼합</span>{% endif %}
|
</colgroup>
|
||||||
<span class="cpg-boxno-qty">{{ b.quantity }}개</span>
|
<thead>
|
||||||
</div>
|
<tr><th>상자번호</th><th>제품명</th><th>제품코드</th><th>수량</th></tr>
|
||||||
<ul class="cpg-boxno-items">
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for b in box_list %}
|
||||||
{% for it in b['items'] %}
|
{% 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 %}
|
{% endfor %}
|
||||||
</ul>
|
{% endfor %}
|
||||||
</li>
|
{% if not box_list %}
|
||||||
{% endfor %}
|
<tr><td colspan="4" class="erp-muted">상자 정보가 없습니다.</td></tr>
|
||||||
{% if not box_list %}
|
{% endif %}
|
||||||
<li class="erp-muted">상자 정보가 없습니다.</li>
|
</tbody>
|
||||||
{% endif %}
|
</table>
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+24
-35
@@ -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-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-dl { margin-left: auto; font-size: 12px; padding: 4px 10px; }
|
||||||
.cpg-boxno {
|
.cpg-boxno-table { table-layout: fixed; width: 100%; }
|
||||||
border: 1px solid var(--color-cloud-gray);
|
.cpg-boxno-table th, .cpg-boxno-table td {
|
||||||
border-radius: 10px;
|
box-sizing: border-box;
|
||||||
padding: 8px 10px;
|
padding: 4px 8px;
|
||||||
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;
|
|
||||||
font-size: 12px;
|
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-table thead th {
|
||||||
.cpg-boxno-items li span { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
position: sticky; top: 0; z-index: 2;
|
||||||
.cpg-boxno-items li b { flex: 0 0 auto; font-family: var(--font-geist-mono); }
|
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 {
|
.cpg-view-meta {
|
||||||
margin: 12px 0 0; padding: 10px 12px;
|
margin: 12px 0 0; padding: 10px 12px;
|
||||||
|
|||||||
Reference in New Issue
Block a user