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:
@@ -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]:
|
||||||
"""창고별 현재고(낱개 아이템 기준).
|
"""창고별 현재고(낱개 아이템 기준).
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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;">
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user