feat(dispatch): 날짜별 사방넷 재고 엑셀 다운로드 + 달력 UI

- 배치 목록을 좌(리스트)/우(달력) 2단 배치
- 달력에서 배치 있는 날짜 클릭 → 그날 전 배치 출고를 낱개로 분해해 다운로드
  · _N 배수 × 주문수량 × 콤보 BOM 구성수량, MD-(뚜껑) 제외
  · 사방넷 4열 템플릿(A 상품코드[필수]=사방넷코드 / B 가용수량=수량 /
    C 불용수량=0 / D 바코드=아이템코드), 주황 헤더, xlsx
- db.stock_aggregate_for_date, export.explode_to_singles/build_stock_xlsx
- 라우터 GET /dispatch/stock-export?date=YYYY-MM-DD

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 17:25:05 +09:00
parent 2c7f98ef30
commit 9221efe925
4 changed files with 274 additions and 2 deletions
+84
View File
@@ -136,6 +136,90 @@ def build_workbook_bytes(
return buf.getvalue()
# ── 사방넷 재고 업로드 엑셀(낱개 분해) ──
_STOCK_HEADERS: tuple[str, ...] = ("상품코드[필수]", "가용수량", "불용수량", "바코드")
_LID_PREFIX = "MD-" # 뚜껑 — 재고 집계 제외
def explode_to_singles(
rows: list[dict[str, Any]],
set_bom: dict[str, list[dict[str, Any]]],
) -> dict[str, int]:
"""SKU별 합산 행 → 낱개 코드별 수량.
- seller_sku 의 _N 접미사는 배수(같은 상품 N개)로 곱한다.
- 콤보(세트, set_bom 에 존재)는 구성 낱개로 분해해 (배수 × 구성수량) 더한다.
- MD-(뚜껑)은 재고 집계에서 제외한다.
반환: {낱개코드(대문자): 수량}.
"""
singles: dict[str, int] = {}
for r in rows:
base, mult = split_sku(r.get("seller_sku", ""))
base = base.upper()
if not base:
continue
pieces = int(r.get("qty", 0)) * mult
if pieces <= 0 or base.startswith(_LID_PREFIX):
continue
comps = set_bom.get(base)
if comps: # 콤보 → 낱개 분해
for c in comps:
cc = str(c.get("component_code", "")).strip().upper()
if not cc or cc.startswith(_LID_PREFIX):
continue
singles[cc] = singles.get(cc, 0) + pieces * int(c.get("component_qty", 0) or 0)
else:
singles[base] = singles.get(base, 0) + pieces
return {k: v for k, v in singles.items() if v > 0}
def build_stock_xlsx(
*,
rows: list[dict[str, Any]],
set_bom: dict[str, list[dict[str, Any]]],
sabangnet_map: dict[str, str],
) -> bytes:
"""사방넷 재고 업로드용 xlsx(낱개 분해). 4열: 상품코드[필수]/가용수량/불용수량/바코드.
A=사방넷코드, B=수량, C=0(불용), D=아이템코드(바코드). 코드 오름차순.
"""
from openpyxl import Workbook # noqa: WPS433
from openpyxl.styles import Alignment, Font, PatternFill
singles = explode_to_singles(rows, set_bom)
wb = Workbook()
ws = wb.active
ws.title = "재고"
ws.append(list(_STOCK_HEADERS))
head_fill = PatternFill("solid", fgColor="C55A11") # 사방넷 템플릿 주황
head_font = Font(bold=True, color="FFFFFF")
for cell in ws[1]:
cell.fill = head_fill
cell.font = head_font
cell.alignment = Alignment(horizontal="center", vertical="center")
for code in sorted(singles):
sabangnet = sabangnet_map.get(code, "")
ws.append([sabangnet, singles[code], 0, code])
widths = [22, 12, 12, 18]
from openpyxl.utils import get_column_letter
for i, w in enumerate(widths, start=1):
ws.column_dimensions[get_column_letter(i)].width = w
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
def stock_filename(dispatch_date: str) -> str:
"""재고 엑셀 파일명: 2026.06.22(Mon)_sabangnet.xlsx."""
d = _parse_date(dispatch_date)
stamp = d.strftime("%Y.%m.%d(%a)") if d else (dispatch_date or "stock").strip()
return f"{stamp}_sabangnet.xlsx"
def _cell(value: Any) -> Any:
"""송장/전화번호 등이 숫자로 보이면 엑셀이 과학표기/반올림 하므로 문자열 보존."""
if isinstance(value, int):