feat(malaysia): 입출고 일자별 낱개 수량 엑셀 다운로드

- '일괄 적용' 버튼명 '입력'으로 변경
- 입력 버튼 우측 '다운로드' 버튼 추가
- GET /malaysia/movements/export: 해당 일자/종류(IN/OUT) 낱개 합계를
  엑셀(A=사방넷코드, B=수량, C=이름)로 스트리밍
- db.daily_movement_totals(): 일자/종류별 낱개 합계 집계
- itemcode.sabangnet_code_map(): item_code → 사방넷 코드 매핑
- i18n 사전 '입력'/'다운로드' 추가, malaysia.js 캐시 버전 bump

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 10:36:10 +09:00
parent 03929ff517
commit d56365b338
6 changed files with 123 additions and 5 deletions
+25
View File
@@ -304,6 +304,31 @@ class MalaysiaStockStore:
).fetchall() ).fetchall()
return [self._serialize(r) for r in rows] return [self._serialize(r) for r in rows]
def daily_movement_totals(
self, *, warehouse_code: str, movement_date: str, movement_type: str
) -> dict[str, int]:
"""특정 일자/종류의 낱개 아이템별 합계 수량.
세트 OUT 은 생성 시 이미 구성품 OUT 으로 분해 저장되므로 그대로 합산된다.
반환: { item_code: 합계수량 }
"""
if movement_type not in store.MOVEMENT_TYPES:
raise ValueError(f"허용되지 않는 이동 종류: {movement_type}")
with self._pool.connection() as conn:
rows = conn.execute(
"""
SELECT item_code, SUM(qty) AS qty
FROM stock_movement
WHERE warehouse_code = %s
AND movement_date = %s
AND movement_type = %s
GROUP BY item_code
ORDER BY item_code
""",
(warehouse_code, movement_date, movement_type),
).fetchall()
return {r["item_code"]: int(r["qty"] or 0) for r in rows}
def current_stock(self, *, warehouse_code: str) -> dict[str, int]: def current_stock(self, *, warehouse_code: str) -> dict[str, int]:
"""창고별 현재고(낱개 아이템 기준). """창고별 현재고(낱개 아이템 기준).
+19
View File
@@ -115,6 +115,25 @@ class MalaysiaItemcodeReader:
for r in rows for r in rows
} }
def sabangnet_code_map(self) -> dict[str, str]:
"""낱개+세트 item_code → 사방넷 코드(itemcode_db). 엑셀 내보내기용."""
if not self.enabled or not self._pool:
return {}
try:
with self._pool.connection() as conn:
rows = conn.execute(
"SELECT item_code, sabangnet_code FROM single_items "
"UNION ALL SELECT item_code, sabangnet_code FROM set_items"
).fetchall()
self.last_error = ""
except Exception as exc: # noqa: BLE001
self.last_error = f"{type(exc).__name__}: {exc}"
return {}
return {
str(r["item_code"]).strip().upper(): str(r.get("sabangnet_code") or "").strip()
for r in rows
}
def close(self) -> None: def close(self) -> None:
if self._pool is not None: if self._pool is not None:
self._pool.close() self._pool.close()
+55 -1
View File
@@ -14,7 +14,7 @@ from __future__ import annotations
from typing import Any from typing import Any
from fastapi import APIRouter, Body, Depends, Form, HTTPException, Request from fastapi import APIRouter, Body, Depends, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, StreamingResponse
from app.timezone import today_kst from app.timezone import today_kst
@@ -292,6 +292,60 @@ async def movements_apply(request: Request, user: dict[str, Any] = Depends(_requ
return RedirectResponse(url=f"/malaysia/movements?wh={wh}&type={mtype}", status_code=303) return RedirectResponse(url=f"/malaysia/movements?wh={wh}&type={mtype}", status_code=303)
@router.get("/movements/export")
async def movements_export(request: Request) -> StreamingResponse:
"""해당 일자/종류(IN/OUT)에 입출고된 낱개 수량을 엑셀로 내보낸다.
컬럼: A=사방넷 코드, B=수량, C=이름.
세트 OUT 은 생성 시 구성품 OUT 으로 분해 저장되므로 낱개 기준으로 합산된다.
"""
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 ("IN", "OUT"):
raise HTTPException(status_code=400, detail="type 은 IN 또는 OUT 이어야 합니다.")
mdate = (request.query_params.get("date") or today_kst().isoformat()).strip()
totals = st.daily_movement_totals(
warehouse_code=wh, movement_date=mdate, movement_type=mtype
)
reader = _reader(request)
sabang = reader.sabangnet_code_map() if reader else {}
names = reader.name_map() if reader else {}
item_names = st.item_name_map()
from openpyxl import Workbook # noqa: WPS433
wb = Workbook()
ws = wb.active
ws.title = f"{mtype}_{mdate}"
ws.append(["사방넷코드", "수량", "이름"])
for code in sorted(totals.keys()):
qty = totals[code]
if qty == 0:
continue
ws.append([
sabang.get(code, ""),
int(qty),
names.get(code) or item_names.get(code) or code,
])
buf = io.BytesIO()
wb.save(buf)
buf.seek(0)
fname = f"malaysia_{mtype}_{mdate}.xlsx"
return StreamingResponse(
buf,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{fname}"'},
)
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
# 일일 재고조사 # 일일 재고조사
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
@@ -2,4 +2,4 @@
<div style="display:flex;justify-content:flex-end;margin-bottom:8px;"> <div style="display:flex;justify-content:flex-end;margin-bottom:8px;">
<button type="button" id="mys-lang-toggle" class="erp-btn erp-btn-outline">ENG</button> <button type="button" id="mys-lang-toggle" class="erp-btn erp-btn-outline">ENG</button>
</div> </div>
<script src="/static/malaysia.js?v=20260611"></script> <script src="/static/malaysia.js?v=20260612"></script>
@@ -42,8 +42,9 @@
<label class="erp-field"><span>메모</span> <label class="erp-field"><span>메모</span>
<input class="erp-input" type="text" name="memo" /></label> <input class="erp-input" type="text" name="memo" /></label>
</div> </div>
<div class="erp-page-actions" style="margin-top:12px;"> <div class="erp-page-actions" style="margin-top:12px;display:flex;gap:8px;">
<button type="submit" class="erp-btn erp-btn-primary">일괄 적용</button> <button type="submit" class="erp-btn erp-btn-primary">입력</button>
<button type="button" class="erp-btn erp-btn-outline" onclick="mysDownloadMovements(this)">다운로드</button>
</div> </div>
</div> </div>
@@ -87,6 +88,24 @@
</div> </div>
</form> </form>
<script>
function mysDownloadMovements(btn) {
var form = btn.closest('form');
var type = form.querySelector('[name=movement_type]').value;
var date = form.querySelector('[name=movement_date]').value;
var wh = form.querySelector('[name=warehouse_code]').value;
if (type !== 'IN' && type !== 'OUT') {
alert('다운로드는 IN(입고) 또는 OUT(출고)만 가능합니다.');
return;
}
if (!date) { alert('날짜를 선택하세요.'); return; }
var url = '/malaysia/movements/export?wh=' + encodeURIComponent(wh)
+ '&type=' + encodeURIComponent(type)
+ '&date=' + encodeURIComponent(date);
window.location.href = url;
}
</script>
<!-- 이동 이력 --> <!-- 이동 이력 -->
<div class="erp-card" style="margin-top:16px;"> <div class="erp-card" style="margin-top:16px;">
<div class="cpg-card-head" style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;"> <div class="cpg-card-head" style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;">
+2 -1
View File
@@ -38,7 +38,8 @@
"IN (입고)": "IN (In)", "IN (입고)": "IN (In)",
"OUT (출고)": "OUT (Out)", "OUT (출고)": "OUT (Out)",
"ADJUST (조정 ±)": "ADJUST (±)", "ADJUST (조정 ±)": "ADJUST (±)",
"일괄 적용": "Apply All", "입력": "Input",
"다운로드": "Download",
"낱개 상품": "Single Products", "낱개 상품": "Single Products",
"콤보 상품": "Combo Products", "콤보 상품": "Combo Products",
"이동 이력": "Movement History", "이동 이력": "Movement History",