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:
@@ -229,8 +229,13 @@ export default function InventoryPage() {
|
||||
const total = matrix.warehouses.reduce(
|
||||
(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 (
|
||||
<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}`}
|
||||
</th>
|
||||
);
|
||||
@@ -247,8 +252,13 @@ export default function InventoryPage() {
|
||||
{matrix.items.map((it) => {
|
||||
const c = matrix.cell[it.OBJID]?.[w.OBJID];
|
||||
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 (
|
||||
<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 ? "-" : (
|
||||
<button
|
||||
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) => {
|
||||
const c = matrix.cell[it.OBJID]?.[w.OBJID];
|
||||
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 (
|
||||
<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 ? "-" : (
|
||||
<button
|
||||
onClick={() => setHistoryOpen({ itemObjid: it.OBJID, whObjid: w.OBJID, itemName: it.NAME, whName: w.WH_NAME })}
|
||||
|
||||
@@ -652,10 +652,10 @@ function StatementPreview({
|
||||
if (lack.length > 0) {
|
||||
const ok = await Swal.fire({
|
||||
icon: "warning",
|
||||
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>그래도 출고를 시도하시겠습니까?`,
|
||||
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>그대로 출고하면 매입/입고 담당자에게 음수 재고로 표시되어 발주가 진행됩니다. 계속하시겠습니까?`,
|
||||
showCancelButton: true,
|
||||
confirmButtonText: "출고 시도",
|
||||
confirmButtonText: "출고",
|
||||
cancelButtonText: "취소",
|
||||
confirmButtonColor: "#0f766e",
|
||||
});
|
||||
@@ -924,10 +924,10 @@ function StatementPreview({
|
||||
</div>
|
||||
|
||||
{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" />
|
||||
<div>
|
||||
<b>재고 부족 {lowStock.length}건</b> — 출고 시 거부됩니다:
|
||||
<b>재고 부족 {lowStock.length}건</b> — 출고 가능. 출고하면 음수 재고로 떨어져 매입/입고 담당자가 발주합니다:
|
||||
<ul className="mt-1 ml-4 list-disc">
|
||||
{lowStock.map((it) => (
|
||||
<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) => {
|
||||
const displaySeq = idx + 1;
|
||||
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 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"}`}>
|
||||
{it.IS_TAX_FREE === "Y" ? "면세" : "과세"}
|
||||
</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"}`}>
|
||||
{isExtra ? "-" : fmt(it.STOCK_QTY)}
|
||||
<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(stockQty)}
|
||||
</td>
|
||||
<td className="border border-slate-300 px-1 py-0.5 text-right">
|
||||
{editable
|
||||
@@ -1134,9 +1136,10 @@ function AdminItemPickerModal({ onClose, onConfirm }: {
|
||||
const [cart, setCart] = useState<Record<string, number>>({});
|
||||
|
||||
useEffect(() => {
|
||||
// 관리자: 모든 판매 가능 품목 노출. 재고로 필터하지 않음 (음수 재고도 추가 가능).
|
||||
fetch("/api/m/items/list", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ stockFilter: "AVAILABLE" }),
|
||||
body: JSON.stringify({ forSale: true }),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((j) => setItems(j.RESULTLIST ?? []))
|
||||
@@ -1187,13 +1190,13 @@ function AdminItemPickerModal({ onClose, onConfirm }: {
|
||||
<div className="font-semibold">{it.ITEM_NAME}</div>
|
||||
<div className="text-[10px] text-slate-400">{it.ITEM_CODE} · {it.IS_TAX_FREE === "Y" ? "면세" : "과세"}</div>
|
||||
</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-center">
|
||||
<input type="number" min={0} max={Number(it.STOCK_QTY)}
|
||||
<input type="number" min={0}
|
||||
value={cart[it.OBJID] ?? 0}
|
||||
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 }));
|
||||
}}
|
||||
className="w-16 h-7 px-1 border border-slate-200 rounded text-right tabular-nums" />
|
||||
|
||||
@@ -184,6 +184,13 @@ function ItemsBrowse() {
|
||||
|
||||
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) => {
|
||||
// 판매 마감 품목은 담기 자체를 차단 (페이지 띄워둔 채 마감 시각이 지난 경우)
|
||||
if (isSaleClosed(item.SALE_END_DATE)) {
|
||||
@@ -195,30 +202,14 @@ function ItemsBrowse() {
|
||||
});
|
||||
return;
|
||||
}
|
||||
const stock = Number(item.STOCK_QTY);
|
||||
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);
|
||||
const limit = limitOf(item);
|
||||
|
||||
// setCart 함수형 업데이트 안에서 외부 변수에 warned 세팅하면 비동기 타이밍 때문에
|
||||
// 첫 클릭에는 if(warned) 체크가 한 박자 늦게 동작. 동기 체크로 변경.
|
||||
const found = cart.find((x) => x.item.OBJID === item.OBJID);
|
||||
const newQty = (found?.qty ?? 0) + qty;
|
||||
|
||||
if (newQty > limit) {
|
||||
const isStockLimit = maxQ <= 0 || stock <= maxQ;
|
||||
Swal.fire({
|
||||
icon: "warning",
|
||||
title: isStockLimit ? "재고 수량 초과" : "1회 발주 한도 초과",
|
||||
text: isStockLimit
|
||||
? `현재 재고 ${fmt(limit)}개 보다 많은 수량은 출고 요청할 수 없습니다.`
|
||||
: `1회 최대 ${fmt(limit)}개까지 발주 가능합니다.`,
|
||||
confirmButtonColor: "#0f766e",
|
||||
confirmButtonText: "확인",
|
||||
});
|
||||
return; // 차단 — 장바구니 변경 없음
|
||||
toastLimit(limit);
|
||||
return;
|
||||
}
|
||||
|
||||
setCart((c) => {
|
||||
@@ -238,14 +229,9 @@ function ItemsBrowse() {
|
||||
if (!target) return;
|
||||
const newQty = target.qty + delta;
|
||||
if (newQty <= 0) return;
|
||||
const stock = Number(target.item.STOCK_QTY);
|
||||
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 limit = limitOf(target.item);
|
||||
if (newQty > limit) {
|
||||
toastLimit(limit, maxQ <= 0 || stock <= maxQ);
|
||||
toastLimit(limit);
|
||||
return;
|
||||
}
|
||||
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 target = cart.find((x) => x.item.OBJID === objid);
|
||||
if (!target) return;
|
||||
const stock = Number(target.item.STOCK_QTY);
|
||||
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 limit = limitOf(target.item);
|
||||
const requested = Math.floor(value || 0);
|
||||
if (requested > limit) {
|
||||
toastLimit(limit, maxQ <= 0 || stock <= maxQ);
|
||||
// 차단 — 기존 수량 유지
|
||||
toastLimit(limit);
|
||||
return;
|
||||
}
|
||||
const clamped = Math.max(1, requested);
|
||||
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({
|
||||
icon: "warning",
|
||||
title: isStockLimit ? "재고 수량 초과" : "1회 발주 한도 초과",
|
||||
text: isStockLimit
|
||||
? `현재 재고 ${fmt(limit)}개 보다 많은 수량은 출고 요청할 수 없습니다.`
|
||||
: `1회 최대 ${fmt(limit)}개까지 발주 가능합니다.`,
|
||||
title: "1회 발주 한도 초과",
|
||||
text: `1회 최대 ${fmt(limit)}개까지 발주 가능합니다.`,
|
||||
confirmButtonColor: "#0f766e",
|
||||
confirmButtonText: "확인",
|
||||
});
|
||||
@@ -590,7 +568,7 @@ function ItemsBrowse() {
|
||||
<div className="flex-1 space-y-4 overflow-y-auto pr-1">
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -681,16 +659,11 @@ function ItemsBrowse() {
|
||||
{items.map((it) => {
|
||||
const cartLine = cart.find((x) => x.item.OBJID === it.OBJID);
|
||||
const inCart = cartLine?.qty ?? 0;
|
||||
const stock = Number(it.STOCK_QTY);
|
||||
const maxQ = Number(it.MAX_ORDER_QTY ?? 0);
|
||||
const isDelivery = it.REQUIRES_DELIVERY === "Y";
|
||||
const effStock = isDelivery ? Number.MAX_SAFE_INTEGER : stock;
|
||||
const limit = unlimitedQty || maxQ <= 0 ? effStock : Math.min(effStock, maxQ);
|
||||
const limit = unlimitedQty || maxQ <= 0 ? Number.MAX_SAFE_INTEGER : maxQ;
|
||||
const closed = isSaleClosed(it.SALE_END_DATE);
|
||||
const soldOut = !isDelivery && stock <= 0;
|
||||
const dim = soldOut || closed;
|
||||
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">
|
||||
{it.IMAGE_URL ? (
|
||||
// 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="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={`text-sm sm:text-base font-extrabold tabular-nums shrink-0 ${stock > 0 ? "text-emerald-700" : "text-rose-500"}`}>
|
||||
{fmt(stock)}{it.UNIT}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-400 tabular-nums shrink-0">{it.UNIT}</div>
|
||||
</div>
|
||||
{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"}`}>
|
||||
@@ -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>
|
||||
) : 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 ? (
|
||||
<div className="flex gap-1 mt-1">
|
||||
<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-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-12">재고</th>
|
||||
<th className="text-center px-1 py-2 w-[120px]">마감</th>
|
||||
<th className="text-center px-1 py-2 w-[112px]">수량</th>
|
||||
</tr>
|
||||
@@ -841,15 +807,11 @@ function ListView({ items, cart, unlimitedQty, onAdd, onPlus, onMinus, onSetQty,
|
||||
{items.map((it) => {
|
||||
const cartLine = cart.find((x) => x.item.OBJID === it.OBJID);
|
||||
const inCart = cartLine?.qty ?? 0;
|
||||
const stock = Number(it.STOCK_QTY);
|
||||
const maxQ = Number(it.MAX_ORDER_QTY ?? 0);
|
||||
const isDelivery = it.REQUIRES_DELIVERY === "Y";
|
||||
const effStock = isDelivery ? Number.MAX_SAFE_INTEGER : stock;
|
||||
const limit = unlimitedQty || maxQ <= 0 ? effStock : Math.min(effStock, maxQ);
|
||||
const limit = unlimitedQty || maxQ <= 0 ? Number.MAX_SAFE_INTEGER : maxQ;
|
||||
const closed = isSaleClosed(it.SALE_END_DATE);
|
||||
const soldOut = !isDelivery && stock <= 0;
|
||||
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">
|
||||
<div className="font-semibold truncate text-[12px]">
|
||||
{it.ITEM_NAME}
|
||||
@@ -863,15 +825,12 @@ function ListView({ items, cart, unlimitedQty, onAdd, onPlus, onMinus, onSetQty,
|
||||
</span>
|
||||
</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"}`}>
|
||||
{it.SALE_END_DATE ? <>{it.SALE_END_DATE}{closed ? " (종료)" : ""}</> : <span className="text-slate-300">상시</span>}
|
||||
</td>
|
||||
<td className="px-1 py-2">
|
||||
{closed ? (
|
||||
<div className="text-center text-[10px] text-slate-400">판매 마감</div>
|
||||
) : soldOut ? (
|
||||
<div className="text-center text-[10px] text-slate-400">품절</div>
|
||||
) : inCart === 0 ? (
|
||||
<div className="flex gap-0.5 justify-end">
|
||||
<input
|
||||
|
||||
@@ -482,14 +482,15 @@ function ItemPickerModal({ onClose, onConfirm }: {
|
||||
onClose: () => 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 [cart, setCart] = useState<Record<string, number>>({});
|
||||
|
||||
useEffect(() => {
|
||||
// 모든 판매 가능 품목 표시 — 재고 무관 출고요청 가능
|
||||
fetch("/api/m/items/list", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ stockFilter: "AVAILABLE" }),
|
||||
body: JSON.stringify({ forSale: true }),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((j) => setItems(j.RESULTLIST ?? []))
|
||||
@@ -526,27 +527,25 @@ function ItemPickerModal({ onClose, onConfirm }: {
|
||||
<thead className="bg-slate-50 text-slate-600 sticky top-0">
|
||||
<tr>
|
||||
<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-center p-2 w-20">수량</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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) => (
|
||||
<tr key={it.OBJID} className="border-t border-slate-100 hover:bg-slate-50">
|
||||
<td className="p-2">
|
||||
<div className="font-semibold">{it.ITEM_NAME}</div>
|
||||
<div className="text-[10px] text-slate-400">{it.ITEM_CODE} · {it.IS_TAX_FREE === "Y" ? "면세" : "과세"}</div>
|
||||
</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-center">
|
||||
<input type="number" min={0} max={Number(it.STOCK_QTY)}
|
||||
<input type="number" min={0}
|
||||
value={cart[it.OBJID] ?? 0}
|
||||
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 }));
|
||||
}}
|
||||
className="w-16 h-7 px-1 border border-slate-200 rounded text-right tabular-nums" />
|
||||
|
||||
@@ -103,13 +103,9 @@ export async function POST(req: NextRequest) {
|
||||
conditions.push(`I.vendor_objid = $${i++}`);
|
||||
params.push(vendorObjid);
|
||||
}
|
||||
if (onlyAvailable) {
|
||||
// 택배 필수 품목(requires_delivery='Y') 은 재고 무관 노출.
|
||||
conditions.push(
|
||||
`(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)`
|
||||
);
|
||||
}
|
||||
// onlyAvailable: 더 이상 재고로 필터하지 않음 — 모든 품목은 재고 무관 출고요청 가능.
|
||||
// (기존 화면 호환을 위해 옵션은 유지하되 동작은 no-op. 노출/숨김은 sale_start/end + is_hidden 으로만 결정.)
|
||||
void onlyAvailable;
|
||||
// 출고요청(orders/new) 메뉴: 판매 가능 기간(sale_start_date ~ sale_end_date) 안의 품목만.
|
||||
// 기간이 NULL 인 품목은 상시 노출. USER 항상 적용, ADMIN 도 forSale=true 이면 적용.
|
||||
//
|
||||
|
||||
@@ -113,6 +113,7 @@ export async function POST(req: NextRequest) {
|
||||
);
|
||||
const unlimited = u.rows[0]?.u === "Y";
|
||||
const viewHidden = u.rows[0]?.v === "Y";
|
||||
// 재고 체크는 하지 않음 — 모든 품목 재고 무관 추가 가능. 숨김/한도만 검증.
|
||||
for (const ln of items) {
|
||||
const it = itemMap.get(ln.itemObjid);
|
||||
if (!it) {
|
||||
@@ -123,13 +124,6 @@ export async function POST(req: NextRequest) {
|
||||
await client.query("ROLLBACK");
|
||||
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) {
|
||||
const maxQ = it.max_order_qty == null ? 0 : Number(it.max_order_qty);
|
||||
if (maxQ > 0 && Number(ln.qty) > maxQ) {
|
||||
|
||||
@@ -122,15 +122,8 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ success: false, message: "수량은 1 이상이어야 합니다." }, { status: 400 });
|
||||
}
|
||||
|
||||
// 재고 / 한도 검증 (관리자가 아니고 unlimited_qty 권한 없으면)
|
||||
// 택배 전용 품목은 재고 무관하게 발주 가능
|
||||
const isDeliveryItem = cur.requires_delivery === "Y";
|
||||
// 재고 체크는 하지 않음 — 모든 품목 재고 무관 수정 가능. 1회 발주 한도만 검증.
|
||||
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(
|
||||
`SELECT COALESCE(unlimited_qty,'N') AS u FROM user_info WHERE user_id = $1`,
|
||||
[r.user.objid]
|
||||
|
||||
@@ -127,11 +127,11 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ success: false, message: `존재하지 않는 품목입니다: ${missing.itemObjid}` }, { status: 400 });
|
||||
}
|
||||
|
||||
// 수량/숨김/택배/판매기간 검증
|
||||
// 수량/숨김/판매기간 검증 (재고 체크는 하지 않음 — 모든 품목은 재고 무관 출고요청 가능.
|
||||
// 부족분은 approve 시 음수로 떨어진 뒤 매입/입고 담당자가 음수 재고를 보고 발주한다.)
|
||||
let needsDelivery = false;
|
||||
for (const ln of lines) {
|
||||
const it = itemMap.get(ln.itemObjid)!;
|
||||
const stock = Number(it.stock_qty ?? 0);
|
||||
// 판매기간(마감) 재체크 — 목록에 떠 있을 때 담아두고 마감 시각이 지난 뒤 전송하는 경우 차단.
|
||||
// (화면 노출 필터와 별개로 서버에서 KST 기준 한 번 더 확정)
|
||||
if (it.on_sale === false) {
|
||||
@@ -141,18 +141,6 @@ export async function POST(req: NextRequest) {
|
||||
message: `${it.item_name}${endTxt} — 판매가 마감된 품목입니다. 출고 요청할 수 없습니다.`,
|
||||
}, { 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) {
|
||||
const maxQ = it.max_order_qty == null ? 0 : Number(it.max_order_qty);
|
||||
if (maxQ > 0 && Number(ln.qty) > maxQ) {
|
||||
|
||||
Reference in New Issue
Block a user