feat(orders): 모든 품목 재고 무관 출고요청 — 음수 재고는 매입/입고에서 발주 트리거

핵심 정책 변경:
- 기존: 택배전용 품목만 재고 무관 출고요청 가능 / 일반 품목은 재고 ≥ 요청 강제
- 신규: 모든 품목 재고 무관 출고요청 가능. 권한 체크는 sale_start/end_date,
        is_hidden(+view_hidden), max_order_qty(+unlimited_qty) 만 적용

API (재고 체크 제거 — 한도/숨김/판매기간만 유지):
- orders/save: ITEM 재고 초과 검증 제거. needsDelivery 자동 추가는 유지
- orders/items/add: 재고 초과 검증 제거
- orders/items/update: 재고 초과 검증 + stock_qty 조회 자체 제거
- items/list: onlyAvailable 재고 필터 제거(옵션은 호환 위해 no-op로 유지)

사용자 화면 — 재고 표시/품절 제거 (재고 없어도 출고 가능):
- /m/orders/new: 카드/리스트에서 STOCK_QTY 컬럼 + '품절' 배지 제거.
       한도 체크는 MAX_ORDER_QTY(권한자 무제한) 만 적용
- /m/orders 주문 상세 ItemPickerModal: 재고 컬럼 + max=stock 제거,
       stockFilter:'AVAILABLE' → forSale:true 로 교체

관리자 화면 — 현재고 표시 유지하되 음수 강조:
- /m/admin/orders 거래명세표: 현재고 음수면 bg-rose-50 + extrabold,
       '재고 부족' 경고를 '음수 재고가 됩니다' 안내로 톤 변경
- /m/admin/inventory(매입/입고): 재고 매트릭스 음수 셀 bg-rose-50 + extrabold

