diff --git a/src/app/(main)/m/admin/items/page.tsx b/src/app/(main)/m/admin/items/page.tsx index 264dbae..c9d1863 100644 --- a/src/app/(main)/m/admin/items/page.tsx +++ b/src/app/(main)/m/admin/items/page.tsx @@ -19,6 +19,7 @@ interface Item { STOCK_QTY: number; ATTRIBUTES: Record | 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" /> + + { + 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" + /> +

+ 모든 거래처 출고요청+출고완료 합산이 이 수량을 넘으면 추가 요청 차단.
+ 월요일 마감: 저번주 금~월 마감 시각, 화요일 마감: 저번주 금~화 마감 시각. +

+
{it.ITEM_NAME}
@@ -695,8 +732,16 @@ function ItemsBrowse() { ⏰ {it.SALE_END_DATE} 마감{closed ? " (종료)" : ""} )} + {(() => { + const remain = cycleRemainOf(it); + return remain != null ? ( +
+ 한정 잔여 {fmt(remain)} / {fmt(Number(it.LIMIT_QTY ?? 0))} +
+ ) : null; + })()} {maxQ > 0 && !unlimitedQty && ( -
한도 ≤ {fmt(maxQ)}
+
1회 한도 ≤ {fmt(maxQ)}
)} {/* 수량 컨트롤 — 카드 안에서 바로 조절 */} @@ -704,6 +749,10 @@ function ItemsBrowse() {
판매 마감
+ ) : limit <= 0 ? ( +
+ 한정 수량 소진 +
) : inCart === 0 ? (
품목 구분 단가 - 마감 + 마감 / 출고 수량 @@ -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 ( 0 ? "bg-emerald-50/40" : "hover:bg-slate-50"}`}> @@ -817,6 +872,11 @@ function ListView({ items, cart, unlimitedQty, onAdd, onPlus, onMinus, onSetQty, {it.ITEM_NAME} {it.REQUIRES_DELIVERY === "Y" && 택배}
+ {lim > 0 && ( +
+ 한정 잔여 {fmt(cycleRemain)} / {fmt(lim)} +
+ )} {inCart > 0 &&
담은 {inCart}
} @@ -827,10 +887,15 @@ function ListView({ items, cart, unlimitedQty, onAdd, onPlus, onMinus, onSetQty, ₩{Number(it.UNIT_PRICE).toLocaleString("ko-KR")} {it.SALE_END_DATE ? <>{it.SALE_END_DATE}{closed ? " (종료)" : ""} : 상시} + {delivLbl && !closed && ( +
{delivLbl}
+ )} {closed ? (
판매 마감
+ ) : limit <= 0 ? ( +
한정 소진
) : inCart === 0 ? (
= ( + 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", diff --git a/src/app/api/m/items/save/route.ts b/src/app/api/m/items/save/route.ts index 9cd20a4..c332c6c 100644 --- a/src/app/api/m/items/save/route.ts +++ b/src/app/api/m/items/save/route.ts @@ -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] ); diff --git a/src/app/api/m/orders/items/add/route.ts b/src/app/api/m/orders/items/add/route.ts index 7fd4de6..3213b14 100644 --- a/src/app/api/m/orders/items/add/route.ts +++ b/src/app/api/m/orders/items/add/route.ts @@ -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(); + for (const ln of items) { + sameReqQtyByItem.set(ln.itemObjid, + (sameReqQtyByItem.get(ln.itemObjid) ?? 0) + Number(ln.qty)); + } + const checked = new Set(); + 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) { // 사용자별 권한 diff --git a/src/app/api/m/orders/items/update/route.ts b/src/app/api/m/orders/items/update/route.ts index 11d0771..dc4e7ae 100644 --- a/src/app/api/m/orders/items/update/route.ts +++ b/src/app/api/m/orders/items/update/route.ts @@ -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 }); diff --git a/src/app/api/m/orders/save/route.ts b/src/app/api/m/orders/save/route.ts index 2a02740..0a1312b 100644 --- a/src/app/api/m/orders/save/route.ts +++ b/src/app/api/m/orders/save/route.ts @@ -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(); + 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(); + 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"); diff --git a/src/lib/momo-cycle.ts b/src/lib/momo-cycle.ts new file mode 100644 index 0000000..2178082 --- /dev/null +++ b/src/lib/momo-cycle.ts @@ -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 { + 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); +}