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