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:
2026-09-09 23:33:23 +09:00
parent af87f317ab
commit bc6e2a3417
4 changed files with 202 additions and 0 deletions
+2
View File
@@ -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 으로 저장돼 있어 자동 합산. 시트 `일자별`(피벗) + `상세`(긴 형식) |
---
+44
View File
@@ -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]:
"""창고별 현재고(낱개 아이템 기준).
+125
View File
@@ -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:
"""창고 랙 보기 데이터를 엑셀로 내보낸다.
@@ -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;
}
</script>
<!-- 이동 이력 -->
@@ -142,6 +157,22 @@
{% endfor %}
</span>
</div>
<!-- 일자별 낱개 수량 엑셀 (기간 전체, 콤보는 낱개로 분해 합산) -->
<div class="mys-daily-dl" data-wh="{{ selected_wh }}"
style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;padding:8px 0 12px;">
<span class="erp-muted i18n">일자별 낱개 수량 엑셀</span>
<select class="erp-input" name="dl_type" style="width:110px;">
{% for t in ['OUT','IN','ADJUST','STOCKTAKE'] %}
<option value="{{ t }}" {% if filter_type==t %}selected{% endif %}>{{ t }}</option>
{% endfor %}
</select>
<input class="erp-input" type="date" name="dl_from" value="" />
<span class="erp-muted">~</span>
<input class="erp-input" type="date" name="dl_to" value="" />
<button type="button" class="erp-btn erp-btn-outline i18n" onclick="mysDownloadDaily(this)">엑셀 다운로드</button>
<span class="erp-muted i18n" style="font-size:12px;">기간 비우면 전체 · 콤보는 낱개로 분해해 합산</span>
</div>
<div class="erp-table-wrap">
<table class="erp-table">
<thead><tr><th>Date</th><th class="i18n">구분</th><th>Type</th><th>Item</th><th style="text-align:right">Qty</th><th>Memo</th><th>By</th></tr></thead>