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)
|
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:
|
def parcel_batch_id(self, *, parcel_id: int) -> int | None:
|
||||||
with self._pool.connection() as conn:
|
with self._pool.connection() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
|
|||||||
@@ -136,6 +136,90 @@ def build_workbook_bytes(
|
|||||||
return buf.getvalue()
|
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:
|
def _cell(value: Any) -> Any:
|
||||||
"""송장/전화번호 등이 숫자로 보이면 엑셀이 과학표기/반올림 하므로 문자열 보존."""
|
"""송장/전화번호 등이 숫자로 보이면 엑셀이 과학표기/반올림 하므로 문자열 보존."""
|
||||||
if isinstance(value, int):
|
if isinstance(value, int):
|
||||||
|
|||||||
@@ -76,6 +76,24 @@ def _itemcode_name_map(request: Request) -> dict[str, str]:
|
|||||||
return {}
|
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]:
|
def _require_user(request: Request) -> dict[str, Any]:
|
||||||
from app.main import get_current_user_record # noqa: WPS433
|
from app.main import get_current_user_record # noqa: WPS433
|
||||||
from app.store import has_module # 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):
|
if not isinstance(guard, tuple):
|
||||||
return guard
|
return guard
|
||||||
st, user = guard
|
st, user = guard
|
||||||
|
batches = st.list_batches()
|
||||||
ctx = _base_ctx(request, user)
|
ctx = _base_ctx(request, user)
|
||||||
ctx.update(
|
ctx.update(
|
||||||
{
|
{
|
||||||
"page_title": "말레이시아 배송 — 출고 배치",
|
"page_title": "말레이시아 배송 — 출고 배치",
|
||||||
"page_subtitle": "TikTok 일일 출고 배치 목록",
|
"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",
|
"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카드)
|
# 출고 작업 리스트 (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 { height:26px;width:auto;max-width:120px;vertical-align:middle;object-fit:contain; }
|
||||||
.dsp-pf-logo[src*="shopee"] { height:34px; }
|
.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>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -15,7 +27,8 @@
|
|||||||
<section class="dsp">
|
<section class="dsp">
|
||||||
{% include "dispatch/_nav.html" %}
|
{% 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;">
|
<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>
|
<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>
|
<a class="erp-btn erp-btn-primary" href="/dispatch/batches/new" data-ko="+ 새 업로드" data-en="+ New Upload">+ 새 업로드</a>
|
||||||
@@ -72,5 +85,101 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</section>
|
||||||
{% endblock %}
|
{% 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