diff --git a/app/modules/malaysia/README.md b/app/modules/malaysia/README.md index 95474ba..ddd4866 100644 --- a/app/modules/malaysia/README.md +++ b/app/modules/malaysia/README.md @@ -92,7 +92,7 @@ JSON API: | GET | `/malaysia/api/stocktakes/{id}/result` | 재고조사 최종 계산 | | POST | `/malaysia/stocktakes/{id}/rack/bulk` | 랙 입력 일괄 저장(병렬배열 `rk_cell/rk_sku/rk_upb/rk_box`) → 라인 재생성 | | GET | `/malaysia/movements/export` | 특정 일자/종류의 낱개 수량 엑셀 | -| GET | `/malaysia/movements/export-daily` | 기간 전체를 **일자 × 낱개 아이템** 수량으로 엑셀(`wh/type/from/to`). 콤보는 이미 구성 낱개 OUT 으로 저장돼 있어 자동 합산. 시트 `일자별`(피벗) + `상세`(긴 형식) | +| GET | `/malaysia/movements/export-daily` | 기간 전체를 **행=낱개 상품 / 열=일자** 표로 엑셀(`wh/type/from/to`). A=상품명, B=자체상품코드, C~=`MM월 DD일`. 콤보는 이미 구성 낱개 OUT 으로 저장돼 있어 자동 합산. 기간 생략 시 이력 최소~최대(최대 2년), 빈 날짜/무이동 상품도 줄·칸 유지 | --- diff --git a/app/modules/malaysia/router.py b/app/modules/malaysia/router.py index f173e70..4ca0c69 100644 --- a/app/modules/malaysia/router.py +++ b/app/modules/malaysia/router.py @@ -483,16 +483,18 @@ async def movements_export(request: Request) -> StreamingResponse: @router.get("/movements/export-daily") async def movements_export_daily(request: Request) -> StreamingResponse: - """기간 전체를 일자별 × 낱개 아이템 수량으로 엑셀 내보내기. + """기간 전체를 상품(행) × 일자(열) 표로 엑셀 내보내기. 콤보(세트)는 등록 시 이미 BOM 으로 분해돼 구성 낱개 OUT movement 로 저장되므로 - (`db.create_set_out`) 여기 합계는 "낱개 + 세트 분해분"이 모두 더해진 값이다. + (`db.create_set_out`) 여기 수량은 "낱개 + 세트 분해분"이 모두 더해진 값이다. - 쿼리: wh(창고), type(IN|OUT|ADJUST|STOCKTAKE, 기본 OUT), from, to(YYYY-MM-DD, 생략 시 전체) - 시트1 '일자별' — 행=날짜, 열=낱개 아이템(코드/이름 2줄 헤더), 마지막 열/행 합계. - 시트2 '상세' — 날짜/코드/사방넷코드/이름/수량 긴 형식. + 쿼리: wh(창고), type(IN|OUT|ADJUST|STOCKTAKE, 기본 OUT), from, to(YYYY-MM-DD) + 기간 생략 시 이력의 최소~최대 날짜. 열은 기간 내 모든 날짜(빈 날 포함), + 행은 등록된 낱개 아이템 전체(수량 없는 아이템도 빈 줄로 표시). + A=상품명, B=자체상품코드, C~=`MM월 DD일`. 수량 0 은 빈칸. """ import io # noqa: WPS433 + from datetime import date as _date, timedelta as _timedelta # noqa: WPS433 guard = _guard(request) if not isinstance(guard, tuple): @@ -510,95 +512,84 @@ async def movements_export_daily(request: Request) -> StreamingResponse: date_from=dfrom or None, date_to=dto or None, ) - reader = _reader(request) - sabang = reader.sabangnet_code_map() if reader else {} - names = reader.name_map() if reader else {} - item_names = st.item_name_map() - - def _name(code: str) -> str: - return names.get(code) or item_names.get(code) or code - - # 열 순서: 등록된 낱개 아이템 순서 우선, 이력에만 있는 코드는 뒤에 붙인다. - used = {r["item_code"] for r in rows if r["qty"]} - codes = [it["item_code"] for it in st.list_items(kind="individual") if it["item_code"] in used] - codes += sorted(used - set(codes)) - - # {날짜: {코드: 수량}} + # {코드: {날짜: 수량}} grid: dict[str, dict[str, int]] = {} for r in rows: if not r["qty"]: continue - grid.setdefault(r["movement_date"], {})[r["item_code"]] = r["qty"] - dates = sorted(grid.keys()) + grid.setdefault(r["item_code"], {})[r["movement_date"]] = r["qty"] + + # 열(날짜) — 지정 기간 우선, 없으면 이력 최소~최대. 빈 날짜도 열로 유지. + seen = sorted({d for per in grid.values() for d in per}) + first = dfrom or (seen[0] if seen else today_kst().isoformat()) + last = dto or (seen[-1] if seen else today_kst().isoformat()) + try: + d0, d1 = _date.fromisoformat(first[:10]), _date.fromisoformat(last[:10]) + except ValueError: + raise HTTPException(status_code=400, detail="날짜 형식은 YYYY-MM-DD 이어야 합니다.") + if d1 < d0: + raise HTTPException(status_code=400, detail="시작일이 종료일보다 늦습니다.") + if (d1 - d0).days > 732: + raise HTTPException(status_code=400, detail="기간이 너무 깁니다(최대 2년). 기간을 좁혀 주세요.") + dates = [(d0 + _timedelta(days=i)).isoformat() for i in range((d1 - d0).days + 1)] + + # 행(상품) — 등록된 낱개 아이템 전체 + 이력에만 있는 코드는 뒤에 추가. + reader = _reader(request) + names = reader.name_map() if reader else {} + item_names = st.item_name_map() + items = st.list_items(kind="individual") + codes = [it["item_code"] for it in items] + codes += sorted(set(grid) - set(codes)) + + def _name(code: str) -> str: + return names.get(code) or item_names.get(code) or code from openpyxl import Workbook # noqa: WPS433 - from openpyxl.styles import Alignment, Font, PatternFill # noqa: WPS433 + from openpyxl.styles import Alignment, Border, Font, PatternFill, Side # noqa: WPS433 from openpyxl.utils import get_column_letter # noqa: WPS433 - fill = PatternFill(start_color="FF4472C4", end_color="FF4472C4", fill_type="solid") - head_font = Font(color="FFFFFFFF", bold=True) - center = Alignment(horizontal="center", vertical="center", wrap_text=True) - wb = Workbook() ws = wb.active - ws.title = "일자별" - # 2줄 헤더: 1행 코드, 2행 이름 - ws.append(["날짜"] + codes + ["합계"]) - ws.append([mtype] + [_name(c) for c in codes] + [""]) - for d in dates: - line = grid[d] - vals = [int(line.get(c, 0)) for c in codes] - ws.append([d] + vals + [sum(vals)]) - if dates: - totals = [sum(int(grid[d].get(c, 0)) for d in dates) for c in codes] - ws.append(["합계"] + totals + [sum(totals)]) - last = ws.max_row - for col in range(1, len(codes) + 3): - ws.cell(row=last, column=col).font = Font(bold=True) + ws.title = f"{mtype}_일자별" - for col in range(1, len(codes) + 3): - for row in (1, 2): - cell = ws.cell(row=row, column=col) - cell.fill = fill - cell.font = head_font - cell.alignment = center - ws.freeze_panes = "B3" - ws.column_dimensions["A"].width = 13 - for col in range(2, len(codes) + 3): - ws.column_dimensions[get_column_letter(col)].width = 12 + header = ["상품명", "자체상품코드"] + [f"{d[5:7]}월 {d[8:10]}일" for d in dates] + ws.append(header) + for code in codes: + per = grid.get(code, {}) + ws.append( + [_name(code), code] + + [(int(per[d]) if per.get(d) else None) for d in dates] + ) - # 시트2 — 긴 형식(피벗/필터용) - ws2 = wb.create_sheet("상세") - header2 = ["날짜", "아이템코드", "사방넷코드", "이름", "수량"] - ws2.append(header2) - for d in dates: - for c in codes: - qty = int(grid[d].get(c, 0)) - if qty == 0: - continue - ws2.append([d, c, sabang.get(c, ""), _name(c), qty]) - for col in range(1, len(header2) + 1): - cell = ws2.cell(row=1, column=col) + ncols = len(header) + fill = PatternFill(start_color="FF9B3C28", end_color="FF9B3C28", fill_type="solid") + head_font = Font(color="FFFFFFFF", bold=True) + center = Alignment(horizontal="center", vertical="center") + thin = Side(style="thin", color="FFBFBFBF") + box = Border(left=thin, right=thin, top=thin, bottom=thin) + for col in range(1, ncols + 1): + cell = ws.cell(row=1, column=col) cell.fill = fill cell.font = head_font cell.alignment = center - ws2.freeze_panes = "A2" - if ws2.max_row > 1: - ws2.auto_filter.ref = f"A1:{get_column_letter(len(header2))}{ws2.max_row}" - for col in range(1, len(header2) + 1): - letter = get_column_letter(col) - longest = 0 - for cell in ws2[letter]: - v = "" if cell.value is None else str(cell.value) - width = sum(2 if ord(ch) > 0x1100 else 1 for ch in v) - longest = max(longest, width) - ws2.column_dimensions[letter].width = max(8, min(50, longest + 2)) + for row in range(2, ws.max_row + 1): + for col in range(1, ncols + 1): + cell = ws.cell(row=row, column=col) + cell.border = box + if col >= 3: + cell.alignment = center + + ws.freeze_panes = "C2" + longest = max((sum(2 if ord(ch) > 0x1100 else 1 for ch in _name(c)) for c in codes), default=8) + ws.column_dimensions["A"].width = max(14, min(40, longest + 2)) + ws.column_dimensions["B"].width = 16 + for col in range(3, ncols + 1): + ws.column_dimensions[get_column_letter(col)].width = 11 buf = io.BytesIO() wb.save(buf) buf.seek(0) - span = f"{dfrom or 'all'}_{dto or 'all'}" - fname = f"malaysia_{mtype}_daily_{span}.xlsx" + fname = f"malaysia_{mtype}_{d0.isoformat()}_{d1.isoformat()}.xlsx" return StreamingResponse( buf, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",