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:
@@ -309,6 +309,55 @@ class DispatchStore:
|
||||
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:
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
|
||||
@@ -139,6 +139,32 @@ def build_workbook_bytes(
|
||||
# ── 사방넷 재고 업로드 엑셀(낱개 분해) ──
|
||||
_STOCK_HEADERS: tuple[str, ...] = ("상품코드[필수]", "가용수량", "불용수량", "바코드")
|
||||
_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(
|
||||
|
||||
@@ -206,6 +206,7 @@ async def batches_index(request: Request) -> HTMLResponse:
|
||||
"page_subtitle": "TikTok 일일 출고 배치 목록",
|
||||
"batches": batches,
|
||||
"batch_dates": sorted({b["dispatch_date"] for b in batches if b.get("dispatch_date")}),
|
||||
"deducted_dates": st.list_deducted_dates(),
|
||||
"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카드)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -109,6 +109,10 @@
|
||||
<a id="dsp-cal-dl" class="erp-btn erp-btn-primary" href="#"
|
||||
data-ko="📥 사방넷 출고 엑셀 다운로드" data-en="📥 Download Sabangnet Outbound Excel"
|
||||
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>
|
||||
@@ -120,6 +124,8 @@
|
||||
(function () {
|
||||
// 배치가 있는 날짜(YYYY-MM-DD)
|
||||
var BATCH_DATES = {{ batch_dates|tojson }};
|
||||
var DEDUCTED = {};
|
||||
({{ deducted_dates|tojson }}).forEach(function (d) { DEDUCTED[d] = true; });
|
||||
var dateSet = {};
|
||||
BATCH_DATES.forEach(function (d) { dateSet[d] = true; });
|
||||
|
||||
@@ -127,6 +133,8 @@
|
||||
var title = document.getElementById('dsp-cal-title');
|
||||
var selLabel = document.getElementById('dsp-cal-sel');
|
||||
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;
|
||||
|
||||
// 초기 표시 월: 가장 최근 배치 날짜 기준(없으면 오늘)
|
||||
@@ -169,16 +177,62 @@
|
||||
dlBtn.href = '/dispatch/stock-export?date=' + encodeURIComponent(iso);
|
||||
dlBtn.style.pointerEvents = '';
|
||||
dlBtn.style.opacity = '';
|
||||
updateDeductBtn();
|
||||
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 () {
|
||||
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);
|
||||
document.addEventListener('dsp:lang', function () { render(); updateDeductBtn(); });
|
||||
render();
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
-- 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 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;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO dispatch_app;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||
|
||||
Reference in New Issue
Block a user