feat(items): 한정 수량(limit_qty) + 출고일 배지 — 마감 사이클 단위 누적 상한

신규 컬럼: momo_items.limit_qty INTEGER (null/0 = 제한 없음)
  · ensureColumns 에서 ADD COLUMN IF NOT EXISTS 자동 보장
  · 관리자 품목 폼: '한정 수량 (이번 마감 사이클 누적 상한)' 필드 추가

마감 사이클 정의 (sale_end_date 요일 기준):
  · 월요일 마감(DOW=1): 저번주 금~월 마감 시각 (마감일 -3일 00:00 ~ 마감일)
  · 화요일 마감(DOW=2): 저번주 금~화 마감 시각 (마감일 -4일 00:00 ~ 마감일)
  · 그 외: sale_start_date ~ sale_end_date (fallback)

누적 합산 대상: 같은 사이클 안에 등록된 momo_order_items.qty
  · status: REQUESTED/APPROVED/PAID/INVOICED (CANCELLED 제외)
  · kind='ITEM' (택배/용차/환불 제외)

검증 (모든 사용자/관리자/unlimited_qty 권한 무관 적용):
  · orders/save: 이번 요청 합 + reserved <= limit_qty 체크
  · orders/items/add: 동일 검증 (트랜잭션 client 사용)
  · orders/items/update: newQty - oldQty 차이만 비교 (수량 증가 시)

신규 파일:
  · src/lib/momo-cycle.ts — getReservedQty(itemObjid, client?) 헬퍼

사용자 출고요청 화면(/m/orders/new):
  · 카드/리스트에 RESERVED_QTY 받아 '한정 잔여 N / 한정 M' 표시
  · 한정 소진 시 '한정 수량 소진' 배지 + 담기 버튼 비활성
  · 이미지 위 출고일 배지: 월요일 마감 → '수요일 출고', 화요일 마감 → '금요일 출고'
  · limit_qty 가 없는 품목은 무한대 (기존 동작 유지)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chpark
