feat(inventory+warehouse): 재고 이동 + 모바일 카드 + 창고코드 자동생성
Deploy momo-erp / deploy (push) Failing after 35s

[재고 관리]
- 새 기능 '재고 이동' (A창고 → B창고): 출발창고 잠금 + 충분재고 검증 + 도착
  창고 upsert + 이동 로그 2건(OUT/IN, ref_type=TRANSFER) 트랜잭션 처리
- /api/m/inventory/transfer 신규
- 모바일에서 테이블 가로 스크롤이 안 되던 문제 → sm:hidden 카드 + sm:block
  desktop 테이블로 분리. 페이지 자체 스크롤로 자연스러운 UX
- 검색 영역 모바일 1열 / sm 3열 그리드 정리
- 재고 이동 모달은 출발창고 선택 시 그 창고에 재고 있는 품목만 셀렉트
- list API 응답에 WH_OBJID 추가 (이동 모달에서 출발창고 필터 용도)

[창고 관리]
- 창고 코드는 자동생성(WH001, WH002 ...) — 등록/수정 폼에서 readonly + 회색.
  save API: regist 시 nextWhCode() 로 MAX+1 패딩. update 시 wh_code 미변경
- 클라이언트가 whCode 보내도 무시되도록 서버에서 분기

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chpark
2026-05-08 16:34:19 +09:00
parent 2d40678358
commit d1683a3c6e
5 changed files with 291 additions and 67 deletions
+189 -57
View File
@@ -1,11 +1,11 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Plus, Search, Trash2, History } from "lucide-react"; import { Plus, Search, Trash2, History, ArrowRightLeft, Package } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Swal from "sweetalert2"; import Swal from "sweetalert2";
interface Stock { OBJID: string; WH_CODE: string; WH_NAME: string; ITEM_OBJID: string; ITEM_CODE: string; ITEM_NAME: string; UNIT: string; IS_TAX_FREE: string; QTY: number; UPDATE_DATE: string } interface Stock { OBJID: string; WH_OBJID: string; WH_CODE: string; WH_NAME: string; ITEM_OBJID: string; ITEM_CODE: string; ITEM_NAME: string; UNIT: string; IS_TAX_FREE: string; QTY: number; UPDATE_DATE: string }
interface Wh { OBJID: string; WH_CODE: string; WH_NAME: string } interface Wh { OBJID: string; WH_CODE: string; WH_NAME: string }
interface Item { OBJID: string; ITEM_CODE: string; ITEM_NAME: string; UNIT_PRICE: number } interface Item { OBJID: string; ITEM_CODE: string; ITEM_NAME: string; UNIT_PRICE: number }
interface InboundLine { itemObjid: string; itemName: string; qty: number; costPrice?: number } interface InboundLine { itemObjid: string; itemName: string; qty: number; costPrice?: number }
@@ -20,6 +20,7 @@ export default function InventoryPage() {
const [whFilter, setWhFilter] = useState(""); const [whFilter, setWhFilter] = useState("");
const [keyword, setKeyword] = useState(""); const [keyword, setKeyword] = useState("");
const [inboundOpen, setInboundOpen] = useState(false); const [inboundOpen, setInboundOpen] = useState(false);
const [transferOpen, setTransferOpen] = useState(false);
// 입고 폼 // 입고 폼
const [inboundWh, setInboundWh] = useState(""); const [inboundWh, setInboundWh] = useState("");
@@ -27,6 +28,12 @@ export default function InventoryPage() {
const [pickItem, setPickItem] = useState(""); const [pickItem, setPickItem] = useState("");
const [pickQty, setPickQty] = useState(1); const [pickQty, setPickQty] = useState(1);
// 이동 폼
const [trFrom, setTrFrom] = useState("");
const [trTo, setTrTo] = useState("");
const [trItem, setTrItem] = useState("");
const [trQty, setTrQty] = useState(1);
const load = async () => { const load = async () => {
const res = await fetch("/api/m/inventory/list", { const res = await fetch("/api/m/inventory/list", {
method: "POST", headers: { "Content-Type": "application/json" }, method: "POST", headers: { "Content-Type": "application/json" },
@@ -73,84 +80,164 @@ export default function InventoryPage() {
} }
}; };
// 출발 창고에 재고가 있는 품목만 추리기 (현재 화면 list 기준)
const trItemsInFrom = useMemo(() => {
if (!trFrom) return [] as Stock[];
return list.filter((s) => s.WH_OBJID === trFrom && Number(s.QTY) > 0);
}, [list, trFrom]);
const trCurrent = useMemo(() => {
if (!trFrom || !trItem) return 0;
return Number(list.find((s) => s.WH_OBJID === trFrom && s.ITEM_OBJID === trItem)?.QTY ?? 0);
}, [list, trFrom, trItem]);
const submitTransfer = async () => {
if (!trFrom || !trTo || !trItem || trQty <= 0) {
Swal.fire({ icon: "warning", title: "출발창고/도착창고/품목/수량을 입력하세요." });
return;
}
if (trFrom === trTo) {
Swal.fire({ icon: "warning", title: "출발창고와 도착창고가 같습니다." });
return;
}
if (trQty > trCurrent) {
Swal.fire({ icon: "warning", title: "재고 부족", text: `현재고 ${trCurrent}` });
return;
}
const res = await fetch("/api/m/inventory/transfer", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fromWhObjid: trFrom, toWhObjid: trTo, itemObjid: trItem, qty: trQty }),
});
const j = await res.json().catch(() => ({ success: false, message: "응답 파싱 실패" }));
if (j.success) {
Swal.fire({ icon: "success", title: "재고 이동 완료", text: j.message, timer: 1500, showConfirmButton: false });
setTransferOpen(false);
setTrFrom(""); setTrTo(""); setTrItem(""); setTrQty(1);
load();
} else {
Swal.fire({ icon: "error", title: "이동 실패", text: j.message });
}
};
return ( return (
<div className="space-y-4"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between flex-wrap gap-2">
<h1 className="text-2xl font-bold"> </h1> <h1 className="text-xl sm:text-2xl font-bold"> </h1>
<div className="flex gap-2"> <div className="flex gap-2 flex-wrap">
<button <button
onClick={() => router.push("/m/admin/inventory/history")} onClick={() => router.push("/m/admin/inventory/history")}
className="px-4 h-10 inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white text-slate-700 text-sm font-semibold hover:bg-slate-50" className="h-9 sm:h-10 px-3 inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white text-slate-700 text-xs sm:text-sm font-semibold hover:bg-slate-50"
> >
<History size={15} /> <History size={14} />
</button> </button>
<button onClick={() => setInboundOpen(true)} className="px-4 h-10 inline-flex items-center gap-2 rounded-lg bg-emerald-700 text-white text-sm font-bold"> <button
<Plus size={16} /> onClick={() => setTransferOpen(true)}
className="h-9 sm:h-10 px-3 inline-flex items-center gap-1.5 rounded-lg bg-sky-600 text-white text-xs sm:text-sm font-bold hover:bg-sky-700"
>
<ArrowRightLeft size={14} />
</button>
<button onClick={() => setInboundOpen(true)} className="h-9 sm:h-10 px-3 inline-flex items-center gap-1.5 rounded-lg bg-emerald-700 text-white text-xs sm:text-sm font-bold hover:bg-emerald-800">
<Plus size={14} />
</button> </button>
</div> </div>
</div> </div>
<div className="flex gap-2 flex-wrap"> {/* 검색 영역 — 모바일 1열 / sm 2열 / lg 옆으로 */}
<select value={whFilter} onChange={(e) => setWhFilter(e.target.value)} className="h-10 px-3 rounded-lg border border-slate-200 text-sm"> <div className="bg-white border border-slate-200 rounded-xl p-2.5">
<option value=""> </option> <div className="grid grid-cols-1 sm:grid-cols-[1fr_2fr_auto] gap-2">
{whs.map((w) => <option key={w.OBJID} value={w.OBJID}>{w.WH_NAME}</option>)} <select value={whFilter} onChange={(e) => setWhFilter(e.target.value)} className="h-9 px-3 rounded-lg border border-slate-200 text-sm bg-white">
</select> <option value=""> </option>
<div className="relative flex-1 max-w-sm"> {whs.map((w) => <option key={w.OBJID} value={w.OBJID}>{w.WH_NAME}</option>)}
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" /> </select>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} onKeyDown={(e) => e.key === "Enter" && load()} placeholder="품목명/코드" className="w-full h-10 pl-9 pr-3 rounded-lg border border-slate-200 text-sm" /> <div className="relative">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} onKeyDown={(e) => e.key === "Enter" && load()} placeholder="품목명 / 코드" className="w-full h-9 pl-8 pr-3 rounded-lg border border-slate-200 text-sm" />
</div>
<button onClick={load} className="h-9 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold"></button>
</div> </div>
<button onClick={load} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold"></button>
</div> </div>
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden"> {/* 데스크톱 테이블 */}
<table className="w-full text-sm"> <div className="hidden sm:block bg-white border border-slate-200 rounded-xl overflow-hidden">
<thead className="bg-slate-50 text-slate-600"> <div className="overflow-x-auto">
<tr> <table className="w-full text-sm min-w-[640px]">
<th className="text-left px-4 py-3"></th> <thead className="bg-slate-50 text-slate-600">
<th className="text-left px-4 py-3"></th> <tr>
<th className="text-left px-4 py-3"></th> <th className="text-left px-4 py-3"></th>
<th className="text-center px-4 py-3"></th> <th className="text-left px-4 py-3"></th>
<th className="text-right px-4 py-3"></th> <th className="text-left px-4 py-3"></th>
<th className="text-left px-4 py-3"> </th> <th className="text-center px-4 py-3"></th>
</tr> <th className="text-right px-4 py-3"></th>
</thead> <th className="text-left px-4 py-3"> </th>
<tbody>
{list.length === 0 ? (
<tr><td colSpan={6} className="text-center py-12 text-slate-400"> . .</td></tr>
) : list.map((s) => (
<tr key={s.OBJID} className="border-t border-slate-100">
<td className="px-4 py-3">{s.WH_NAME}</td>
<td className="px-4 py-3 font-mono text-xs">{s.ITEM_CODE}</td>
<td className="px-4 py-3 font-semibold">{s.ITEM_NAME}</td>
<td className="px-4 py-3 text-center">
{s.IS_TAX_FREE === "Y"
? <span className="px-1.5 py-0.5 rounded bg-violet-100 text-violet-700 text-[10px] font-bold"></span>
: <span className="px-1.5 py-0.5 rounded bg-rose-100 text-rose-700 text-[10px] font-bold"></span>}
</td>
<td className="px-4 py-3 text-right tabular-nums font-bold">{fmt(s.QTY)} {s.UNIT}</td>
<td className="px-4 py-3 text-slate-500 text-xs">{s.UPDATE_DATE}</td>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {list.length === 0 ? (
<tr><td colSpan={6} className="text-center py-12 text-slate-400"> . .</td></tr>
) : list.map((s) => (
<tr key={s.OBJID} className="border-t border-slate-100">
<td className="px-4 py-3">{s.WH_NAME}</td>
<td className="px-4 py-3 font-mono text-xs">{s.ITEM_CODE}</td>
<td className="px-4 py-3 font-semibold">{s.ITEM_NAME}</td>
<td className="px-4 py-3 text-center">
{s.IS_TAX_FREE === "Y"
? <span className="px-1.5 py-0.5 rounded bg-violet-100 text-violet-700 text-[10px] font-bold"></span>
: <span className="px-1.5 py-0.5 rounded bg-rose-100 text-rose-700 text-[10px] font-bold"></span>}
</td>
<td className="px-4 py-3 text-right tabular-nums font-bold">{fmt(s.QTY)} {s.UNIT}</td>
<td className="px-4 py-3 text-slate-500 text-xs">{s.UPDATE_DATE}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* 모바일 카드 — 페이지 자체 스크롤 사용 (테이블 가로 스크롤 X) */}
<div className="sm:hidden space-y-2">
{list.length === 0 ? (
<div className="bg-white border border-slate-200 rounded-xl py-12 text-center text-slate-400 text-sm">
<Package size={32} className="mx-auto mb-2 opacity-50" />
.
</div>
) : list.map((s) => (
<div key={s.OBJID} className="bg-white border border-slate-200 rounded-xl p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="text-[10px] text-slate-400 font-mono">{s.ITEM_CODE}</div>
<div className="font-bold text-sm text-slate-900 truncate">{s.ITEM_NAME}</div>
<div className="text-[11px] text-slate-500 mt-0.5">{s.WH_NAME}</div>
</div>
<div className="text-right shrink-0">
<div className="text-lg font-bold tabular-nums text-emerald-700">{fmt(s.QTY)}<span className="text-xs ml-0.5 text-slate-500">{s.UNIT}</span></div>
{s.IS_TAX_FREE === "Y"
? <span className="px-1.5 py-0.5 rounded bg-violet-100 text-violet-700 text-[9px] font-bold"></span>
: <span className="px-1.5 py-0.5 rounded bg-rose-100 text-rose-700 text-[9px] font-bold"></span>}
</div>
</div>
<div className="mt-2 pt-2 border-t border-slate-100 text-[10px] text-slate-400">
· {s.UPDATE_DATE}
</div>
</div>
))}
</div> </div>
{/* 입고 모달 */} {/* 입고 모달 */}
{inboundOpen && ( {inboundOpen && (
<div className="fixed inset-0 bg-slate-900/60 z-50 flex items-center justify-center p-4" onClick={() => setInboundOpen(false)}> <div className="fixed inset-0 bg-slate-900/60 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4" onClick={() => setInboundOpen(false)}>
<div onClick={(e) => e.stopPropagation()} className="bg-white rounded-xl max-w-2xl w-full p-6 max-h-[90vh] overflow-y-auto"> <div onClick={(e) => e.stopPropagation()} className="bg-white rounded-t-xl sm:rounded-xl max-w-2xl w-full p-4 sm:p-6 max-h-[90vh] overflow-y-auto">
<h3 className="font-bold mb-4"> </h3> <h3 className="font-bold mb-4"> </h3>
<div className="space-y-3"> <div className="space-y-3">
<select value={inboundWh} onChange={(e) => setInboundWh(e.target.value)} className="w-full h-10 px-3 rounded-lg border border-slate-200"> <select value={inboundWh} onChange={(e) => setInboundWh(e.target.value)} className="w-full h-10 px-3 rounded-lg border border-slate-200 bg-white">
<option value=""> </option> <option value=""> </option>
{whs.map((w) => <option key={w.OBJID} value={w.OBJID}>{w.WH_NAME}</option>)} {whs.map((w) => <option key={w.OBJID} value={w.OBJID}>{w.WH_NAME}</option>)}
</select> </select>
<div className="flex gap-2"> <div className="grid grid-cols-[1fr_80px_auto] gap-2">
<select value={pickItem} onChange={(e) => setPickItem(e.target.value)} className="flex-1 h-10 px-3 rounded-lg border border-slate-200"> <select value={pickItem} onChange={(e) => setPickItem(e.target.value)} className="h-10 px-3 rounded-lg border border-slate-200 bg-white min-w-0">
<option value=""> </option> <option value=""> </option>
{items.map((i) => <option key={i.OBJID} value={i.OBJID}>{i.ITEM_NAME}</option>)} {items.map((i) => <option key={i.OBJID} value={i.OBJID}>{i.ITEM_NAME}</option>)}
</select> </select>
<input type="number" min={1} value={pickQty} onChange={(e) => setPickQty(Number(e.target.value))} className="w-24 h-10 px-3 rounded-lg border border-slate-200" /> <input type="number" min={1} value={pickQty} onChange={(e) => setPickQty(Number(e.target.value))} className="h-10 px-3 rounded-lg border border-slate-200" />
<button onClick={addLine} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold"></button> <button onClick={addLine} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold"></button>
</div> </div>
<div className="border border-slate-200 rounded-lg max-h-60 overflow-y-auto"> <div className="border border-slate-200 rounded-lg max-h-60 overflow-y-auto">
@@ -158,7 +245,7 @@ export default function InventoryPage() {
<div className="text-center text-slate-400 text-sm py-8"> </div> <div className="text-center text-slate-400 text-sm py-8"> </div>
) : ( ) : (
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-600 text-xs"> <thead className="bg-slate-50 text-slate-600 text-xs sticky top-0">
<tr><th className="px-3 py-2 text-left"></th><th className="px-3 py-2 text-right"></th><th></th></tr> <tr><th className="px-3 py-2 text-left"></th><th className="px-3 py-2 text-right"></th><th></th></tr>
</thead> </thead>
<tbody> <tbody>
@@ -183,6 +270,51 @@ export default function InventoryPage() {
</div> </div>
</div> </div>
)} )}
{/* 재고 이동 모달 — A창고 → B창고 */}
{transferOpen && (
<div className="fixed inset-0 bg-slate-900/60 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4" onClick={() => setTransferOpen(false)}>
<div onClick={(e) => e.stopPropagation()} className="bg-white rounded-t-xl sm:rounded-xl max-w-md w-full p-4 sm:p-6 max-h-[90vh] overflow-y-auto">
<h3 className="font-bold mb-1 inline-flex items-center gap-1.5"><ArrowRightLeft size={16} className="text-sky-600" /> </h3>
<p className="text-xs text-slate-500 mb-4">A창고에서 B창고로 .</p>
<div className="space-y-3">
<div>
<label className="block text-[11px] font-semibold text-slate-500 mb-1"> (A)</label>
<select value={trFrom} onChange={(e) => { setTrFrom(e.target.value); setTrItem(""); }} className="w-full h-10 px-3 rounded-lg border border-slate-200 bg-white">
<option value=""></option>
{whs.map((w) => <option key={w.OBJID} value={w.OBJID}>{w.WH_NAME}</option>)}
</select>
</div>
<div>
<label className="block text-[11px] font-semibold text-slate-500 mb-1"> (B)</label>
<select value={trTo} onChange={(e) => setTrTo(e.target.value)} className="w-full h-10 px-3 rounded-lg border border-slate-200 bg-white">
<option value=""></option>
{whs.filter((w) => w.OBJID !== trFrom).map((w) => <option key={w.OBJID} value={w.OBJID}>{w.WH_NAME}</option>)}
</select>
</div>
<div>
<label className="block text-[11px] font-semibold text-slate-500 mb-1"> {trFrom && <span className="text-slate-400">( )</span>}</label>
<select value={trItem} onChange={(e) => setTrItem(e.target.value)} disabled={!trFrom} className="w-full h-10 px-3 rounded-lg border border-slate-200 bg-white disabled:bg-slate-50">
<option value="">{trFrom ? "선택" : "출발창고 먼저 선택"}</option>
{trItemsInFrom.map((s) => (
<option key={s.ITEM_OBJID} value={s.ITEM_OBJID}>{s.ITEM_NAME} ( {fmt(s.QTY)} {s.UNIT})</option>
))}
</select>
</div>
<div>
<label className="block text-[11px] font-semibold text-slate-500 mb-1">
{trItem && <span className="text-emerald-700 font-bold"> {fmt(trCurrent)}</span>}
</label>
<input type="number" min={1} max={trCurrent || undefined} value={trQty} onChange={(e) => setTrQty(Number(e.target.value))} className="w-full h-10 px-3 rounded-lg border border-slate-200 text-right tabular-nums" />
</div>
</div>
<div className="flex gap-2 justify-end mt-5">
<button onClick={() => setTransferOpen(false)} className="px-4 h-10 rounded-lg border border-slate-200 text-sm font-semibold"></button>
<button onClick={submitTransfer} disabled={!trFrom || !trTo || !trItem || trQty <= 0} className="px-5 h-10 rounded-lg bg-sky-600 text-white text-sm font-bold disabled:opacity-50"></button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }
+9 -2
View File
@@ -34,7 +34,7 @@ export default function WarehousesPage() {
method: "POST", headers: { "Content-Type": "application/json" }, method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
objid: editing.OBJID, actionType: editing.OBJID ? "update" : "regist", objid: editing.OBJID, actionType: editing.OBJID ? "update" : "regist",
whCode: editing.WH_CODE, whName: editing.WH_NAME, whName: editing.WH_NAME,
location: editing.LOCATION, whType: editing.WH_TYPE || "STOCK", location: editing.LOCATION, whType: editing.WH_TYPE || "STOCK",
}), }),
}); });
@@ -119,7 +119,14 @@ export default function WarehousesPage() {
className="bg-white rounded-xl max-w-md w-full p-5 sm:p-6 max-h-[90vh] overflow-y-auto"> className="bg-white rounded-xl max-w-md w-full p-5 sm:p-6 max-h-[90vh] overflow-y-auto">
<h3 className="font-bold text-base sm:text-lg mb-4">{editing.OBJID ? "창고 수정" : "창고 추가"}</h3> <h3 className="font-bold text-base sm:text-lg mb-4">{editing.OBJID ? "창고 수정" : "창고 추가"}</h3>
<div className="space-y-3"> <div className="space-y-3">
<input required placeholder="코드 (예: WH005)" value={editing.WH_CODE ?? ""} onChange={(e) => setEditing({ ...editing, WH_CODE: e.target.value })} className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm" /> <div>
<label className="block text-[11px] font-semibold text-slate-500 mb-1"> <span className="text-slate-400 font-normal">( , )</span></label>
<input
readOnly
value={editing.OBJID ? (editing.WH_CODE ?? "") : "등록 시 자동 생성"}
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm bg-slate-50 text-slate-500 font-mono"
/>
</div>
<input required placeholder="이름" value={editing.WH_NAME ?? ""} onChange={(e) => setEditing({ ...editing, WH_NAME: e.target.value })} className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm" /> <input required placeholder="이름" value={editing.WH_NAME ?? ""} onChange={(e) => setEditing({ ...editing, WH_NAME: e.target.value })} className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm" />
<select value={editing.WH_TYPE ?? "STOCK"} onChange={(e) => setEditing({ ...editing, WH_TYPE: e.target.value })} className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm bg-white"> <select value={editing.WH_TYPE ?? "STOCK"} onChange={(e) => setEditing({ ...editing, WH_TYPE: e.target.value })} className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm bg-white">
{Object.entries(TYPE_LABEL).map(([k, v]) => <option key={k} value={k}>{v}</option>)} {Object.entries(TYPE_LABEL).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
+1
View File
@@ -25,6 +25,7 @@ export async function POST(req: NextRequest) {
const rows = await queryRows( const rows = await queryRows(
`SELECT `SELECT
S.objid AS "OBJID", S.objid AS "OBJID",
W.objid AS "WH_OBJID",
W.wh_code AS "WH_CODE", W.wh_code AS "WH_CODE",
W.wh_name AS "WH_NAME", W.wh_name AS "WH_NAME",
I.objid AS "ITEM_OBJID", I.objid AS "ITEM_OBJID",
+72
View File
@@ -0,0 +1,72 @@
// 재고 이동 (A창고 → B창고). 출발창고 차감 + 도착창고 가산 + 이동 로그 2건
import { NextRequest, NextResponse } from "next/server";
import { pool } from "@/lib/db";
import { createObjectId } from "@/lib/utils";
import { requireMomoAdmin } from "@/lib/momo-guard";
export async function POST(req: NextRequest) {
const g = await requireMomoAdmin();
if (g instanceof NextResponse) return g;
const userId = g.user.objid || g.user.userId;
const { fromWhObjid, toWhObjid, itemObjid, qty, memo } = await req.json().catch(() => ({})) as {
fromWhObjid?: string; toWhObjid?: string; itemObjid?: string; qty?: number; memo?: string;
};
if (!fromWhObjid || !toWhObjid || !itemObjid || !qty || qty <= 0) {
return NextResponse.json({ success: false, message: "출발창고/도착창고/품목/수량을 입력하세요." }, { status: 400 });
}
if (fromWhObjid === toWhObjid) {
return NextResponse.json({ success: false, message: "출발창고와 도착창고가 같습니다." }, { status: 400 });
}
const client = await pool.connect();
try {
await client.query("BEGIN");
// 출발 재고 잠금 + 충분 여부 확인
const cur = await client.query<{ qty: number }>(
`SELECT qty::int FROM momo_stocks WHERE wh_objid=$1 AND item_objid=$2 FOR UPDATE`,
[fromWhObjid, itemObjid]
);
const have = cur.rows[0]?.qty ?? 0;
if (have < qty) {
await client.query("ROLLBACK");
return NextResponse.json({ success: false, message: `출발창고 재고가 부족합니다. (현재고 ${have})` }, { status: 400 });
}
// 출발 차감
await client.query(
`UPDATE momo_stocks SET qty = qty - $3, update_date = NOW()
WHERE wh_objid = $1 AND item_objid = $2`,
[fromWhObjid, itemObjid, qty]
);
// 도착 가산 (없으면 생성)
await client.query(
`INSERT INTO momo_stocks (objid, wh_objid, item_objid, qty, update_date)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (wh_objid, item_objid) DO UPDATE
SET qty = momo_stocks.qty + EXCLUDED.qty, update_date = NOW()`,
[createObjectId(), toWhObjid, itemObjid, qty]
);
// 이동 로그 — 출발(OUT) + 도착(IN)
await client.query(
`INSERT INTO momo_stock_moves (objid, wh_objid, item_objid, move_type, qty, ref_type, memo, regdate, regid)
VALUES ($1, $2, $3, 'OUT', $4, 'TRANSFER', $5, NOW(), $6),
($7, $8, $3, 'IN', $4, 'TRANSFER', $5, NOW(), $6)`,
[createObjectId(), fromWhObjid, itemObjid, qty, memo ?? null, userId, createObjectId(), toWhObjid]
);
await client.query("COMMIT");
return NextResponse.json({ success: true, message: `${qty} 단위 이동 완료` });
} catch (err) {
await client.query("ROLLBACK").catch(() => {});
const msg = err instanceof Error ? err.message : String(err);
console.error("[transfer]", err);
return NextResponse.json({ success: false, message: msg }, { status: 500 });
} finally {
client.release();
}
}
+20 -8
View File
@@ -1,28 +1,40 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { execute } from "@/lib/db"; import { execute, queryOne } from "@/lib/db";
import { createObjectId } from "@/lib/utils"; import { createObjectId } from "@/lib/utils";
import { requireMomoAdmin } from "@/lib/momo-guard"; import { requireMomoAdmin } from "@/lib/momo-guard";
// 다음 자동 코드 생성: WH001, WH002 ... — 가장 큰 숫자 + 1
async function nextWhCode(): Promise<string> {
const row = await queryOne<{ MAX_CODE: string | null }>(
`SELECT MAX(wh_code) AS "MAX_CODE" FROM momo_warehouses WHERE wh_code ~ '^WH[0-9]+$'`
);
const max = row?.MAX_CODE;
const nextNum = max ? parseInt(max.replace(/^WH/, ""), 10) + 1 : 1;
return `WH${String(nextNum).padStart(3, "0")}`;
}
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const g = await requireMomoAdmin(); const g = await requireMomoAdmin();
if (g instanceof NextResponse) return g; if (g instanceof NextResponse) return g;
const { objid, actionType, whCode, whName, location, whType } = await req.json(); const { objid, actionType, whName, location, whType } = await req.json();
if (!whCode || !whName) { if (!whName) {
return NextResponse.json({ success: false, message: "코드와 이름은 필수입니다." }, { status: 400 }); return NextResponse.json({ success: false, message: "창고명은 필수입니다." }, { status: 400 });
} }
if (actionType === "regist") { if (actionType === "regist") {
const id = createObjectId(); const id = createObjectId();
const code = await nextWhCode();
await execute( await execute(
`INSERT INTO momo_warehouses (objid, wh_code, wh_name, location, wh_type, regdate) `INSERT INTO momo_warehouses (objid, wh_code, wh_name, location, wh_type, regdate)
VALUES ($1,$2,$3,$4,$5,NOW())`, VALUES ($1,$2,$3,$4,$5,NOW())`,
[id, whCode, whName, location ?? null, whType ?? "STOCK"] [id, code, whName, location ?? null, whType ?? "STOCK"]
); );
return NextResponse.json({ success: true, objId: id }); return NextResponse.json({ success: true, objId: id, whCode: code });
} }
// 수정 시 wh_code 는 손대지 않음
await execute( await execute(
`UPDATE momo_warehouses SET wh_code=$2, wh_name=$3, location=$4, wh_type=$5 WHERE objid=$1`, `UPDATE momo_warehouses SET wh_name=$2, location=$3, wh_type=$4 WHERE objid=$1`,
[objid, whCode, whName, location ?? null, whType ?? "STOCK"] [objid, whName, location ?? null, whType ?? "STOCK"]
); );
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} }