(approve API의 음수 재고 허용 정책은 이전부터 적용되어 있어 변경 없음)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chpark
2026-05-31 22:53:17 +09:00
parent d6f80c187b
commit 77f2ef2cd5
8 changed files with 69 additions and 122 deletions
+18 -3
View File
@@ -229,8 +229,13 @@ export default function InventoryPage() {
const total = matrix.warehouses.reduce( const total = matrix.warehouses.reduce(
(sum, w) => sum + (matrix.cell[it.OBJID]?.[w.OBJID]?.qty ?? 0), 0 (sum, w) => sum + (matrix.cell[it.OBJID]?.[w.OBJID]?.qty ?? 0), 0
); );
const cls = total < 0
? "text-rose-700 font-extrabold bg-rose-50"
: total === 0
? "text-emerald-300"
: "text-emerald-800 font-bold";
return ( return (
<th key={it.OBJID} className={`px-3 py-2 text-right tabular-nums ${total === 0 ? "text-emerald-300" : "text-emerald-800 font-bold"}`}> <th key={it.OBJID} className={`px-3 py-2 text-right tabular-nums ${cls}`}>
{total === 0 ? "-" : `${fmt(total)} ${it.UNIT}`} {total === 0 ? "-" : `${fmt(total)} ${it.UNIT}`}
</th> </th>
); );
@@ -247,8 +252,13 @@ export default function InventoryPage() {
{matrix.items.map((it) => { {matrix.items.map((it) => {
const c = matrix.cell[it.OBJID]?.[w.OBJID]; const c = matrix.cell[it.OBJID]?.[w.OBJID];
const qty = c ? c.qty : 0; const qty = c ? c.qty : 0;
const cls = qty < 0
? "text-rose-700 font-extrabold bg-rose-50"
: qty === 0
? "text-slate-300"
: "text-slate-800 font-semibold";
return ( return (
<td key={it.OBJID} className={`px-3 py-2 text-right ${qty === 0 ? "text-slate-300" : "text-slate-800 font-semibold"}`}> <td key={it.OBJID} className={`px-3 py-2 text-right tabular-nums ${cls}`}>
{qty === 0 ? "-" : ( {qty === 0 ? "-" : (
<button <button
onClick={() => setHistoryOpen({ itemObjid: it.OBJID, whObjid: w.OBJID, itemName: it.NAME, whName: w.WH_NAME })} onClick={() => setHistoryOpen({ itemObjid: it.OBJID, whObjid: w.OBJID, itemName: it.NAME, whName: w.WH_NAME })}
@@ -291,8 +301,13 @@ export default function InventoryPage() {
{matrix.warehouses.map((w) => { {matrix.warehouses.map((w) => {
const c = matrix.cell[it.OBJID]?.[w.OBJID]; const c = matrix.cell[it.OBJID]?.[w.OBJID];
const qty = c ? c.qty : 0; const qty = c ? c.qty : 0;
const cls = qty < 0
? "text-rose-700 font-extrabold bg-rose-50"
: qty === 0
? "text-slate-300"
: "text-slate-800 font-semibold";
return ( return (
<td key={w.OBJID} className={`px-3 py-2 text-right ${qty === 0 ? "text-slate-300" : "text-slate-800 font-semibold"}`}> <td key={w.OBJID} className={`px-3 py-2 text-right tabular-nums ${cls}`}>
{qty === 0 ? "-" : ( {qty === 0 ? "-" : (
<button <button
onClick={() => setHistoryOpen({ itemObjid: it.OBJID, whObjid: w.OBJID, itemName: it.NAME, whName: w.WH_NAME })} onClick={() => setHistoryOpen({ itemObjid: it.OBJID, whObjid: w.OBJID, itemName: it.NAME, whName: w.WH_NAME })}
+15 -12
View File
@@ -652,10 +652,10 @@ function StatementPreview({
if (lack.length > 0) { if (lack.length > 0) {
const ok = await Swal.fire({ const ok = await Swal.fire({
icon: "warning", icon: "warning",
title: "재고 부족 항목이 있습니다.", title: "재고 부족 — 출고 시 음수 재고가 됩니다",
html: `<div class="text-left text-sm">${lack.map((it) => `· ${it.ITEM_NAME} (요청 ${fmt(it.QTY)} / 현재고 ${fmt(it.STOCK_QTY)})`).join("<br>")}</div><br>그래도 출고를 시도하시겠습니까?`, html: `<div class="text-left text-sm">${lack.map((it) => `· ${it.ITEM_NAME} (요청 ${fmt(it.QTY)} / 현재고 ${fmt(it.STOCK_QTY)})`).join("<br>")}</div><br>그대로 출고하면 매입/입고 담당자에게 음수 재고로 표시되어 발주가 진행됩니다. 계속하시겠습니까?`,
showCancelButton: true, showCancelButton: true,
confirmButtonText: "출고 시도", confirmButtonText: "출고",
cancelButtonText: "취소", cancelButtonText: "취소",
confirmButtonColor: "#0f766e", confirmButtonColor: "#0f766e",
}); });
@@ -924,10 +924,10 @@ function StatementPreview({
</div> </div>
{lowStock.length > 0 && ( {lowStock.length > 0 && (
<div className="border border-rose-200 bg-rose-50 rounded p-2 text-[13px] text-rose-700 flex items-start gap-2 js-no-export"> <div className="border border-amber-300 bg-amber-50 rounded p-2 text-[13px] text-amber-800 flex items-start gap-2 js-no-export">
<AlertCircle size={14} className="mt-0.5 flex-shrink-0" /> <AlertCircle size={14} className="mt-0.5 flex-shrink-0" />
<div> <div>
<b> {lowStock.length}</b> : <b> {lowStock.length}</b> . / :
<ul className="mt-1 ml-4 list-disc"> <ul className="mt-1 ml-4 list-disc">
{lowStock.map((it) => ( {lowStock.map((it) => (
<li key={it.SEQ}>{it.ITEM_NAME} ( {fmt(it.QTY)} / {fmt(it.STOCK_QTY)})</li> <li key={it.SEQ}>{it.ITEM_NAME} ( {fmt(it.QTY)} / {fmt(it.STOCK_QTY)})</li>
@@ -992,7 +992,9 @@ function StatementPreview({
{items.map((it, idx) => { {items.map((it, idx) => {
const displaySeq = idx + 1; const displaySeq = idx + 1;
const isExtra = it.KIND === "DELIVERY" || it.KIND === "CHARTER" || it.KIND === "REFUND"; const isExtra = it.KIND === "DELIVERY" || it.KIND === "CHARTER" || it.KIND === "REFUND";
const lack = !isExtra && Number(it.STOCK_QTY) < Number(it.QTY); const stockQty = Number(it.STOCK_QTY);
const negative = !isExtra && stockQty < 0;
const lack = !isExtra && stockQty < Number(it.QTY);
const kindBadge = it.KIND === "DELIVERY" ? "택배" : it.KIND === "CHARTER" ? "용차" : it.KIND === "REFUND" ? "환불" : null; const kindBadge = it.KIND === "DELIVERY" ? "택배" : it.KIND === "CHARTER" ? "용차" : it.KIND === "REFUND" ? "환불" : null;
const kindBg = it.KIND === "DELIVERY" ? "bg-orange-50" : it.KIND === "CHARTER" ? "bg-sky-50" : it.KIND === "REFUND" ? "bg-rose-50" : ""; const kindBg = it.KIND === "DELIVERY" ? "bg-orange-50" : it.KIND === "CHARTER" ? "bg-sky-50" : it.KIND === "REFUND" ? "bg-rose-50" : "";
@@ -1020,8 +1022,8 @@ function StatementPreview({
<td className={`border border-slate-300 px-1.5 py-1 text-center ${it.IS_TAX_FREE === "Y" ? "text-violet-700" : "text-rose-700"}`}> <td className={`border border-slate-300 px-1.5 py-1 text-center ${it.IS_TAX_FREE === "Y" ? "text-violet-700" : "text-rose-700"}`}>
{it.IS_TAX_FREE === "Y" ? "면세" : "과세"} {it.IS_TAX_FREE === "Y" ? "면세" : "과세"}
</td> </td>
<td className={`border border-slate-300 px-1.5 py-1 text-right js-no-export ${lack ? "text-rose-700 font-bold" : "text-slate-600"}`}> <td className={`border border-slate-300 px-1.5 py-1 text-right js-no-export tabular-nums ${negative ? "text-rose-700 font-extrabold bg-rose-50" : lack ? "text-rose-600 font-bold" : "text-slate-600"}`}>
{isExtra ? "-" : fmt(it.STOCK_QTY)} {isExtra ? "-" : fmt(stockQty)}
</td> </td>
<td className="border border-slate-300 px-1 py-0.5 text-right"> <td className="border border-slate-300 px-1 py-0.5 text-right">
{editable {editable
@@ -1134,9 +1136,10 @@ function AdminItemPickerModal({ onClose, onConfirm }: {
const [cart, setCart] = useState<Record<string, number>>({}); const [cart, setCart] = useState<Record<string, number>>({});
useEffect(() => { useEffect(() => {
// 관리자: 모든 판매 가능 품목 노출. 재고로 필터하지 않음 (음수 재고도 추가 가능).
fetch("/api/m/items/list", { fetch("/api/m/items/list", {
method: "POST", headers: { "Content-Type": "application/json" }, method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ stockFilter: "AVAILABLE" }), body: JSON.stringify({ forSale: true }),
}) })
.then((r) => r.json()) .then((r) => r.json())
.then((j) => setItems(j.RESULTLIST ?? [])) .then((j) => setItems(j.RESULTLIST ?? []))
@@ -1187,13 +1190,13 @@ function AdminItemPickerModal({ onClose, onConfirm }: {
<div className="font-semibold">{it.ITEM_NAME}</div> <div className="font-semibold">{it.ITEM_NAME}</div>
<div className="text-[10px] text-slate-400">{it.ITEM_CODE} · {it.IS_TAX_FREE === "Y" ? "면세" : "과세"}</div> <div className="text-[10px] text-slate-400">{it.ITEM_CODE} · {it.IS_TAX_FREE === "Y" ? "면세" : "과세"}</div>
</td> </td>
<td className="p-2 text-right tabular-nums">{Number(it.STOCK_QTY).toLocaleString()}</td> <td className={`p-2 text-right tabular-nums ${Number(it.STOCK_QTY) < 0 ? "text-rose-700 font-bold" : ""}`}>{Number(it.STOCK_QTY).toLocaleString()}</td>
<td className="p-2 text-right tabular-nums">{Number(it.UNIT_PRICE).toLocaleString()}</td> <td className="p-2 text-right tabular-nums">{Number(it.UNIT_PRICE).toLocaleString()}</td>
<td className="p-2 text-center"> <td className="p-2 text-center">
<input type="number" min={0} max={Number(it.STOCK_QTY)} <input type="number" min={0}
value={cart[it.OBJID] ?? 0} value={cart[it.OBJID] ?? 0}
onChange={(e) => { onChange={(e) => {
const v = Math.min(Number(it.STOCK_QTY), Math.max(0, Number(e.target.value) || 0)); const v = Math.max(0, Number(e.target.value) || 0);
setCart((p) => ({ ...p, [it.OBJID]: v })); setCart((p) => ({ ...p, [it.OBJID]: v }));
}} }}
className="w-16 h-7 px-1 border border-slate-200 rounded text-right tabular-nums" /> className="w-16 h-7 px-1 border border-slate-200 rounded text-right tabular-nums" />
+23 -64
View File
@@ -184,6 +184,13 @@ function ItemsBrowse() {
const addToCart = (item: Item) => addManyToCart(item, 1); const addToCart = (item: Item) => addManyToCart(item, 1);
// 출고요청 한도 계산 — 재고는 더 이상 제한이 아님(전 품목 재고 무관 출고).
// 1회 발주 한도(MAX_ORDER_QTY) 만 적용. unlimitedQty 권한이면 무제한.
const limitOf = (item: Item) => {
const maxQ = Number(item.MAX_ORDER_QTY ?? 0);
return unlimitedQty || maxQ <= 0 ? Number.MAX_SAFE_INTEGER : maxQ;
};
const addManyToCart = (item: Item, qty: number) => { const addManyToCart = (item: Item, qty: number) => {
// 판매 마감 품목은 담기 자체를 차단 (페이지 띄워둔 채 마감 시각이 지난 경우) // 판매 마감 품목은 담기 자체를 차단 (페이지 띄워둔 채 마감 시각이 지난 경우)
if (isSaleClosed(item.SALE_END_DATE)) { if (isSaleClosed(item.SALE_END_DATE)) {
@@ -195,30 +202,14 @@ function ItemsBrowse() {
}); });
return; return;
} }
const stock = Number(item.STOCK_QTY); const limit = limitOf(item);
const maxQ = Number(item.MAX_ORDER_QTY ?? 0);
const isDelivery = item.REQUIRES_DELIVERY === "Y";
const effStock = isDelivery ? Number.MAX_SAFE_INTEGER : stock;
// 재고 한도 = 전체 창고 합(effStock). unlimitedQty 는 1회 발주 한도(maxQ)만 무시, 총 재고는 못 넘김.
const limit = unlimitedQty || maxQ <= 0 ? effStock : Math.min(effStock, maxQ);
// setCart 함수형 업데이트 안에서 외부 변수에 warned 세팅하면 비동기 타이밍 때문에
// 첫 클릭에는 if(warned) 체크가 한 박자 늦게 동작. 동기 체크로 변경.
const found = cart.find((x) => x.item.OBJID === item.OBJID); const found = cart.find((x) => x.item.OBJID === item.OBJID);
const newQty = (found?.qty ?? 0) + qty; const newQty = (found?.qty ?? 0) + qty;
if (newQty > limit) { if (newQty > limit) {
const isStockLimit = maxQ <= 0 || stock <= maxQ; toastLimit(limit);
Swal.fire({ return;
icon: "warning",
title: isStockLimit ? "재고 수량 초과" : "1회 발주 한도 초과",
text: isStockLimit
? `현재 재고 ${fmt(limit)}개 보다 많은 수량은 출고 요청할 수 없습니다.`
: `1회 최대 ${fmt(limit)}개까지 발주 가능합니다.`,
confirmButtonColor: "#0f766e",
confirmButtonText: "확인",
});
return; // 차단 — 장바구니 변경 없음
} }
setCart((c) => { setCart((c) => {
@@ -238,14 +229,9 @@ function ItemsBrowse() {
if (!target) return; if (!target) return;
const newQty = target.qty + delta; const newQty = target.qty + delta;
if (newQty <= 0) return; if (newQty <= 0) return;
const stock = Number(target.item.STOCK_QTY); const limit = limitOf(target.item);
const maxQ = Number(target.item.MAX_ORDER_QTY ?? 0);
const isDelivery = target.item.REQUIRES_DELIVERY === "Y";
const effStock = isDelivery ? Number.MAX_SAFE_INTEGER : stock;
// 재고 한도 = 전체 창고 합(effStock). unlimitedQty 는 1회 발주 한도(maxQ)만 무시, 총 재고는 못 넘김.
const limit = unlimitedQty || maxQ <= 0 ? effStock : Math.min(effStock, maxQ);
if (newQty > limit) { if (newQty > limit) {
toastLimit(limit, maxQ <= 0 || stock <= maxQ); toastLimit(limit);
return; return;
} }
setCart((c) => c.map((x) => x.item.OBJID === objid ? { ...x, qty: newQty } : x)); setCart((c) => c.map((x) => x.item.OBJID === objid ? { ...x, qty: newQty } : x));
@@ -254,29 +240,21 @@ function ItemsBrowse() {
const setQty = (objid: string, value: number) => { const setQty = (objid: string, value: number) => {
const target = cart.find((x) => x.item.OBJID === objid); const target = cart.find((x) => x.item.OBJID === objid);
if (!target) return; if (!target) return;
const stock = Number(target.item.STOCK_QTY); const limit = limitOf(target.item);
const maxQ = Number(target.item.MAX_ORDER_QTY ?? 0);
const isDelivery = target.item.REQUIRES_DELIVERY === "Y";
const effStock = isDelivery ? Number.MAX_SAFE_INTEGER : stock;
// 재고 한도 = 전체 창고 합(effStock). unlimitedQty 는 1회 발주 한도(maxQ)만 무시, 총 재고는 못 넘김.
const limit = unlimitedQty || maxQ <= 0 ? effStock : Math.min(effStock, maxQ);
const requested = Math.floor(value || 0); const requested = Math.floor(value || 0);
if (requested > limit) { if (requested > limit) {
toastLimit(limit, maxQ <= 0 || stock <= maxQ); toastLimit(limit);
// 차단 — 기존 수량 유지
return; return;
} }
const clamped = Math.max(1, requested); const clamped = Math.max(1, requested);
setCart((c) => c.map((x) => x.item.OBJID === objid ? { ...x, qty: clamped } : x)); setCart((c) => c.map((x) => x.item.OBJID === objid ? { ...x, qty: clamped } : x));
}; };
const toastLimit = (limit: number, isStockLimit: boolean) => { const toastLimit = (limit: number) => {
Swal.fire({ Swal.fire({
icon: "warning", icon: "warning",
title: isStockLimit ? "재고 수량 초과" : "1회 발주 한도 초과", title: "1회 발주 한도 초과",
text: isStockLimit text: `1회 최대 ${fmt(limit)}개까지 발주 가능합니다.`,
? `현재 재고 ${fmt(limit)}개 보다 많은 수량은 출고 요청할 수 없습니다.`
: `1회 최대 ${fmt(limit)}개까지 발주 가능합니다.`,
confirmButtonColor: "#0f766e", confirmButtonColor: "#0f766e",
confirmButtonText: "확인", confirmButtonText: "확인",
}); });
@@ -590,7 +568,7 @@ function ItemsBrowse() {
<div className="flex-1 space-y-4 overflow-y-auto pr-1"> <div className="flex-1 space-y-4 overflow-y-auto pr-1">
<div> <div>
<h1 className="text-xl sm:text-2xl font-bold text-slate-900"> </h1> <h1 className="text-xl sm:text-2xl font-bold text-slate-900"> </h1>
<p className="text-slate-500 text-xs sm:text-sm mt-1"> [ ] .</p> <p className="text-slate-500 text-xs sm:text-sm mt-1"> [ ] .</p>
<p className="text-slate-400 text-[11px] mt-0.5"> / <b></b> .</p> <p className="text-slate-400 text-[11px] mt-0.5"> / <b></b> .</p>
</div> </div>
@@ -681,16 +659,11 @@ function ItemsBrowse() {
{items.map((it) => { {items.map((it) => {
const cartLine = cart.find((x) => x.item.OBJID === it.OBJID); const cartLine = cart.find((x) => x.item.OBJID === it.OBJID);
const inCart = cartLine?.qty ?? 0; const inCart = cartLine?.qty ?? 0;
const stock = Number(it.STOCK_QTY);
const maxQ = Number(it.MAX_ORDER_QTY ?? 0); const maxQ = Number(it.MAX_ORDER_QTY ?? 0);
const isDelivery = it.REQUIRES_DELIVERY === "Y"; const limit = unlimitedQty || maxQ <= 0 ? Number.MAX_SAFE_INTEGER : maxQ;
const effStock = isDelivery ? Number.MAX_SAFE_INTEGER : stock;
const limit = unlimitedQty || maxQ <= 0 ? effStock : Math.min(effStock, maxQ);
const closed = isSaleClosed(it.SALE_END_DATE); const closed = isSaleClosed(it.SALE_END_DATE);
const soldOut = !isDelivery && stock <= 0;
const dim = soldOut || closed;
return ( return (
<div key={it.OBJID} className={`bg-white border rounded-lg p-2 transition ${dim ? "opacity-50" : ""} ${inCart > 0 ? "border-emerald-400 ring-2 ring-emerald-100" : "border-slate-200 hover:shadow-md"}`}> <div key={it.OBJID} className={`bg-white border rounded-lg p-2 transition ${closed ? "opacity-50" : ""} ${inCart > 0 ? "border-emerald-400 ring-2 ring-emerald-100" : "border-slate-200 hover:shadow-md"}`}>
<div className="aspect-[4/3] bg-slate-50 rounded mb-1.5 overflow-hidden flex items-center justify-center relative"> <div className="aspect-[4/3] bg-slate-50 rounded mb-1.5 overflow-hidden flex items-center justify-center relative">
{it.IMAGE_URL ? ( {it.IMAGE_URL ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
@@ -715,9 +688,7 @@ function ItemsBrowse() {
<div className="font-bold text-sm sm:text-base text-slate-900 leading-tight mb-1 line-clamp-2 min-h-[2.4em]">{it.ITEM_NAME}</div> <div className="font-bold text-sm sm:text-base text-slate-900 leading-tight mb-1 line-clamp-2 min-h-[2.4em]">{it.ITEM_NAME}</div>
<div className="flex items-baseline justify-between mb-1 gap-1"> <div className="flex items-baseline justify-between mb-1 gap-1">
<div className="font-extrabold text-slate-900 tabular-nums text-base sm:text-lg">{fmt(it.UNIT_PRICE)}</div> <div className="font-extrabold text-slate-900 tabular-nums text-base sm:text-lg">{fmt(it.UNIT_PRICE)}</div>
<div className={`text-sm sm:text-base font-extrabold tabular-nums shrink-0 ${stock > 0 ? "text-emerald-700" : "text-rose-500"}`}> <div className="text-[10px] text-slate-400 tabular-nums shrink-0">{it.UNIT}</div>
{fmt(stock)}{it.UNIT}
</div>
</div> </div>
{it.SALE_END_DATE && ( {it.SALE_END_DATE && (
<div className={`text-[11px] sm:text-xs mb-1 tabular-nums font-bold leading-tight whitespace-nowrap truncate ${closed ? "text-slate-400" : "text-rose-600"}`}> <div className={`text-[11px] sm:text-xs mb-1 tabular-nums font-bold leading-tight whitespace-nowrap truncate ${closed ? "text-slate-400" : "text-rose-600"}`}>
@@ -733,10 +704,6 @@ function ItemsBrowse() {
<div className="w-full mt-1 h-7 rounded bg-slate-100 text-slate-400 text-[11px] font-bold flex items-center justify-center"> <div className="w-full mt-1 h-7 rounded bg-slate-100 text-slate-400 text-[11px] font-bold flex items-center justify-center">
</div> </div>
) : soldOut ? (
<div className="w-full mt-1 h-7 rounded bg-slate-100 text-slate-400 text-[11px] font-bold flex items-center justify-center">
</div>
) : inCart === 0 ? ( ) : inCart === 0 ? (
<div className="flex gap-1 mt-1"> <div className="flex gap-1 mt-1">
<input <input
@@ -832,7 +799,6 @@ function ListView({ items, cart, unlimitedQty, onAdd, onPlus, onMinus, onSetQty,
<th className="text-left px-2 py-2"></th> <th className="text-left px-2 py-2"></th>
<th className="text-center px-1 py-2 w-10"></th> <th className="text-center px-1 py-2 w-10"></th>
<th className="text-right px-1 py-2 w-[68px]"></th> <th className="text-right px-1 py-2 w-[68px]"></th>
<th className="text-right px-1 py-2 w-12"></th>
<th className="text-center px-1 py-2 w-[120px]"></th> <th className="text-center px-1 py-2 w-[120px]"></th>
<th className="text-center px-1 py-2 w-[112px]"></th> <th className="text-center px-1 py-2 w-[112px]"></th>
</tr> </tr>
@@ -841,15 +807,11 @@ function ListView({ items, cart, unlimitedQty, onAdd, onPlus, onMinus, onSetQty,
{items.map((it) => { {items.map((it) => {
const cartLine = cart.find((x) => x.item.OBJID === it.OBJID); const cartLine = cart.find((x) => x.item.OBJID === it.OBJID);
const inCart = cartLine?.qty ?? 0; const inCart = cartLine?.qty ?? 0;
const stock = Number(it.STOCK_QTY);
const maxQ = Number(it.MAX_ORDER_QTY ?? 0); const maxQ = Number(it.MAX_ORDER_QTY ?? 0);
const isDelivery = it.REQUIRES_DELIVERY === "Y"; const limit = unlimitedQty || maxQ <= 0 ? Number.MAX_SAFE_INTEGER : maxQ;
const effStock = isDelivery ? Number.MAX_SAFE_INTEGER : stock;
const limit = unlimitedQty || maxQ <= 0 ? effStock : Math.min(effStock, maxQ);
const closed = isSaleClosed(it.SALE_END_DATE); const closed = isSaleClosed(it.SALE_END_DATE);
const soldOut = !isDelivery && stock <= 0;
return ( return (
<tr key={it.OBJID} className={`border-t border-slate-100 ${soldOut || closed ? "opacity-50" : ""} ${inCart > 0 ? "bg-emerald-50/40" : "hover:bg-slate-50"}`}> <tr key={it.OBJID} className={`border-t border-slate-100 ${closed ? "opacity-50" : ""} ${inCart > 0 ? "bg-emerald-50/40" : "hover:bg-slate-50"}`}>
<td className="px-2 py-2 overflow-hidden"> <td className="px-2 py-2 overflow-hidden">
<div className="font-semibold truncate text-[12px]"> <div className="font-semibold truncate text-[12px]">
{it.ITEM_NAME} {it.ITEM_NAME}
@@ -863,15 +825,12 @@ function ListView({ items, cart, unlimitedQty, onAdd, onPlus, onMinus, onSetQty,
</span> </span>
</td> </td>
<td className="px-1 py-2 text-right tabular-nums font-bold text-[11px]">{Number(it.UNIT_PRICE).toLocaleString("ko-KR")}</td> <td className="px-1 py-2 text-right tabular-nums font-bold text-[11px]">{Number(it.UNIT_PRICE).toLocaleString("ko-KR")}</td>
<td className={`px-1 py-2 text-right tabular-nums text-[11px] ${stock <= 0 ? "text-rose-500 font-bold" : "text-slate-700"}`}>{Number(stock).toLocaleString("ko-KR")}</td>
<td className={`px-1 py-2 text-center text-[10px] tabular-nums font-semibold ${closed ? "text-slate-400" : "text-rose-600"}`}> <td className={`px-1 py-2 text-center text-[10px] tabular-nums font-semibold ${closed ? "text-slate-400" : "text-rose-600"}`}>
{it.SALE_END_DATE ? <>{it.SALE_END_DATE}{closed ? " (종료)" : ""}</> : <span className="text-slate-300"></span>} {it.SALE_END_DATE ? <>{it.SALE_END_DATE}{closed ? " (종료)" : ""}</> : <span className="text-slate-300"></span>}
</td> </td>
<td className="px-1 py-2"> <td className="px-1 py-2">
{closed ? ( {closed ? (
<div className="text-center text-[10px] text-slate-400"> </div> <div className="text-center text-[10px] text-slate-400"> </div>
) : soldOut ? (
<div className="text-center text-[10px] text-slate-400"></div>
) : inCart === 0 ? ( ) : inCart === 0 ? (
<div className="flex gap-0.5 justify-end"> <div className="flex gap-0.5 justify-end">
<input <input
+6 -7
View File
@@ -482,14 +482,15 @@ function ItemPickerModal({ onClose, onConfirm }: {
onClose: () => void; onClose: () => void;
onConfirm: (selected: { itemObjid: string; qty: number }[]) => void; onConfirm: (selected: { itemObjid: string; qty: number }[]) => void;
}) { }) {
const [items, setItems] = useState<Array<{ OBJID: string; ITEM_CODE: string; ITEM_NAME: string; UNIT_PRICE: number; STOCK_QTY: number; IS_TAX_FREE: string; UNIT: string }>>([]); const [items, setItems] = useState<Array<{ OBJID: string; ITEM_CODE: string; ITEM_NAME: string; UNIT_PRICE: number; IS_TAX_FREE: string; UNIT: string }>>([]);
const [keyword, setKeyword] = useState(""); const [keyword, setKeyword] = useState("");
const [cart, setCart] = useState<Record<string, number>>({}); const [cart, setCart] = useState<Record<string, number>>({});
useEffect(() => { useEffect(() => {
// 모든 판매 가능 품목 표시 — 재고 무관 출고요청 가능
fetch("/api/m/items/list", { fetch("/api/m/items/list", {
method: "POST", headers: { "Content-Type": "application/json" }, method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ stockFilter: "AVAILABLE" }), body: JSON.stringify({ forSale: true }),
}) })
.then((r) => r.json()) .then((r) => r.json())
.then((j) => setItems(j.RESULTLIST ?? [])) .then((j) => setItems(j.RESULTLIST ?? []))
@@ -526,27 +527,25 @@ function ItemPickerModal({ onClose, onConfirm }: {
<thead className="bg-slate-50 text-slate-600 sticky top-0"> <thead className="bg-slate-50 text-slate-600 sticky top-0">
<tr> <tr>
<th className="text-left p-2"></th> <th className="text-left p-2"></th>
<th className="text-right p-2 w-16"></th>
<th className="text-right p-2 w-20"></th> <th className="text-right p-2 w-20"></th>
<th className="text-center p-2 w-20"></th> <th className="text-center p-2 w-20"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<tr><td colSpan={4} className="text-center py-8 text-slate-400"> </td></tr> <tr><td colSpan={3} className="text-center py-8 text-slate-400"> </td></tr>
) : filtered.slice(0, 100).map((it) => ( ) : filtered.slice(0, 100).map((it) => (
<tr key={it.OBJID} className="border-t border-slate-100 hover:bg-slate-50"> <tr key={it.OBJID} className="border-t border-slate-100 hover:bg-slate-50">
<td className="p-2"> <td className="p-2">
<div className="font-semibold">{it.ITEM_NAME}</div> <div className="font-semibold">{it.ITEM_NAME}</div>
<div className="text-[10px] text-slate-400">{it.ITEM_CODE} · {it.IS_TAX_FREE === "Y" ? "면세" : "과세"}</div> <div className="text-[10px] text-slate-400">{it.ITEM_CODE} · {it.IS_TAX_FREE === "Y" ? "면세" : "과세"}</div>
</td> </td>
<td className="p-2 text-right tabular-nums">{Number(it.STOCK_QTY).toLocaleString()}</td>
<td className="p-2 text-right tabular-nums">{Number(it.UNIT_PRICE).toLocaleString()}</td> <td className="p-2 text-right tabular-nums">{Number(it.UNIT_PRICE).toLocaleString()}</td>
<td className="p-2 text-center"> <td className="p-2 text-center">
<input type="number" min={0} max={Number(it.STOCK_QTY)} <input type="number" min={0}
value={cart[it.OBJID] ?? 0} value={cart[it.OBJID] ?? 0}
onChange={(e) => { onChange={(e) => {
const v = Math.min(Number(it.STOCK_QTY), Math.max(0, Number(e.target.value) || 0)); const v = Math.max(0, Number(e.target.value) || 0);
setCart((p) => ({ ...p, [it.OBJID]: v })); setCart((p) => ({ ...p, [it.OBJID]: v }));
}} }}
className="w-16 h-7 px-1 border border-slate-200 rounded text-right tabular-nums" /> className="w-16 h-7 px-1 border border-slate-200 rounded text-right tabular-nums" />
+3 -7
View File
@@ -103,13 +103,9 @@ export async function POST(req: NextRequest) {
conditions.push(`I.vendor_objid = $${i++}`); conditions.push(`I.vendor_objid = $${i++}`);
params.push(vendorObjid); params.push(vendorObjid);
} }
if (onlyAvailable) { // onlyAvailable: 더 이상 재고로 필터하지 않음 — 모든 품목은 재고 무관 출고요청 가능.
// 택배 필수 품목(requires_delivery='Y') 은 재고 무관 노출. // (기존 화면 호환을 위해 옵션은 유지하되 동작은 no-op. 노출/숨김은 sale_start/end + is_hidden 으로만 결정.)
conditions.push( void onlyAvailable;
`(COALESCE(I.requires_delivery,'N') = 'Y'
OR COALESCE((SELECT SUM(S.qty) FROM momo_stocks S JOIN momo_warehouses W ON S.wh_objid = W.objid WHERE S.item_objid = I.objid AND COALESCE(W.is_del,'N') != 'Y'), 0) > 0)`
);
}
// 출고요청(orders/new) 메뉴: 판매 가능 기간(sale_start_date ~ sale_end_date) 안의 품목만. // 출고요청(orders/new) 메뉴: 판매 가능 기간(sale_start_date ~ sale_end_date) 안의 품목만.
// 기간이 NULL 인 품목은 상시 노출. USER 항상 적용, ADMIN 도 forSale=true 이면 적용. // 기간이 NULL 인 품목은 상시 노출. USER 항상 적용, ADMIN 도 forSale=true 이면 적용.
// //
+1 -7
View File
@@ -113,6 +113,7 @@ export async function POST(req: NextRequest) {
); );
const unlimited = u.rows[0]?.u === "Y"; const unlimited = u.rows[0]?.u === "Y";
const viewHidden = u.rows[0]?.v === "Y"; const viewHidden = u.rows[0]?.v === "Y";
// 재고 체크는 하지 않음 — 모든 품목 재고 무관 추가 가능. 숨김/한도만 검증.
for (const ln of items) { for (const ln of items) {
const it = itemMap.get(ln.itemObjid); const it = itemMap.get(ln.itemObjid);
if (!it) { if (!it) {
@@ -123,13 +124,6 @@ export async function POST(req: NextRequest) {
await client.query("ROLLBACK"); await client.query("ROLLBACK");
return NextResponse.json({ success: false, message: `${it.item_name} 은 발주 불가 품목입니다.` }, { status: 400 }); return NextResponse.json({ success: false, message: `${it.item_name} 은 발주 불가 품목입니다.` }, { status: 400 });
} }
const stock = Number(it.stock_qty ?? 0);
// 택배 전용 품목은 재고 무관하게 추가 가능
const isDeliveryItem = it.requires_delivery === "Y";
if (!isDeliveryItem && Number(ln.qty) > stock) {
await client.query("ROLLBACK");
return NextResponse.json({ success: false, message: `${it.item_name} — 재고(${stock}) 초과` }, { status: 400 });
}
if (!unlimited) { if (!unlimited) {
const maxQ = it.max_order_qty == null ? 0 : Number(it.max_order_qty); const maxQ = it.max_order_qty == null ? 0 : Number(it.max_order_qty);
if (maxQ > 0 && Number(ln.qty) > maxQ) { if (maxQ > 0 && Number(ln.qty) > maxQ) {
+1 -8
View File
@@ -122,15 +122,8 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ success: false, message: "수량은 1 이상이어야 합니다." }, { status: 400 }); return NextResponse.json({ success: false, message: "수량은 1 이상이어야 합니다." }, { status: 400 });
} }
// 재고 / 한도 검증 (관리자가 아니고 unlimited_qty 권한 없으면) // 재고 체크는 하지 않음 — 모든 품목 재고 무관 수정 가능. 1회 발주 한도만 검증.
// 택배 전용 품목은 재고 무관하게 발주 가능
const isDeliveryItem = cur.requires_delivery === "Y";
if (!isAdmin) { if (!isAdmin) {
const stock = Number(cur.stock_qty);
if (!isDeliveryItem && newQty > stock) {
await client.query("ROLLBACK");
return NextResponse.json({ success: false, message: `${cur.item_name} — 재고(${stock})를 초과할 수 없습니다.` }, { status: 400 });
}
const u = await client.query( const u = await client.query(
`SELECT COALESCE(unlimited_qty,'N') AS u FROM user_info WHERE user_id = $1`, `SELECT COALESCE(unlimited_qty,'N') AS u FROM user_info WHERE user_id = $1`,
[r.user.objid] [r.user.objid]
+2 -14
View File
@@ -127,11 +127,11 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ success: false, message: `존재하지 않는 품목입니다: ${missing.itemObjid}` }, { status: 400 }); return NextResponse.json({ success: false, message: `존재하지 않는 품목입니다: ${missing.itemObjid}` }, { status: 400 });
} }
// 수량/숨김/택배/판매기간 검증 // 수량/숨김/판매기간 검증 (재고 체크는 하지 않음 — 모든 품목은 재고 무관 출고요청 가능.
// 부족분은 approve 시 음수로 떨어진 뒤 매입/입고 담당자가 음수 재고를 보고 발주한다.)
let needsDelivery = false; let needsDelivery = false;
for (const ln of lines) { for (const ln of lines) {
const it = itemMap.get(ln.itemObjid)!; const it = itemMap.get(ln.itemObjid)!;
const stock = Number(it.stock_qty ?? 0);
// 판매기간(마감) 재체크 — 목록에 떠 있을 때 담아두고 마감 시각이 지난 뒤 전송하는 경우 차단. // 판매기간(마감) 재체크 — 목록에 떠 있을 때 담아두고 마감 시각이 지난 뒤 전송하는 경우 차단.
// (화면 노출 필터와 별개로 서버에서 KST 기준 한 번 더 확정) // (화면 노출 필터와 별개로 서버에서 KST 기준 한 번 더 확정)
if (it.on_sale === false) { if (it.on_sale === false) {
@@ -141,18 +141,6 @@ export async function POST(req: NextRequest) {
message: `${it.item_name}${endTxt} — 판매가 마감된 품목입니다. 출고 요청할 수 없습니다.`, message: `${it.item_name}${endTxt} — 판매가 마감된 품목입니다. 출고 요청할 수 없습니다.`,
}, { status: 400 }); }, { status: 400 });
} }
// 택배 전용 품목(requires_delivery='Y')은 재고와 무관하게 발주 가능
const isDeliveryItem = it.requires_delivery === "Y";
// 재고 체크는 "전체 창고 합(stock_qty)" 기준 — 누구도 총 재고보다 많이 출고할 수 없음.
// 기준 창고(거래처 default_wh)가 비어 있어도 총 재고가 충분하면 출고 가능.
// 실제 차감은 approve 에서 기준 창고에서 수행하며, 부족분은 음수로 떨어뜨린 뒤
// 관리자가 재고 이동으로 정리한다.
if (!isDeliveryItem && Number(ln.qty) > stock) {
return NextResponse.json({
success: false,
message: `${it.item_name} — 전체 재고(${stock})를 초과할 수 없습니다.`,
}, { status: 400 });
}
if (!unlimitedQty) { if (!unlimitedQty) {
const maxQ = it.max_order_qty == null ? 0 : Number(it.max_order_qty); const maxQ = it.max_order_qty == null ? 0 : Number(it.max_order_qty);
if (maxQ > 0 && Number(ln.qty) > maxQ) { if (maxQ > 0 && Number(ln.qty) > maxQ) {