2026-06-01 00:17:20 +09:00
parent 77f2ef2cd5
commit 4f2543686a
8 changed files with 263 additions and 20 deletions
+19 -1
View File
@@ -19,6 +19,7 @@ interface Item {
STOCK_QTY: number;
ATTRIBUTES: Record<string, unknown> | null;
MAX_ORDER_QTY: number | null;
LIMIT_QTY: number | null;
IS_HIDDEN: string;
REQUIRES_DELIVERY: string;
VENDOR_OBJID?: string;
@@ -102,7 +103,7 @@ export default function AdminItemsPage() {
};
const openNew = () => {
setEditing({ ITEM_NAME: "", UNIT: "EA", IS_TAX_FREE: "N", STATUS: "ACTIVE", IS_HIDDEN: "N", MAX_ORDER_QTY: null, REQUIRES_DELIVERY: "N" });
setEditing({ ITEM_NAME: "", UNIT: "EA", IS_TAX_FREE: "N", STATUS: "ACTIVE", IS_HIDDEN: "N", MAX_ORDER_QTY: null, LIMIT_QTY: null, REQUIRES_DELIVERY: "N" });
setAttrs({});
};
@@ -123,6 +124,7 @@ export default function AdminItemsPage() {
status: editing.STATUS || "ACTIVE",
attributes: Object.keys(attrs).length > 0 ? attrs : null,
maxOrderQty: editing.MAX_ORDER_QTY ?? null,
limitQty: editing.LIMIT_QTY ?? null,
isHidden: editing.IS_HIDDEN === "Y" ? "Y" : "N",
requiresDelivery: editing.REQUIRES_DELIVERY === "Y" ? "Y" : "N",
vendorObjid: editing.VENDOR_OBJID || null,
@@ -471,6 +473,22 @@ export default function AdminItemsPage() {
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none"
/>
</Field>
<Field label="한정 수량 (이번 마감 사이클 누적 상한)">
<input
type="number" min={0}
value={editing.LIMIT_QTY ?? ""}
onChange={(e) => {
const v = e.target.value;
setEditing({ ...editing, LIMIT_QTY: v === "" ? null : Number(v) });
}}
placeholder="공란 = 제한 없음"
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none"
/>
<p className="text-[10px] text-slate-400 mt-1 leading-tight">
+ .<br/>
마감: 저번주 ~ , 마감: 저번주 ~ .
</p>
</Field>
<Field label="숨김 처리">
<div className="flex gap-2 h-10">
<label className="flex-1 inline-flex items-center justify-center rounded-lg border cursor-pointer text-sm font-semibold hover:bg-slate-50">
+73 -8
View File
@@ -17,11 +17,28 @@ interface Item {
IMAGE_URL: string;
STOCK_QTY: number;
MAX_ORDER_QTY: number | null;
LIMIT_QTY: number | null;
RESERVED_QTY: number;
IS_HIDDEN: string;
REQUIRES_DELIVERY: string;
SALE_START_DATE?: string | null;
SALE_END_DATE?: string | null;
}
// 마감 요일 기준 출고일 라벨.
// · 월요일(1) 마감 → "수요일 출고"
// · 화요일(2) 마감 → "금요일 출고"
// · 그 외 요일 → null
function deliveryLabel(saleEnd?: string | null): string | null {
if (!saleEnd) return null;
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(saleEnd);
if (!m) return null;
const [, y, mo, d] = m;
const dow = new Date(+y, +mo - 1, +d).getDay(); // 0=일, 1=월, 2=화
if (dow === 1) return "수요일 출고";
if (dow === 2) return "금요일 출고";
return null;
}
interface CartLine { item: Item; qty: number }
interface ExtraLine { id: string; kind: "DELIVERY" | "CHARTER"; unitPrice: number; qty: number; label: string }
@@ -184,11 +201,23 @@ function ItemsBrowse() {
const addToCart = (item: Item) => addManyToCart(item, 1);
// 출고요청 한도 계산 — 재고는 더 이상 제한이 아님(전 품목 재고 무관 출고).
// 1회 발주 한도(MAX_ORDER_QTY) 만 적용. unlimitedQty 권한이면 무제한.
// 출고요청 한도 계산:
// · MAX_ORDER_QTY (1회 발주 제한) — unlimitedQty 권한자는 우회
// · LIMIT_QTY - RESERVED_QTY (이번 마감 사이클 잔여) — 모든 사용자 적용
// 둘 중 더 작은 값이 실제 가능 수량.
const limitOf = (item: Item) => {
const maxQ = Number(item.MAX_ORDER_QTY ?? 0);
return unlimitedQty || maxQ <= 0 ? Number.MAX_SAFE_INTEGER : maxQ;
const oneOrderLimit = unlimitedQty || maxQ <= 0 ? Number.MAX_SAFE_INTEGER : maxQ;
const lim = Number(item.LIMIT_QTY ?? 0);
const cycleRemain = lim > 0
? Math.max(0, lim - Number(item.RESERVED_QTY ?? 0))
: Number.MAX_SAFE_INTEGER;
return Math.min(oneOrderLimit, cycleRemain);
};
const cycleRemainOf = (item: Item) => {
const lim = Number(item.LIMIT_QTY ?? 0);
if (lim <= 0) return null;
return Math.max(0, lim - Number(item.RESERVED_QTY ?? 0));
};
const addManyToCart = (item: Item, qty: number) => {
@@ -253,8 +282,10 @@ function ItemsBrowse() {
const toastLimit = (limit: number) => {
Swal.fire({
icon: "warning",
title: "1회 발주 한도 초과",
text: `1회 최대 ${fmt(limit)}개까지 발주 가능합니다.`,
title: limit <= 0 ? "한정 수량 소진" : "발주 한도 초과",
text: limit <= 0
? `이번 마감 사이클에 더 이상 출고 요청할 수 없습니다.`
: `이번 마감 사이클에 최대 ${fmt(limit)}개까지 발주 가능합니다.`,
confirmButtonColor: "#0f766e",
confirmButtonText: "확인",
});
@@ -683,6 +714,12 @@ function ItemsBrowse() {
{it.REQUIRES_DELIVERY === "Y" && (
<span className="px-1 py-0.5 rounded bg-orange-500/90 text-white text-[9px] font-bold"></span>
)}
{(() => {
const lbl = deliveryLabel(it.SALE_END_DATE);
return lbl ? (
<span className="px-1 py-0.5 rounded bg-emerald-600/90 text-white text-[9px] font-bold">{lbl}</span>
) : null;
})()}
</div>
</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>
@@ -695,8 +732,16 @@ function ItemsBrowse() {
{it.SALE_END_DATE} {closed ? " (종료)" : ""}
</div>
)}
{(() => {
const remain = cycleRemainOf(it);
return remain != null ? (
<div className={`text-[10px] font-bold mb-0.5 tabular-nums ${remain <= 0 ? "text-rose-600" : "text-amber-700"}`}>
{fmt(remain)} / {fmt(Number(it.LIMIT_QTY ?? 0))}
</div>
) : null;
})()}
{maxQ > 0 && !unlimitedQty && (
<div className="text-[9px] text-sky-700 mb-0.5"> {fmt(maxQ)}</div>
<div className="text-[9px] text-sky-700 mb-0.5">1 {fmt(maxQ)}</div>
)}
{/* 수량 컨트롤 — 카드 안에서 바로 조절 */}
@@ -704,6 +749,10 @@ 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>
) : limit <= 0 ? (
<div className="w-full mt-1 h-7 rounded bg-rose-50 text-rose-600 text-[11px] font-bold flex items-center justify-center">
</div>
) : inCart === 0 ? (
<div className="flex gap-1 mt-1">
<input
@@ -799,7 +848,7 @@ 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-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>
</tr>
</thead>
@@ -808,8 +857,14 @@ function ListView({ items, cart, unlimitedQty, onAdd, onPlus, onMinus, onSetQty,
const cartLine = cart.find((x) => x.item.OBJID === it.OBJID);
const inCart = cartLine?.qty ?? 0;
const maxQ = Number(it.MAX_ORDER_QTY ?? 0);
const limit = unlimitedQty || maxQ <= 0 ? Number.MAX_SAFE_INTEGER : maxQ;
const oneOrderLimit = unlimitedQty || maxQ <= 0 ? Number.MAX_SAFE_INTEGER : maxQ;
const lim = Number(it.LIMIT_QTY ?? 0);
const cycleRemain = lim > 0
? Math.max(0, lim - Number(it.RESERVED_QTY ?? 0))
: Number.MAX_SAFE_INTEGER;
const limit = Math.min(oneOrderLimit, cycleRemain);
const closed = isSaleClosed(it.SALE_END_DATE);
const delivLbl = deliveryLabel(it.SALE_END_DATE);
return (
<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">
@@ -817,6 +872,11 @@ function ListView({ items, cart, unlimitedQty, onAdd, onPlus, onMinus, onSetQty,
{it.ITEM_NAME}
{it.REQUIRES_DELIVERY === "Y" && <span className="ml-1 px-1 py-0.5 rounded bg-orange-100 text-orange-700 text-[9px] font-bold"></span>}
</div>
{lim > 0 && (
<div className={`text-[10px] font-bold tabular-nums ${cycleRemain <= 0 ? "text-rose-600" : "text-amber-700"}`}>
{fmt(cycleRemain)} / {fmt(lim)}
</div>
)}
{inCart > 0 && <div className="text-[10px] text-emerald-700 font-bold"> {inCart}</div>}
</td>
<td className="px-1 py-2 text-center">
@@ -827,10 +887,15 @@ function ListView({ items, cart, unlimitedQty, onAdd, onPlus, onMinus, onSetQty,
<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-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>}
{delivLbl && !closed && (
<div className="text-[9px] text-emerald-700 font-bold mt-0.5">{delivLbl}</div>
)}
</td>
<td className="px-1 py-2">
{closed ? (
<div className="text-center text-[10px] text-slate-400"> </div>
) : limit <= 0 ? (
<div className="text-center text-[10px] text-rose-600 font-bold"> </div>
) : inCart === 0 ? (
<div className="flex gap-0.5 justify-end">
<input
+34
View File
@@ -12,6 +12,7 @@ async function ensureColumns() {
ALTER TABLE momo_items
ADD COLUMN IF NOT EXISTS vendor_objid TEXT,
ADD COLUMN IF NOT EXISTS max_order_qty INTEGER,
ADD COLUMN IF NOT EXISTS limit_qty INTEGER,
ADD COLUMN IF NOT EXISTS is_hidden CHAR(1) DEFAULT 'N',
ADD COLUMN IF NOT EXISTS requires_delivery CHAR(1) DEFAULT 'N',
ADD COLUMN IF NOT EXISTS sale_start_date TIMESTAMP,
@@ -168,6 +169,39 @@ export async function POST(req: NextRequest) {
I.status AS "STATUS",
I.attributes AS "ATTRIBUTES",
I.max_order_qty AS "MAX_ORDER_QTY",
I.limit_qty AS "LIMIT_QTY",
-- 이번 마감 사이클 동안 거래처들이 출고요청한 누적 수량 (한정 수량 검증용).
-- 취소(CANCELLED) 제외, 출고요청/출고완료/입금완료/계산서발행 모두 포함.
-- 사이클 정의 (마감 요일 기준, PG DOW: 0=일, 1=월, 2=화):
-- · 월요일 마감(1): 저번주 금~이번주 월요일 마감 시각 (-3일 00:00 ~ 마감일)
-- · 화요일 마감(2): 저번주 금~이번주 화요일 마감 시각 (-4일 00:00 ~ 마감일)
-- · 그 외: sale_start_date ~ sale_end_date (fallback)
-- sale_end_date 없으면 0 (한정 의미 없음).
COALESCE((
SELECT SUM(OI.qty)
FROM momo_order_items OI
JOIN momo_orders O ON O.objid = OI.order_objid
WHERE OI.item_objid = I.objid
AND COALESCE(OI.kind, 'ITEM') = 'ITEM'
AND COALESCE(O.is_del, 'N') != 'Y'
AND O.status IN ('REQUESTED','APPROVED','PAID','INVOICED')
AND I.sale_end_date IS NOT NULL
AND O.regdate >= (
CASE EXTRACT(DOW FROM I.sale_end_date)::int
WHEN 1 THEN date_trunc('day', I.sale_end_date) - INTERVAL '3 days'
WHEN 2 THEN date_trunc('day', I.sale_end_date) - INTERVAL '4 days'
ELSE COALESCE(I.sale_start_date,
date_trunc('day', I.sale_end_date) - INTERVAL '7 days')
END
)
AND O.regdate <= (
CASE
WHEN I.sale_end_date = date_trunc('day', I.sale_end_date)
THEN I.sale_end_date + INTERVAL '1 day' - INTERVAL '1 second'
ELSE I.sale_end_date
END
)
), 0) AS "RESERVED_QTY",
COALESCE(I.is_hidden, 'N') AS "IS_HIDDEN",
COALESCE(I.requires_delivery, 'N') AS "REQUIRES_DELIVERY",
TO_CHAR(I.sale_start_date, 'YYYY-MM-DD HH24:MI') AS "SALE_START_DATE",
+11 -9
View File
@@ -109,6 +109,7 @@ export async function POST(req: NextRequest) {
attributes,
status,
maxOrderQty,
limitQty,
isHidden,
requiresDelivery,
vendorObjid,
@@ -117,6 +118,7 @@ export async function POST(req: NextRequest) {
isAlwaysSale,
} = body;
const maxQty = maxOrderQty == null || maxOrderQty === "" ? null : Number(maxOrderQty);
const limQty = limitQty == null || limitQty === "" ? null : Number(limitQty);
const hidden = isHidden === "Y" ? "Y" : "N";
const reqDelivery = requiresDelivery === "Y" ? "Y" : "N";
// 상시 판매 체크 시 날짜는 강제로 비움 (상시 == 항상 노출).
@@ -143,16 +145,16 @@ export async function POST(req: NextRequest) {
`INSERT INTO momo_items (
objid, item_code, item_name, item_detail, maker_objid, vendor_objid,
unit, unit_price, cost_price, is_tax_free, image_url, attributes, status,
max_order_qty, is_hidden, requires_delivery,
max_order_qty, limit_qty, is_hidden, requires_delivery,
sale_start_date, sale_end_date, is_always_sale,
is_del, regdate, regid
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::jsonb,$13,$14,$15,$16,$18::timestamp,$19::timestamp,$20,'N',NOW(),$17)`,
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::jsonb,$13,$14,$15,$16,$17,$19::timestamp,$20::timestamp,$21,'N',NOW(),$18)`,
[newId, itemCode, cleanName, itemDetail ?? null, makerObjid ?? null, vendorObjid ?? null,
unit ?? "EA", Number(unitPrice ?? 0), Number(costPrice ?? 0),
taxFree, imageUrl ?? null,
attributes ? JSON.stringify(attributes) : null,
status ?? "ACTIVE",
maxQty, hidden, reqDelivery,
maxQty, limQty, hidden, reqDelivery,
userId, saleStart, saleEnd, alwaysSale]
);
// 신규 등록 품목에 판매 일정(현재/미래)이 잡혀 있으면 일반 사용자에게 알림
@@ -169,18 +171,18 @@ export async function POST(req: NextRequest) {
item_name=$2, item_detail=$3, maker_objid=$4, unit=$5,
unit_price=$6, cost_price=$7, is_tax_free=$8, image_url=$9,
attributes=$10::jsonb, status=$11,
max_order_qty=$12, is_hidden=$13, requires_delivery=$14,
vendor_objid=$15,
sale_start_date=$17::timestamp, sale_end_date=$18::timestamp,
is_always_sale=$19,
update_date=NOW(), update_id=$16
max_order_qty=$12, limit_qty=$13, is_hidden=$14, requires_delivery=$15,
vendor_objid=$16,
sale_start_date=$18::timestamp, sale_end_date=$19::timestamp,
is_always_sale=$20,
update_date=NOW(), update_id=$17
WHERE objid=$1`,
[objid, cleanName, itemDetail ?? null, makerObjid ?? null, unit ?? "EA",
Number(unitPrice ?? 0), Number(costPrice ?? 0),
taxFree, imageUrl ?? null,
attributes ? JSON.stringify(attributes) : null,
status ?? "ACTIVE",
maxQty, hidden, reqDelivery,
maxQty, limQty, hidden, reqDelivery,
vendorObjid ?? null,
userId, saleStart, saleEnd, alwaysSale]
);
+29
View File
@@ -5,6 +5,7 @@ import { pool } from "@/lib/db";
import { createObjectId } from "@/lib/utils";
import { requireMomoUser } from "@/lib/momo-guard";
import { calcLine } from "@/lib/momo-pricing";
import { getReservedQty } from "@/lib/momo-cycle";
let lockColsEnsured = false;
async function ensureLockCols() {
@@ -91,6 +92,7 @@ export async function POST(req: NextRequest) {
const itemsRes = await client.query(
`SELECT
I.objid, I.item_name, I.unit_price, I.is_tax_free, I.max_order_qty,
I.limit_qty,
COALESCE(I.is_hidden, 'N') AS is_hidden,
COALESCE(I.requires_delivery, 'N') AS requires_delivery,
COALESCE((
@@ -104,6 +106,33 @@ export async function POST(req: NextRequest) {
);
const itemMap = new Map(itemsRes.rows.map((row) => [row.objid as string, row]));
// 한정 수량 검증 — admin/일반 모두 적용 (전체 사이클 상한이므로 누구도 우회 불가)
// 같은 요청에 동일 품목이 여러 라인이면 합산
const sameReqQtyByItem = new Map<string, number>();
for (const ln of items) {
sameReqQtyByItem.set(ln.itemObjid,
(sameReqQtyByItem.get(ln.itemObjid) ?? 0) + Number(ln.qty));
}
const checked = new Set<string>();
for (const ln of items) {
if (checked.has(ln.itemObjid)) continue;
checked.add(ln.itemObjid);
const it = itemMap.get(ln.itemObjid);
if (!it) continue;
const limit = it.limit_qty == null ? 0 : Number(it.limit_qty);
if (limit <= 0) continue;
const reserved = await getReservedQty(ln.itemObjid, client);
const thisReq = sameReqQtyByItem.get(ln.itemObjid) ?? 0;
if (reserved + thisReq > limit) {
const remain = Math.max(0, limit - reserved);
await client.query("ROLLBACK");
return NextResponse.json({
success: false,
message: `${it.item_name} — 한정 ${limit} (이미 ${reserved}, 남은 ${remain}). 요청 ${thisReq} 불가.`,
}, { status: 400 });
}
}
// 검증 (admin 은 재고/한도 우회)
if (!isAdmin) {
// 사용자별 권한
+20 -1
View File
@@ -5,6 +5,7 @@ import { NextRequest, NextResponse } from "next/server";
import { pool } from "@/lib/db";
import { requireMomoUser } from "@/lib/momo-guard";
import { calcLine } from "@/lib/momo-pricing";
import { getReservedQty } from "@/lib/momo-cycle";
let lockColsEnsured = false;
async function ensureLockCols() {
@@ -92,7 +93,7 @@ export async function POST(req: NextRequest) {
const lineRes = await client.query(
`SELECT OI.objid, OI.item_objid, OI.unit_price, OI.qty, OI.is_tax_free,
COALESCE(OI.kind,'ITEM') AS kind,
I.item_name, I.max_order_qty,
I.item_name, I.max_order_qty, I.limit_qty,
COALESCE(I.requires_delivery,'N') AS requires_delivery,
COALESCE((
SELECT SUM(S.qty) FROM momo_stocks S
@@ -137,6 +138,24 @@ export async function POST(req: NextRequest) {
}
}
}
// 한정 수량 검증 — 수량 증가분(newQty - oldQty)만 사이클 누적에 더해서 비교.
// getReservedQty 는 현재 라인의 oldQty 도 포함하므로 reserved + (newQty - oldQty) 비교.
// (admin/일반/unlimited_qty 모두 적용 — 전체 사이클 상한)
{
const limit = cur.limit_qty == null ? 0 : Number(cur.limit_qty);
if (limit > 0 && newQty > Number(cur.qty)) {
const reserved = await getReservedQty(cur.item_objid, client);
const delta = newQty - Number(cur.qty);
if (reserved + delta > limit) {
const remain = Math.max(0, limit - reserved);
await client.query("ROLLBACK");
return NextResponse.json({
success: false,
message: `${cur.item_name} — 한정 ${limit} (이미 ${reserved}, 남은 ${remain}). 증가 +${delta} 불가.`,
}, { status: 400 });
}
}
}
const isFree = cur.is_tax_free === "Y";
const calc = calcLine({ unitPrice: Number(cur.unit_price), qty: newQty, isTaxFree: isFree });
+28 -1
View File
@@ -6,6 +6,7 @@ import { createObjectId } from "@/lib/utils";
import { requireMomoUser } from "@/lib/momo-guard";
import { calcLine, sumTotals } from "@/lib/momo-pricing";
import { getSupplierByBranch } from "@/lib/momo-branches";
import { getReservedQty } from "@/lib/momo-cycle";
interface InputItemLine {
itemObjid: string;
@@ -91,6 +92,7 @@ export async function POST(req: NextRequest) {
`SELECT
I.objid, I.item_name, I.unit_price, I.is_tax_free,
I.max_order_qty,
I.limit_qty,
COALESCE(I.is_hidden, 'N') AS is_hidden,
COALESCE(I.requires_delivery, 'N') AS requires_delivery,
TO_CHAR(I.sale_end_date, 'YYYY-MM-DD HH24:MI') AS sale_end_txt,
@@ -127,9 +129,15 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ success: false, message: `존재하지 않는 품목입니다: ${missing.itemObjid}` }, { status: 400 });
}
// 수량/숨김/판매기간 검증 (재고 체크는 하지 않음 — 모든 품목은 재고 무관 출고요청 가능.
// 수량/숨김/판매기간/한정 수량 검증 (재고 체크는 하지 않음 — 모든 품목은 재고 무관 출고요청 가능.
// 부족분은 approve 시 음수로 떨어진 뒤 매입/입고 담당자가 음수 재고를 보고 발주한다.)
let needsDelivery = false;
// 같은 요청 안에 동일 품목이 여러 라인으로 들어와도 한정 수량 검증은 합산 기준
const sameReqQtyByItem = new Map<string, number>();
for (const ln of lines) {
sameReqQtyByItem.set(ln.itemObjid,
(sameReqQtyByItem.get(ln.itemObjid) ?? 0) + Number(ln.qty));
}
for (const ln of lines) {
const it = itemMap.get(ln.itemObjid)!;
// 판매기간(마감) 재체크 — 목록에 떠 있을 때 담아두고 마감 시각이 지난 뒤 전송하는 경우 차단.
@@ -159,6 +167,25 @@ export async function POST(req: NextRequest) {
}
if (it.requires_delivery === "Y") needsDelivery = true;
}
// 한정 수량 검증 — 품목별로 한 번씩, 사이클 누적합 + 이번 요청 합 ≤ limit_qty
// (unlimitedQty 권한 거래처도 한정 수량은 우회 불가 — 전체 사이클 상한이므로)
const checkedItems = new Set<string>();
for (const ln of lines) {
if (checkedItems.has(ln.itemObjid)) continue;
checkedItems.add(ln.itemObjid);
const it = itemMap.get(ln.itemObjid)!;
const limit = it.limit_qty == null ? 0 : Number(it.limit_qty);
if (limit <= 0) continue;
const reserved = await getReservedQty(ln.itemObjid);
const thisReq = sameReqQtyByItem.get(ln.itemObjid) ?? 0;
if (reserved + thisReq > limit) {
const remain = Math.max(0, limit - reserved);
return NextResponse.json({
success: false,
message: `${it.item_name} — 한정 ${limit} (이미 ${reserved} 요청됨, 남은 ${remain}). 요청 ${thisReq} 불가.`,
}, { status: 400 });
}
}
// 택배 전용 품목이 있는데 택배 라인이 없으면 차단
const hasDeliveryLine = normExtras.some((e) => e.kind === "DELIVERY");
+49
View File
@@ -0,0 +1,49 @@
// 마감 사이클 기반 한정 수량(limit_qty) 누적 합산 계산.
//
// 사이클 정의 (sale_end_date 요일 기준, PG DOW: 0=일, 1=월, 2=화):
// · 월요일 마감(1): 저번주 금/토/일 + 이번주 월요일 마감 시각까지 (= 마감일 -3일 00:00 ~ 마감일)
// · 화요일 마감(2): 저번주 금/토/일 + 이번주 월/화요일 마감 시각까지 (= 마감일 -4일 00:00 ~ 마감일)
// · 그 외 요일: sale_start_date ~ sale_end_date (fallback)
//
// 마감 시각은 sale_end_date 의 시각 부분 그대로 사용 (관리자가 품목 마스터에서 분 단위로 지정).
// limit_qty 가 null 또는 0 이면 한정 의미 없음 — 호출자가 검증 자체를 skip 해야 한다.
//
// 누적 대상: 같은 품목의 같은 사이클 안에 들어온 momo_order_items.qty 합.
// - status: REQUESTED / APPROVED / PAID / INVOICED (CANCELLED 제외)
// - kind = 'ITEM' (택배/용차/환불 라인 제외)
// - momo_orders.is_del != 'Y'
import { PoolClient } from "pg";
import { pool } from "@/lib/db";
export async function getReservedQty(itemObjid: string, client?: PoolClient): Promise<number> {
const exec = client ?? pool;
const res = await exec.query<{ reserved: string }>(
`SELECT COALESCE(SUM(OI.qty), 0)::text AS reserved
FROM momo_order_items OI
JOIN momo_orders O ON O.objid = OI.order_objid
JOIN momo_items I ON I.objid = OI.item_objid
WHERE OI.item_objid = $1
AND COALESCE(OI.kind, 'ITEM') = 'ITEM'
AND COALESCE(O.is_del, 'N') != 'Y'
AND O.status IN ('REQUESTED','APPROVED','PAID','INVOICED')
AND I.sale_end_date IS NOT NULL
AND O.regdate >= (
CASE EXTRACT(DOW FROM I.sale_end_date)::int
WHEN 1 THEN date_trunc('day', I.sale_end_date) - INTERVAL '3 days'
WHEN 2 THEN date_trunc('day', I.sale_end_date) - INTERVAL '4 days'
ELSE COALESCE(I.sale_start_date,
date_trunc('day', I.sale_end_date) - INTERVAL '7 days')
END
)
AND O.regdate <= (
CASE
WHEN I.sale_end_date = date_trunc('day', I.sale_end_date)
THEN I.sale_end_date + INTERVAL '1 day' - INTERVAL '1 second'
ELSE I.sale_end_date
END
)`,
[itemObjid]
);
return Number(res.rows[0]?.reserved ?? 0);
}