feat(orders): USER 마감 후 수정 차단 + ADMIN 한정수량 우회

USER (거래처):
- 자신의 발주 라인에 포함된 품목의 sale_end_date 가 지나면 수정/삭제 차단
  · orders/items/update: lineRes 에 is_closed 계산해서 마감 후 라인은 ROLLBACK
  · orders/items/add: 추가하려는 품목 중 하나라도 마감이면 ROLLBACK
  · 마감 판정은 strict less than (마감 시각 정각부터 차단), 자정 정각은 그날 종일
- 사용자 주문 상세: 마감된 ITEM 라인은 수량/삭제 버튼 비활성 + '마감' 배지,
       마감 라인이 있을 때 상단에 안내 한 줄

ADMIN (출고 담당자):
- 한정 수량(limit_qty) 검증 우회 — orders/save / items/add / items/update 모두
  isAdmin 일 때 한정 검증 블록 skip
- 마감 후에도 라인 수정 가능 (USER 만 차단되는 is_closed 가드 조건에 !isAdmin 포함)
- 1회 발주 한도(max_order_qty) 우회는 이전부터 적용

API: orders/detail 응답 라인에 SALE_END_DATE / IS_CLOSED 추가 (사용자 화면 가드용)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chpark
2026-06-01 00:35:59 +09:00
parent 2afb5fecdd
commit adcc7e3b48
5 changed files with 121 additions and 45 deletions
+16 -2
View File
@@ -25,6 +25,8 @@ interface DetailLine {
UNIT_PRICE: number; QTY: number; IS_TAX_FREE: string;
SUPPLY_AMOUNT: number; VAT_AMOUNT: number; TOTAL_AMOUNT: number;
KIND: "ITEM" | "DELIVERY" | "CHARTER" | "REFUND"; EXTRA_LABEL?: string; REMARK?: string;
SALE_END_DATE?: string | null;
IS_CLOSED?: boolean;
}
interface Supplier {
NAME: string; CEO: string; BIZ_NO: string;
@@ -375,6 +377,11 @@ function DetailModal({ order, items, supplier, onClose, onCancel, onReload }: {
<p className="mt-3 text-[11px] text-amber-700 bg-amber-50 border border-amber-200 rounded p-2">
{order.STATUS === "REQUESTED" ? "출고요청" : "출고완료"} //, · . .
</p>
{items.some((it) => it.KIND === "ITEM" && it.IS_CLOSED) && (
<p className="mt-2 text-[11px] text-slate-600 bg-slate-100 border border-slate-200 rounded p-2">
🔒 / . .
</p>
)}
<div className="mt-2 flex flex-wrap gap-1.5 items-center js-no-export">
<button type="button" onClick={() => setPickerOpen(true)}
className="inline-flex items-center gap-1 h-7 px-2.5 rounded bg-emerald-100 text-emerald-800 text-[11px] font-bold hover:bg-emerald-200">
@@ -404,15 +411,22 @@ function DetailModal({ order, items, supplier, onClose, onCancel, onReload }: {
const isExtra = it.KIND === "DELIVERY" || it.KIND === "CHARTER";
const kindBadge = it.KIND === "DELIVERY" ? "택배" : it.KIND === "CHARTER" ? "용차" : null;
const kindBg = it.KIND === "DELIVERY" ? "bg-orange-50" : it.KIND === "CHARTER" ? "bg-sky-50" : "";
const canEditItem = editable && !isExtra;
// 거래처는 마감 시각 지나면 자기 라인도 수정/삭제 불가 (출고 담당자에게 문의)
const isClosed = it.IS_CLOSED === true;
const canEditItem = editable && !isExtra && !isClosed;
// 거래처는 택배/용차 라인 수정/삭제 불가 — 출고관리(/m/admin/orders)에서만 가능
const canEditExtra = false;
return (
<tr key={it.OBJID || idx} className={kindBg}>
<tr key={it.OBJID || idx} className={`${kindBg} ${isClosed && !isExtra ? "bg-slate-50/50" : ""}`}>
<td className="border border-slate-300 px-1.5 py-1 text-center">{idx + 1}</td>
<td className="border border-slate-300 px-1.5 py-1">
{kindBadge && <span className={`mr-1 text-[9px] font-bold px-1 py-0.5 rounded ${it.KIND === "DELIVERY" ? "bg-orange-200 text-orange-800" : "bg-sky-200 text-sky-800"}`}>{kindBadge}</span>}
{isExtra ? (it.EXTRA_LABEL || it.ITEM_NAME) : it.ITEM_NAME}
{isClosed && !isExtra && (
<span className="ml-1 text-[9px] px-1 py-0.5 rounded bg-slate-200 text-slate-600 font-bold" title={it.SALE_END_DATE ? `마감 ${it.SALE_END_DATE}` : "마감"}>
</span>
)}
</td>
<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" ? "면세" : "과세"}
+11
View File
@@ -87,6 +87,17 @@ export async function POST(req: NextRequest) {
OI.remark AS "REMARK",
I.unit AS "UNIT",
I.image_url AS "IMAGE_URL",
TO_CHAR(I.sale_end_date, 'YYYY-MM-DD HH24:MI') AS "SALE_END_DATE",
-- USER 의 라인 수정/삭제 차단 판정: 마감 시각 정각부터는 차단 (strict less than).
-- 자정 정각 입력은 '그날 종일 마감' 으로 해석.
(
I.sale_end_date IS NOT NULL
AND (NOW() AT TIME ZONE 'Asia/Seoul') >= CASE
WHEN I.sale_end_date = date_trunc('day', I.sale_end_date)
THEN I.sale_end_date + INTERVAL '1 day'
ELSE I.sale_end_date
END
) AS "IS_CLOSED",
-- 현재고: 거래처에 default_wh_objid 가 있으면 그 창고 재고, 없으면 STOCK 류 전체 합
-- 거래처 default 창고가 비어있을 수 있어, 항상 STOCK 류 전체 합산 표시.
-- (실제 출고는 approve 시 default 창고 또는 STOCK 첫 창고에서 차감)
+29 -1
View File
@@ -93,6 +93,16 @@ 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,
TO_CHAR(I.sale_end_date, 'YYYY-MM-DD HH24:MI') AS sale_end_txt,
-- USER 의 신규 추가 차단 판정: 마감 시각 정각부터는 차단 (strict less than).
(
I.sale_end_date IS NOT NULL
AND (NOW() AT TIME ZONE 'Asia/Seoul') >= CASE
WHEN I.sale_end_date = date_trunc('day', I.sale_end_date)
THEN I.sale_end_date + INTERVAL '1 day'
ELSE I.sale_end_date
END
) AS is_closed,
COALESCE(I.is_hidden, 'N') AS is_hidden,
COALESCE(I.requires_delivery, 'N') AS requires_delivery,
COALESCE((
@@ -106,8 +116,25 @@ export async function POST(req: NextRequest) {
);
const itemMap = new Map(itemsRes.rows.map((row) => [row.objid as string, row]));
// 한정 수량 검증 — admin/일반 모두 적용 (전체 사이클 상한이므로 누구도 우회 불가)
// USER: 추가하려는 품목 중 하나라도 마감 시각이 지났으면 차단. ADMIN 은 우회.
if (!isAdmin) {
for (const ln of items) {
const it = itemMap.get(ln.itemObjid);
if (!it) continue;
if (it.is_closed) {
const endTxt = it.sale_end_txt ? ` (마감 ${it.sale_end_txt})` : "";
await client.query("ROLLBACK");
return NextResponse.json({
success: false,
message: `${it.item_name}${endTxt} — 판매 마감 후에는 추가할 수 없습니다. 출고 담당자에게 문의하세요.`,
}, { status: 400 });
}
}
}
// 한정 수량 검증 — USER 만 적용. ADMIN 은 한도 우회 가능.
// 같은 요청에 동일 품목이 여러 라인이면 합산
if (!isAdmin) {
const sameReqQtyByItem = new Map<string, number>();
for (const ln of items) {
sameReqQtyByItem.set(ln.itemObjid,
@@ -132,6 +159,7 @@ export async function POST(req: NextRequest) {
}, { status: 400 });
}
}
}
// 검증 (admin 은 재고/한도 우회)
if (!isAdmin) {
+25 -3
View File
@@ -94,7 +94,19 @@ export async function POST(req: NextRequest) {
`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.limit_qty,
I.sale_end_date,
TO_CHAR(I.sale_end_date, 'YYYY-MM-DD HH24:MI') AS sale_end_txt,
COALESCE(I.requires_delivery,'N') AS requires_delivery,
-- USER 의 수정 차단 판정: 마감 시각 정각부터는 차단 (strict less than).
-- 자정 정각 입력은 '그날 종일 마감' 으로 해석해 다음날 00:00 직전까지 허용.
(
I.sale_end_date IS NOT NULL
AND (NOW() AT TIME ZONE 'Asia/Seoul') >= CASE
WHEN I.sale_end_date = date_trunc('day', I.sale_end_date)
THEN I.sale_end_date + INTERVAL '1 day'
ELSE I.sale_end_date
END
) AS is_closed,
COALESCE((
SELECT SUM(S.qty) FROM momo_stocks S
JOIN momo_warehouses W ON S.wh_objid = W.objid
@@ -112,6 +124,16 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ success: false, message: "택배/용차 라인은 이 화면에서 수정할 수 없습니다." }, { status: 400 });
}
// USER: 해당 품목의 판매 마감 시각이 지났으면 수정/삭제 모두 차단. ADMIN 은 우회.
if (!isAdmin && cur.is_closed) {
await client.query("ROLLBACK");
const endTxt = cur.sale_end_txt ? ` (마감 ${cur.sale_end_txt})` : "";
return NextResponse.json({
success: false,
message: `${cur.item_name}${endTxt} — 판매 마감 후에는 수정할 수 없습니다. 출고 담당자에게 문의하세요.`,
}, { status: 400 });
}
if (ln.delete) {
await client.query(`DELETE FROM momo_order_items WHERE objid = $1`, [ln.objid]);
continue;
@@ -138,10 +160,10 @@ export async function POST(req: NextRequest) {
}
}
}
// 한정 수량 검증 — 수량 증가분(newQty - oldQty)만 사이클 누적에 더해서 비교.
// 한정 수량 검증 — USER 만 적용. ADMIN 은 한도 우회 가능.
// 수량 증가분(newQty - oldQty)만 사이클 누적에 더해서 비교.
// getReservedQty 는 현재 라인의 oldQty 도 포함하므로 reserved + (newQty - oldQty) 비교.
// (admin/일반/unlimited_qty 모두 적용 — 전체 사이클 상한)
{
if (!isAdmin) {
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);
+3 -2
View File
@@ -167,8 +167,8 @@ export async function POST(req: NextRequest) {
}
if (it.requires_delivery === "Y") needsDelivery = true;
}
// 한정 수량 검증 — 품목별로 한 번씩, 사이클 누적합 + 이번 요청 합 ≤ limit_qty
// (unlimitedQty 권한 거래처도 한정 수량은 우회 불가 — 전체 사이클 상한이므로)
// 한정 수량 검증 — USER 만 적용. ADMIN 은 한도 우회 가능 (수기 발주 등).
if (!isAdmin) {
const checkedItems = new Set<string>();
for (const ln of lines) {
if (checkedItems.has(ln.itemObjid)) continue;
@@ -186,6 +186,7 @@ export async function POST(req: NextRequest) {
}, { status: 400 });
}
}
}
// 택배 전용 품목이 있는데 택배 라인이 없으면 차단
const hasDeliveryLine = normExtras.some((e) => e.kind === "DELIVERY");