feat(dispatch): 말레이시아 재고 차감 버튼(날짜 1회성)

- 달력 다운로드 아래 '말레이시아 재고 차감' 버튼. 선택 날짜 출고를
  말레이시아 재고관리에 OUT 으로 반영(movement_date=해당 날짜)
  · 낱개(MT/MX/MZ)=낱개 OUT, 콤보(MY-)=세트 OUT(재고관리가 BOM 분해)
  · _N 배수 반영, MD-(뚜껑) 제외
- 날짜당 1회만: dispatch_stock_deductions 마커로 재차감 차단(이중 출고 방지)
  · 마커 선점 후 반영, 콤보 BOM 누락은 사전검증으로 차단
- export.split_singles_combos, db 차감 가드 메서드, 라우터 POST /dispatch/stock-deduct
- 스키마: dispatch_stock_deductions (init + 마이그레이션 dispatch_add_deductions.sql)
- 이미 차감된 날짜는 버튼 비활성 + 안내

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 18:07:37 +09:00
parent 27e2c17606
commit b583627bd3
6 changed files with 259 additions and 3 deletions
+49
View File
@@ -309,6 +309,55 @@ class DispatchStore:
for r in rows for r in rows
] ]
# ════════════════════════════════════════════════════════════
# 말레이시아 재고 차감 1회성 가드
# ════════════════════════════════════════════════════════════
def deduction_exists(self, *, dispatch_date: str) -> bool:
with self._pool.connection() as conn:
row = conn.execute(
"SELECT 1 FROM dispatch_stock_deductions WHERE dispatch_date = %s",
(dispatch_date,),
).fetchone()
return row is not None
def list_deducted_dates(self) -> list[str]:
with self._pool.connection() as conn:
rows = conn.execute(
"SELECT dispatch_date FROM dispatch_stock_deductions "
"ORDER BY dispatch_date DESC"
).fetchall()
out: list[str] = []
for r in rows:
d = r["dispatch_date"]
out.append(d.isoformat() if isinstance(d, date) else str(d))
return out
def record_deduction(
self,
*,
dispatch_date: str,
warehouse_code: str,
single_lines: int,
combo_lines: int,
worker_name: str = "",
) -> None:
"""차감 완료 기록. 이미 있으면 충돌(재차감 차단)."""
with self._pool.connection() as conn:
conn.execute(
"""
INSERT INTO dispatch_stock_deductions
(dispatch_date, warehouse_code, single_lines, combo_lines, worker_name)
VALUES (%s,%s,%s,%s,%s)
""",
(
dispatch_date,
(warehouse_code or "").strip(),
int(single_lines),
int(combo_lines),
(worker_name or "").lower().strip(),
),
)
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(
+26
View File
@@ -139,6 +139,32 @@ def build_workbook_bytes(
# ── 사방넷 재고 업로드 엑셀(낱개 분해) ── # ── 사방넷 재고 업로드 엑셀(낱개 분해) ──
_STOCK_HEADERS: tuple[str, ...] = ("상품코드[필수]", "가용수량", "불용수량", "바코드") _STOCK_HEADERS: tuple[str, ...] = ("상품코드[필수]", "가용수량", "불용수량", "바코드")
_LID_PREFIX = "MD-" # 뚜껑 — 재고 집계 제외 _LID_PREFIX = "MD-" # 뚜껑 — 재고 집계 제외
_SET_PREFIX = "MY-" # 콤보(세트)
def split_singles_combos(
rows: list[dict[str, Any]],
) -> tuple[dict[str, int], dict[str, int]]:
"""SKU별 합산 행 → (낱개 {code: qty}, 콤보 {code: qty}).
_N 배수 반영, MD-(뚜껑) 제외. 콤보는 MY- 접두사. 분해는 하지 않는다
(말레이시아 재고관리가 세트 OUT 시 BOM 으로 분해하므로 콤보 그대로 넘긴다).
"""
singles: dict[str, int] = {}
combos: 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
if base.startswith(_SET_PREFIX):
combos[base] = combos.get(base, 0) + pieces
else:
singles[base] = singles.get(base, 0) + pieces
return singles, combos
def explode_to_singles( def explode_to_singles(
+86
View File
@@ -206,6 +206,7 @@ async def batches_index(request: Request) -> HTMLResponse:
"page_subtitle": "TikTok 일일 출고 배치 목록", "page_subtitle": "TikTok 일일 출고 배치 목록",
"batches": batches, "batches": batches,
"batch_dates": sorted({b["dispatch_date"] for b in batches if b.get("dispatch_date")}), "batch_dates": sorted({b["dispatch_date"] for b in batches if b.get("dispatch_date")}),
"deducted_dates": st.list_deducted_dates(),
"active_tab": "batches", "active_tab": "batches",
} }
) )
@@ -438,6 +439,91 @@ async def stock_export(
) )
@router.post("/stock-deduct")
async def stock_deduct(
request: Request,
date: str = Form(...),
user: dict[str, Any] = Depends(_require_user),
) -> JSONResponse:
"""선택 날짜의 출고를 말레이시아 재고관리에 OUT 으로 반영(1회성).
- 낱개(MT/MX/MZ)는 낱개 OUT, 콤보(MY-)는 세트 OUT(BOM 으로 낱개 분해).
- _N 배수 반영, MD-(뚜껑) 제외. movement_date = 해당 날짜.
- dispatch_stock_deductions 로 날짜당 1회만 — 재실행 시 재고 이중 차감 방지.
"""
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="날짜가 필요합니다.")
if st.deduction_exists(dispatch_date=d):
raise HTTPException(status_code=409, detail=f"{d} 는 이미 재고 차감되었습니다.")
ms = getattr(request.app.state, "malaysia_store", None)
if ms is None:
raise HTTPException(status_code=503, detail="말레이시아 재고관리(MALAYSIA_STOCK_DB_URL) 미설정")
# 낱개/콤보 분리(_N 배수, MD- 제외)
rows = st.stock_aggregate_for_date(dispatch_date=d)
singles, combos = export.split_singles_combos(rows)
if not singles and not combos:
raise HTTPException(status_code=404, detail="해당 날짜의 출고 데이터가 없습니다.")
# 창고 결정(첫 활성 창고)
warehouses = ms.list_warehouses()
if not warehouses:
raise HTTPException(status_code=400, detail="등록된 창고가 없습니다.")
wh = warehouses[0]["warehouse_code"]
# 콤보 BOM 사전검증(누락 시 아무 것도 쓰지 않고 중단)
bom_map, _sab = _itemcode_bom_and_sabangnet(request)
missing = [c for c in combos if c not in bom_map]
if missing:
raise HTTPException(
status_code=400,
detail="콤보 BOM 이 없어 차감할 수 없습니다(세트구성 먼저 등록): " + ", ".join(sorted(missing)),
)
# 마커 선점(레이스/재실행 차단). 충돌 시 이미 차감됨.
try:
st.record_deduction(
dispatch_date=d, warehouse_code=wh,
single_lines=len(singles), combo_lines=len(combos),
worker_name=user.get("email", ""),
)
except Exception: # noqa: BLE001 — unique 위반 등 = 이미 차감
raise HTTPException(status_code=409, detail=f"{d} 는 이미 재고 차감되었습니다.")
# 실제 OUT 반영. 실패해도 마커는 남겨 재고 이중 차감을 막는다(원인은 로그).
memo = f"배송 출고 {d}"
try:
for code, qty in combos.items():
ms.create_set_out(
movement_date=d, warehouse_code=wh, set_code=code, set_qty=qty,
bom_map=bom_map, ref_type="dispatch", ref_no=d, memo=memo,
created_by=user.get("email", ""),
)
for code, qty in singles.items():
ms.create_movement(
movement_date=d, warehouse_code=wh, item_code=code,
movement_type="OUT", qty=qty, ref_type="dispatch", ref_no=d,
memo=memo, created_by=user.get("email", ""),
)
except Exception as exc: # noqa: BLE001
logger.exception("재고 차감 중 오류 date=%s", d)
raise HTTPException(
status_code=500,
detail=f"일부 반영 중 오류가 발생했습니다(재차감 방지를 위해 차감완료로 표시됨). 재고관리 이동이력을 확인하세요: {type(exc).__name__}",
)
return JSONResponse({
"ok": True, "date": d, "warehouse": wh,
"singles": len(singles), "combos": len(combos),
})
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
# 출고 작업 리스트 (1박스 = 1카드) # 출고 작업 리스트 (1박스 = 1카드)
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
@@ -109,6 +109,10 @@
<a id="dsp-cal-dl" class="erp-btn erp-btn-primary" href="#" <a id="dsp-cal-dl" class="erp-btn erp-btn-primary" href="#"
data-ko="📥 사방넷 출고 엑셀 다운로드" data-en="📥 Download Sabangnet Outbound Excel" data-ko="📥 사방넷 출고 엑셀 다운로드" data-en="📥 Download Sabangnet Outbound Excel"
style="pointer-events:none;opacity:.5;">📥 사방넷 출고 엑셀 다운로드</a> style="pointer-events:none;opacity:.5;">📥 사방넷 출고 엑셀 다운로드</a>
<button id="dsp-cal-deduct" type="button" class="erp-btn erp-btn-outline" onclick="dspDeduct()"
data-ko="📦 말레이시아 재고 차감" data-en="📦 Deduct Malaysia Stock"
style="pointer-events:none;opacity:.5;">📦 말레이시아 재고 차감</button>
<div id="dsp-cal-deduct-note" class="erp-muted" style="font-size:12px;"></div>
</div> </div>
</div> </div>
</div> </div>
@@ -120,6 +124,8 @@
(function () { (function () {
// 배치가 있는 날짜(YYYY-MM-DD) // 배치가 있는 날짜(YYYY-MM-DD)
var BATCH_DATES = {{ batch_dates|tojson }}; var BATCH_DATES = {{ batch_dates|tojson }};
var DEDUCTED = {};
({{ deducted_dates|tojson }}).forEach(function (d) { DEDUCTED[d] = true; });
var dateSet = {}; var dateSet = {};
BATCH_DATES.forEach(function (d) { dateSet[d] = true; }); BATCH_DATES.forEach(function (d) { dateSet[d] = true; });
@@ -127,6 +133,8 @@
var title = document.getElementById('dsp-cal-title'); var title = document.getElementById('dsp-cal-title');
var selLabel = document.getElementById('dsp-cal-sel'); var selLabel = document.getElementById('dsp-cal-sel');
var dlBtn = document.getElementById('dsp-cal-dl'); var dlBtn = document.getElementById('dsp-cal-dl');
var dedBtn = document.getElementById('dsp-cal-deduct');
var dedNote = document.getElementById('dsp-cal-deduct-note');
var selected = null; var selected = null;
// 초기 표시 월: 가장 최근 배치 날짜 기준(없으면 오늘) // 초기 표시 월: 가장 최근 배치 날짜 기준(없으면 오늘)
@@ -169,16 +177,62 @@
dlBtn.href = '/dispatch/stock-export?date=' + encodeURIComponent(iso); dlBtn.href = '/dispatch/stock-export?date=' + encodeURIComponent(iso);
dlBtn.style.pointerEvents = ''; dlBtn.style.pointerEvents = '';
dlBtn.style.opacity = ''; dlBtn.style.opacity = '';
updateDeductBtn();
render(); render();
} }
function updateDeductBtn() {
var en = (window.dspLang && window.dspLang() === 'en');
if (!selected) {
dedBtn.style.pointerEvents = 'none'; dedBtn.style.opacity = '.5';
dedNote.textContent = '';
return;
}
if (DEDUCTED[selected]) {
dedBtn.style.pointerEvents = 'none'; dedBtn.style.opacity = '.5';
dedNote.textContent = en ? 'Already deducted (cannot run again).' : '이미 차감됨 (재실행 불가).';
} else {
dedBtn.style.pointerEvents = ''; dedBtn.style.opacity = '';
dedNote.textContent = '';
}
}
window.dspDeduct = function () {
if (!selected || DEDUCTED[selected]) return;
var en = (window.dspLang && window.dspLang() === 'en');
var msg = en
? ('Deduct Malaysia stock for ' + selected + '? This runs ONCE only and cannot be undone.')
: (selected + ' 출고를 말레이시아 재고에서 차감할까요?\n한 번만 실행되며 되돌릴 수 없습니다.');
if (!confirm(msg)) return;
dedBtn.style.pointerEvents = 'none'; dedBtn.style.opacity = '.5';
dedNote.textContent = en ? 'Processing…' : '처리 중…';
var body = new URLSearchParams(); body.set('date', selected);
fetch('/dispatch/stock-deduct', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
})
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
.then(function (res) {
if (!res.ok) throw new Error((res.j && res.j.detail) || '실패');
DEDUCTED[selected] = true;
updateDeductBtn();
alert((en ? 'Done. singles ' : '완료. 낱개 ') + res.j.singles + (en ? ', combos ' : '종, 콤보 ') + res.j.combos + (en ? '' : '종 반영'));
})
.catch(function (e) {
dedNote.textContent = (e && e.message) || '실패';
updateDeductBtn();
alert((en ? 'Failed: ' : '실패: ') + ((e && e.message) || ''));
});
};
document.getElementById('dsp-cal-prev').onclick = function () { document.getElementById('dsp-cal-prev').onclick = function () {
viewM--; if (viewM < 0) { viewM = 11; viewY--; } render(); viewM--; if (viewM < 0) { viewM = 11; viewY--; } render();
}; };
document.getElementById('dsp-cal-next').onclick = function () { document.getElementById('dsp-cal-next').onclick = function () {
viewM++; if (viewM > 11) { viewM = 0; viewY++; } render(); viewM++; if (viewM > 11) { viewM = 0; viewY++; } render();
}; };
document.addEventListener('dsp:lang', render); document.addEventListener('dsp:lang', function () { render(); updateDeductBtn(); });
render(); render();
})(); })();
</script> </script>
+27
View File
@@ -0,0 +1,27 @@
-- =====================================================================
-- dispatch_db 마이그레이션 — 말레이시아 재고 차감 1회성 기록
-- =====================================================================
-- 멱등(idempotent): 여러 번 실행해도 안전.
--
-- 배경: 배송 배치의 그날 출고를 말레이시아 재고관리(OUT)로 1회만 반영하기 위해,
-- 날짜 단위로 차감 완료를 기록한다. 같은 날짜를 다시 차감하면 재고가
-- 이중으로 빠지므로 이 표의 존재 여부로 재실행을 차단한다.
--
-- 실행:
-- docker exec -i postgres-db psql -U postgres -d dispatch_db \
-- < scripts/sql/dispatch_add_deductions.sql
-- =====================================================================
\set ON_ERROR_STOP on
\connect dispatch_db
CREATE TABLE IF NOT EXISTS dispatch_stock_deductions (
dispatch_date DATE PRIMARY KEY,
warehouse_code TEXT NOT NULL DEFAULT '',
single_lines INTEGER NOT NULL DEFAULT 0,
combo_lines INTEGER NOT NULL DEFAULT 0,
worker_name TEXT NOT NULL DEFAULT '',
deducted_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
SELECT 'dispatch_stock_deductions ready' AS status;
+16 -2
View File
@@ -142,11 +142,25 @@ CREATE TABLE IF NOT EXISTS dispatch_logs (
CREATE INDEX IF NOT EXISTS idx_dispatch_logs_parcel ON dispatch_logs (parcel_id); CREATE INDEX IF NOT EXISTS idx_dispatch_logs_parcel ON dispatch_logs (parcel_id);
-- ════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════
-- 5) 권한 (dispatch_app: CRUD only) -- 5) 말레이시아 재고 차감 1회성 기록 (날짜 단위 멱등 가드)
-- 그날 출고를 말레이시아 재고관리(OUT)로 1회만 반영. 존재 시 재차감 차단.
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS dispatch_stock_deductions (
dispatch_date DATE PRIMARY KEY,
warehouse_code TEXT NOT NULL DEFAULT '',
single_lines INTEGER NOT NULL DEFAULT 0,
combo_lines INTEGER NOT NULL DEFAULT 0,
worker_name TEXT NOT NULL DEFAULT '',
deducted_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ════════════════════════════════════════════════════════════
-- 6) 권한 (dispatch_app: CRUD only)
-- ════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════
GRANT USAGE ON SCHEMA public TO dispatch_app; GRANT USAGE ON SCHEMA public TO dispatch_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON GRANT SELECT, INSERT, UPDATE, DELETE ON
dispatch_batches, dispatch_parcels, dispatch_items, dispatch_logs dispatch_batches, dispatch_parcels, dispatch_items, dispatch_logs,
dispatch_stock_deductions
TO dispatch_app; TO dispatch_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO dispatch_app; GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO dispatch_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public ALTER DEFAULT PRIVILEGES IN SCHEMA public