feat(malaysia): 이동 이력 일자별 낱개 수량 엑셀 다운로드
기간 전체를 일자 x 낱개 아이템 피벗으로 내려받는 /movements/export-daily 추가. 콤보(세트) 출고는 등록 시 이미 BOM 으로 분해돼 구성 낱개 OUT movement 로 저장되므로 별도 분해 없이 낱개 기준 합계에 포함된다. - db.movement_totals_by_date(): 기간/종류별 일자 x 아이템 합계 - 시트 '일자별'(행=날짜, 열=아이템, 합계 행/열) + '상세'(긴 형식, 자동필터) - 이동 이력 카드에 종류/기간 선택 + 다운로드 버튼 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -481,6 +481,131 @@ 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`) 여기 합계는 "낱개 + 세트 분해분"이 모두 더해진 값이다.
|
||||
|
||||
쿼리: wh(창고), type(IN|OUT|ADJUST|STOCKTAKE, 기본 OUT), from, to(YYYY-MM-DD, 생략 시 전체)
|
||||
시트1 '일자별' — 행=날짜, 열=낱개 아이템(코드/이름 2줄 헤더), 마지막 열/행 합계.
|
||||
시트2 '상세' — 날짜/코드/사방넷코드/이름/수량 긴 형식.
|
||||
"""
|
||||
import io # noqa: WPS433
|
||||
|
||||
guard = _guard(request)
|
||||
if not isinstance(guard, tuple):
|
||||
return guard # type: ignore[return-value]
|
||||
st, _user = guard
|
||||
wh = _selected_wh(request, st)
|
||||
mtype = (request.query_params.get("type") or "OUT").strip().upper()
|
||||
if mtype not in store.MOVEMENT_TYPES:
|
||||
raise HTTPException(status_code=400, detail=f"허용되지 않는 이동 종류: {mtype}")
|
||||
dfrom = (request.query_params.get("from") or "").strip()
|
||||
dto = (request.query_params.get("to") or "").strip()
|
||||
|
||||
rows = st.movement_totals_by_date(
|
||||
warehouse_code=wh, movement_type=mtype,
|
||||
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())
|
||||
|
||||
from openpyxl import Workbook # noqa: WPS433
|
||||
from openpyxl.styles import Alignment, Font, PatternFill # 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)
|
||||
|
||||
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
|
||||
|
||||
# 시트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)
|
||||
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))
|
||||
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
buf.seek(0)
|
||||
span = f"{dfrom or 'all'}_{dto or 'all'}"
|
||||
fname = f"malaysia_{mtype}_daily_{span}.xlsx"
|
||||
return StreamingResponse(
|
||||
buf,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": f'attachment; filename="{fname}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/rack/export")
|
||||
async def rack_export(request: Request) -> StreamingResponse:
|
||||
"""창고 랙 보기 데이터를 엑셀로 내보낸다.
|
||||
|
||||
Reference in New Issue
Block a user