feat(dispatch): 날짜별 사방넷 재고 엑셀 다운로드 + 달력 UI
- 배치 목록을 좌(리스트)/우(달력) 2단 배치
- 달력에서 배치 있는 날짜 클릭 → 그날 전 배치 출고를 낱개로 분해해 다운로드
· _N 배수 × 주문수량 × 콤보 BOM 구성수량, MD-(뚜껑) 제외
· 사방넷 4열 템플릿(A 상품코드[필수]=사방넷코드 / B 가용수량=수량 /
C 불용수량=0 / D 바코드=아이템코드), 주황 헤더, xlsx
- db.stock_aggregate_for_date, export.explode_to_singles/build_stock_xlsx
- 라우터 GET /dispatch/stock-export?date=YYYY-MM-DD
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -286,6 +286,29 @@ class DispatchStore:
|
||||
)
|
||||
return len(ids)
|
||||
|
||||
def stock_aggregate_for_date(self, *, dispatch_date: str) -> list[dict[str, Any]]:
|
||||
"""해당 날짜 모든 배치의 SKU별 합산 수량.
|
||||
|
||||
반환: [{"seller_sku", "qty"}]. _N 배수/콤보 분해는 호출부(export)에서 적용.
|
||||
"""
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT i.seller_sku, SUM(i.quantity)::int AS qty
|
||||
FROM dispatch_items i
|
||||
JOIN dispatch_parcels p ON p.id = i.parcel_id
|
||||
JOIN dispatch_batches b ON b.id = p.batch_id
|
||||
WHERE b.dispatch_date = %s
|
||||
GROUP BY i.seller_sku
|
||||
ORDER BY i.seller_sku ASC
|
||||
""",
|
||||
(dispatch_date,),
|
||||
).fetchall()
|
||||
return [
|
||||
{"seller_sku": r["seller_sku"] or "", "qty": int(r["qty"] or 0)}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def parcel_batch_id(self, *, parcel_id: int) -> int | None:
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
|
||||
@@ -136,6 +136,90 @@ def build_workbook_bytes(
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
# ── 사방넷 재고 업로드 엑셀(낱개 분해) ──
|
||||
_STOCK_HEADERS: tuple[str, ...] = ("상품코드[필수]", "가용수량", "불용수량", "바코드")
|
||||
_LID_PREFIX = "MD-" # 뚜껑 — 재고 집계 제외
|
||||
|
||||
|
||||
def explode_to_singles(
|
||||
rows: list[dict[str, Any]],
|
||||
set_bom: dict[str, list[dict[str, Any]]],
|
||||
) -> dict[str, int]:
|
||||
"""SKU별 합산 행 → 낱개 코드별 수량.
|
||||
|
||||
- seller_sku 의 _N 접미사는 배수(같은 상품 N개)로 곱한다.
|
||||
- 콤보(세트, set_bom 에 존재)는 구성 낱개로 분해해 (배수 × 구성수량) 더한다.
|
||||
- MD-(뚜껑)은 재고 집계에서 제외한다.
|
||||
반환: {낱개코드(대문자): 수량}.
|
||||
"""
|
||||
singles: dict[str, int] = {}
|
||||
for r in rows:
|
||||
base, mult = split_sku(r.get("seller_sku", ""))
|
||||
base = base.upper()
|
||||
if not base:
|
||||
continue
|
||||
pieces = int(r.get("qty", 0)) * mult
|
||||
if pieces <= 0 or base.startswith(_LID_PREFIX):
|
||||
continue
|
||||
comps = set_bom.get(base)
|
||||
if comps: # 콤보 → 낱개 분해
|
||||
for c in comps:
|
||||
cc = str(c.get("component_code", "")).strip().upper()
|
||||
if not cc or cc.startswith(_LID_PREFIX):
|
||||
continue
|
||||
singles[cc] = singles.get(cc, 0) + pieces * int(c.get("component_qty", 0) or 0)
|
||||
else:
|
||||
singles[base] = singles.get(base, 0) + pieces
|
||||
return {k: v for k, v in singles.items() if v > 0}
|
||||
|
||||
|
||||
def build_stock_xlsx(
|
||||
*,
|
||||
rows: list[dict[str, Any]],
|
||||
set_bom: dict[str, list[dict[str, Any]]],
|
||||
sabangnet_map: dict[str, str],
|
||||
) -> bytes:
|
||||
"""사방넷 재고 업로드용 xlsx(낱개 분해). 4열: 상품코드[필수]/가용수량/불용수량/바코드.
|
||||
|
||||
A=사방넷코드, B=수량, C=0(불용), D=아이템코드(바코드). 코드 오름차순.
|
||||
"""
|
||||
from openpyxl import Workbook # noqa: WPS433
|
||||
from openpyxl.styles import Alignment, Font, PatternFill
|
||||
|
||||
singles = explode_to_singles(rows, set_bom)
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "재고"
|
||||
ws.append(list(_STOCK_HEADERS))
|
||||
head_fill = PatternFill("solid", fgColor="C55A11") # 사방넷 템플릿 주황
|
||||
head_font = Font(bold=True, color="FFFFFF")
|
||||
for cell in ws[1]:
|
||||
cell.fill = head_fill
|
||||
cell.font = head_font
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center")
|
||||
|
||||
for code in sorted(singles):
|
||||
sabangnet = sabangnet_map.get(code, "")
|
||||
ws.append([sabangnet, singles[code], 0, code])
|
||||
|
||||
widths = [22, 12, 12, 18]
|
||||
from openpyxl.utils import get_column_letter
|
||||
for i, w in enumerate(widths, start=1):
|
||||
ws.column_dimensions[get_column_letter(i)].width = w
|
||||
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def stock_filename(dispatch_date: str) -> str:
|
||||
"""재고 엑셀 파일명: 2026.06.22(Mon)_sabangnet.xlsx."""
|
||||
d = _parse_date(dispatch_date)
|
||||
stamp = d.strftime("%Y.%m.%d(%a)") if d else (dispatch_date or "stock").strip()
|
||||
return f"{stamp}_sabangnet.xlsx"
|
||||
|
||||
|
||||
def _cell(value: Any) -> Any:
|
||||
"""송장/전화번호 등이 숫자로 보이면 엑셀이 과학표기/반올림 하므로 문자열 보존."""
|
||||
if isinstance(value, int):
|
||||
|
||||
@@ -76,6 +76,24 @@ def _itemcode_name_map(request: Request) -> dict[str, str]:
|
||||
return {}
|
||||
|
||||
|
||||
def _itemcode_bom_and_sabangnet(request: Request) -> tuple[dict[str, Any], dict[str, str]]:
|
||||
"""(세트 BOM map, item_code→사방넷코드 map). 미설정/실패 시 빈 dict."""
|
||||
reader = getattr(request.app.state, "malaysia_itemcode", None)
|
||||
if reader is None or not getattr(reader, "enabled", False):
|
||||
return {}, {}
|
||||
bom: dict[str, Any] = {}
|
||||
sab: dict[str, str] = {}
|
||||
try:
|
||||
bom = reader.set_bom_map()
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("itemcode set_bom_map 조회 실패")
|
||||
try:
|
||||
sab = reader.sabangnet_code_map()
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("itemcode sabangnet_code_map 조회 실패")
|
||||
return bom, sab
|
||||
|
||||
|
||||
def _require_user(request: Request) -> dict[str, Any]:
|
||||
from app.main import get_current_user_record # noqa: WPS433
|
||||
from app.store import has_module # noqa: WPS433
|
||||
@@ -180,12 +198,14 @@ async def batches_index(request: Request) -> HTMLResponse:
|
||||
if not isinstance(guard, tuple):
|
||||
return guard
|
||||
st, user = guard
|
||||
batches = st.list_batches()
|
||||
ctx = _base_ctx(request, user)
|
||||
ctx.update(
|
||||
{
|
||||
"page_title": "말레이시아 배송 — 출고 배치",
|
||||
"page_subtitle": "TikTok 일일 출고 배치 목록",
|
||||
"batches": st.list_batches(),
|
||||
"batches": batches,
|
||||
"batch_dates": sorted({b["dispatch_date"] for b in batches if b.get("dispatch_date")}),
|
||||
"active_tab": "batches",
|
||||
}
|
||||
)
|
||||
@@ -382,6 +402,42 @@ async def batch_download(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stock-export")
|
||||
async def stock_export(
|
||||
request: Request, date: str, user: dict[str, Any] = Depends(_require_user)
|
||||
) -> Response:
|
||||
"""선택한 날짜의 전 배치 출고 수량을 낱개로 분해해 사방넷 재고 업로드 xlsx 다운로드.
|
||||
|
||||
콤보(세트)는 BOM 으로 낱개 분해, _N 배수 반영, MD-(뚜껑) 제외.
|
||||
A=사방넷코드 / B=수량 / C=불용수량(0) / D=아이템코드.
|
||||
"""
|
||||
st = _store(request)
|
||||
if st is None:
|
||||
raise HTTPException(status_code=503, detail="dispatch_db 미설정")
|
||||
d = (date or "").strip()
|
||||
if not d:
|
||||
raise HTTPException(status_code=400, detail="날짜가 필요합니다.")
|
||||
|
||||
rows = st.stock_aggregate_for_date(dispatch_date=d)
|
||||
if not rows:
|
||||
raise HTTPException(status_code=404, detail="해당 날짜의 출고 데이터가 없습니다.")
|
||||
|
||||
set_bom, sabangnet_map = _itemcode_bom_and_sabangnet(request)
|
||||
xlsx_bytes = export.build_stock_xlsx(rows=rows, set_bom=set_bom, sabangnet_map=sabangnet_map)
|
||||
fname = export.stock_filename(d)
|
||||
headers = {
|
||||
"Content-Disposition": (
|
||||
f"attachment; filename=\"stock_{d}.xlsx\"; "
|
||||
f"filename*=UTF-8''{quote(fname)}"
|
||||
)
|
||||
}
|
||||
return Response(
|
||||
content=xlsx_bytes,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 출고 작업 리스트 (1박스 = 1카드)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -8,6 +8,18 @@
|
||||
/* 플랫폼 로고: 아이콘마다 여백이 달라 시각 크기를 맞춘다 */
|
||||
.dsp-pf-logo { height:26px;width:auto;max-width:120px;vertical-align:middle;object-fit:contain; }
|
||||
.dsp-pf-logo[src*="shopee"] { height:34px; }
|
||||
/* 좌(리스트) / 우(달력) 2단 */
|
||||
.dsp-layout { display:flex;gap:16px;align-items:flex-start;flex-wrap:wrap; }
|
||||
.dsp-list { flex:1 1 620px;min-width:320px; }
|
||||
.dsp-calwrap { flex:0 1 340px;min-width:280px; }
|
||||
/* 달력 */
|
||||
.dsp-cal-head { display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px; }
|
||||
.dsp-cal-grid { display:grid;grid-template-columns:repeat(7,1fr);gap:4px; }
|
||||
.dsp-cal-wd { text-align:center;font-size:12px;color:#94a3b8;padding:2px 0;font-weight:600; }
|
||||
.dsp-cal-day { text-align:center;padding:8px 0;border-radius:8px;font-size:14px;color:#cbd5e1; }
|
||||
.dsp-cal-day.has { color:#0f172a;background:#eef2ff;font-weight:700;cursor:pointer;border:1px solid #c7d2fe; }
|
||||
.dsp-cal-day.has:hover { background:#e0e7ff; }
|
||||
.dsp-cal-day.sel { background:#16a34a !important;border-color:#16a34a !important;color:#fff !important; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -15,7 +27,8 @@
|
||||
<section class="dsp">
|
||||
{% include "dispatch/_nav.html" %}
|
||||
|
||||
<div class="erp-card">
|
||||
<div class="dsp-layout">
|
||||
<div class="erp-card dsp-list">
|
||||
<div class="cpg-card-head" style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<h2><span data-ko="출고 배치" data-en="Batches">출고 배치</span> ({{ batches|length }})</h2>
|
||||
<a class="erp-btn erp-btn-primary" href="/dispatch/batches/new" data-ko="+ 새 업로드" data-en="+ New Upload">+ 새 업로드</a>
|
||||
@@ -72,5 +85,101 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="erp-card dsp-calwrap">
|
||||
<div class="cpg-card-head"><h3 style="margin:0;" data-ko="재고 다운로드 (사방넷)" data-en="Stock Download (Sabangnet)">재고 다운로드 (사방넷)</h3></div>
|
||||
<p class="erp-muted" style="margin:4px 0 10px;font-size:13px;"
|
||||
data-ko="날짜를 클릭하면 그날 출고를 낱개로 분해한 사방넷 재고 엑셀을 받습니다."
|
||||
data-en="Pick a date to download the Sabangnet stock Excel (combos split into singles).">날짜를 클릭하면 그날 출고를 낱개로 분해한 사방넷 재고 엑셀을 받습니다.</p>
|
||||
|
||||
<div class="dsp-cal" id="dsp-cal">
|
||||
<div class="dsp-cal-head">
|
||||
<button type="button" class="erp-btn erp-btn-outline" id="dsp-cal-prev">‹</button>
|
||||
<span id="dsp-cal-title" style="font-weight:700;"></span>
|
||||
<button type="button" class="erp-btn erp-btn-outline" id="dsp-cal-next">›</button>
|
||||
</div>
|
||||
<div class="dsp-cal-grid" id="dsp-cal-grid"></div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:12px;display:flex;flex-direction:column;gap:8px;">
|
||||
<div class="erp-muted" style="font-size:13px;">
|
||||
<span data-ko="선택한 날짜" data-en="Selected">선택한 날짜</span>:
|
||||
<b id="dsp-cal-sel">—</b>
|
||||
</div>
|
||||
<a id="dsp-cal-dl" class="erp-btn erp-btn-primary" href="#"
|
||||
data-ko="📥 사방넷 재고 엑셀 다운로드" data-en="📥 Download Sabangnet Stock Excel"
|
||||
style="pointer-events:none;opacity:.5;">📥 사방넷 재고 엑셀 다운로드</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(function () {
|
||||
// 배치가 있는 날짜(YYYY-MM-DD)
|
||||
var BATCH_DATES = {{ batch_dates|tojson }};
|
||||
var dateSet = {};
|
||||
BATCH_DATES.forEach(function (d) { dateSet[d] = true; });
|
||||
|
||||
var grid = document.getElementById('dsp-cal-grid');
|
||||
var title = document.getElementById('dsp-cal-title');
|
||||
var selLabel = document.getElementById('dsp-cal-sel');
|
||||
var dlBtn = document.getElementById('dsp-cal-dl');
|
||||
var selected = null;
|
||||
|
||||
// 초기 표시 월: 가장 최근 배치 날짜 기준(없으면 오늘)
|
||||
var base = BATCH_DATES.length ? new Date(BATCH_DATES[BATCH_DATES.length - 1] + 'T00:00:00') : new Date();
|
||||
var viewY = base.getFullYear(), viewM = base.getMonth();
|
||||
|
||||
var WD = { ko: ['일','월','화','수','목','금','토'], en: ['Su','Mo','Tu','We','Th','Fr','Sa'] };
|
||||
function lang() { return (window.dspLang && window.dspLang() === 'en') ? 'en' : 'ko'; }
|
||||
function pad(n) { return (n < 10 ? '0' : '') + n; }
|
||||
|
||||
function render() {
|
||||
var l = lang();
|
||||
title.textContent = viewY + '.' + pad(viewM + 1);
|
||||
grid.innerHTML = '';
|
||||
WD[l].forEach(function (w) {
|
||||
var c = document.createElement('div');
|
||||
c.className = 'dsp-cal-wd'; c.textContent = w; grid.appendChild(c);
|
||||
});
|
||||
var first = new Date(viewY, viewM, 1).getDay();
|
||||
var days = new Date(viewY, viewM + 1, 0).getDate();
|
||||
for (var i = 0; i < first; i++) grid.appendChild(document.createElement('div'));
|
||||
for (var d = 1; d <= days; d++) {
|
||||
var iso = viewY + '-' + pad(viewM + 1) + '-' + pad(d);
|
||||
var cell = document.createElement('div');
|
||||
cell.className = 'dsp-cal-day';
|
||||
cell.textContent = d;
|
||||
if (dateSet[iso]) {
|
||||
cell.classList.add('has');
|
||||
cell.setAttribute('data-iso', iso);
|
||||
cell.onclick = function () { pick(this.getAttribute('data-iso')); };
|
||||
}
|
||||
if (iso === selected) cell.classList.add('sel');
|
||||
grid.appendChild(cell);
|
||||
}
|
||||
}
|
||||
|
||||
function pick(iso) {
|
||||
selected = iso;
|
||||
selLabel.textContent = iso;
|
||||
dlBtn.href = '/dispatch/stock-export?date=' + encodeURIComponent(iso);
|
||||
dlBtn.style.pointerEvents = '';
|
||||
dlBtn.style.opacity = '';
|
||||
render();
|
||||
}
|
||||
|
||||
document.getElementById('dsp-cal-prev').onclick = function () {
|
||||
viewM--; if (viewM < 0) { viewM = 11; viewY--; } render();
|
||||
};
|
||||
document.getElementById('dsp-cal-next').onclick = function () {
|
||||
viewM++; if (viewM > 11) { viewM = 0; viewY++; } render();
|
||||
};
|
||||
document.addEventListener('dsp:lang', render);
|
||||
render();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user