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:
@@ -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" ? "면세" : "과세"}
|
||||
|
||||
@@ -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 첫 창고에서 차감)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user