diff --git a/app/modules/malaysia/README.md b/app/modules/malaysia/README.md index 3215ba9..95474ba 100644 --- a/app/modules/malaysia/README.md +++ b/app/modules/malaysia/README.md @@ -91,6 +91,8 @@ JSON API: | POST | `/malaysia/api/movements/set-out` | 세트 출고(BOM 분해) | | 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 으로 저장돼 있어 자동 합산. 시트 `일자별`(피벗) + `상세`(긴 형식) | --- diff --git a/app/modules/malaysia/db.py b/app/modules/malaysia/db.py index bec749e..4a398f2 100644 --- a/app/modules/malaysia/db.py +++ b/app/modules/malaysia/db.py @@ -388,6 +388,50 @@ class MalaysiaStockStore: ).fetchall() return {r["item_code"]: int(r["qty"] or 0) for r in rows} + def movement_totals_by_date( + self, + *, + warehouse_code: str, + movement_type: str, + date_from: str | None = None, + date_to: str | None = None, + ) -> list[dict[str, Any]]: + """기간 내 일자 × 낱개 아이템별 합계 수량. + + 세트(콤보) 출고는 생성 시 이미 구성품 OUT movement 로 분해 저장되므로 + (`create_set_out`) 여기서 별도 분해 없이 낱개 기준으로 합산된다. + 반환: [{"movement_date": "2026-09-09", "item_code": "MT-0750", "qty": 12}, ...] + """ + if movement_type not in store.MOVEMENT_TYPES: + raise ValueError(f"허용되지 않는 이동 종류: {movement_type}") + clauses = ["warehouse_code = %s", "movement_type = %s"] + params: list[Any] = [warehouse_code, movement_type] + if date_from: + clauses.append("movement_date >= %s") + params.append(date_from) + if date_to: + clauses.append("movement_date <= %s") + params.append(date_to) + with self._pool.connection() as conn: + rows = conn.execute( + f""" + SELECT movement_date, item_code, SUM(qty) AS qty + FROM stock_movement + WHERE {" AND ".join(clauses)} + GROUP BY movement_date, item_code + ORDER BY movement_date, item_code + """, + params, + ).fetchall() + return [ + { + "movement_date": str(r["movement_date"]), + "item_code": r["item_code"], + "qty": int(r["qty"] or 0), + } + for r in rows + ] + def current_stock(self, *, warehouse_code: str) -> dict[str, int]: """창고별 현재고(낱개 아이템 기준). diff --git a/app/modules/malaysia/router.py b/app/modules/malaysia/router.py index 4b8effb..f173e70 100644 --- a/app/modules/malaysia/router.py +++ b/app/modules/malaysia/router.py @@ -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: """창고 랙 보기 데이터를 엑셀로 내보낸다. diff --git a/app/modules/malaysia/templates/malaysia/movements.html b/app/modules/malaysia/templates/malaysia/movements.html index c677932..d4e8b23 100644 --- a/app/modules/malaysia/templates/malaysia/movements.html +++ b/app/modules/malaysia/templates/malaysia/movements.html @@ -123,6 +123,21 @@ + '&date=' + encodeURIComponent(date); window.location.href = url; } + + // 이동 이력 — 기간 전체를 일자별 낱개 수량(콤보는 BOM 분해 합산)으로 다운로드 + function mysDownloadDaily(btn) { + var box = btn.closest('.mys-daily-dl'); + var type = box.querySelector('[name=dl_type]').value; + var from = box.querySelector('[name=dl_from]').value; + var to = box.querySelector('[name=dl_to]').value; + var wh = box.getAttribute('data-wh'); + if (from && to && from > to) { alert('시작일이 종료일보다 늦습니다.'); return; } + var url = '/malaysia/movements/export-daily?wh=' + encodeURIComponent(wh) + + '&type=' + encodeURIComponent(type) + + '&from=' + encodeURIComponent(from) + + '&to=' + encodeURIComponent(to); + window.location.href = url; + } @@ -142,6 +157,22 @@ {% endfor %} + + +
+ 일자별 낱개 수량 엑셀 + + + ~ + + + 기간 비우면 전체 · 콤보는 낱개로 분해해 합산 +
Date구분TypeItemQtyMemoBy