From bbd4f84a1233ab7fbdf4967cfe9cfc0ddfbe189c Mon Sep 17 00:00:00 2001 From: chpark Date: Tue, 26 May 2026 23:29:27 +0900 Subject: [PATCH] =?UTF-8?q?fix(orders):=20=EC=9D=8C=EC=88=98=20=EC=9E=AC?= =?UTF-8?q?=EA=B3=A0=20=EC=B6=9C=EA=B3=A0=20=ED=97=88=EC=9A=A9(=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90/=EB=AC=B4=EC=A0=9C=ED=95=9C)=20+=20=ED=8C=90?= =?UTF-8?q?=EB=A7=A4=20=EB=A7=88=EA=B0=90=20KST=20=EC=9E=AC=ED=8C=90?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - items/list: 마감 비교를 NOW() → (NOW() AT TIME ZONE 'Asia/Seoul') 로 변경. DB 서버 TZ 가 UTC 면 마감 지난 품목이 9시간 더 노출되던 문제 해결. - orders/save: 출고요청 시 판매기간 KST 기준 서버 재체크 — 마감 지난 품목이 장바구니에 남아 전송돼도 차단 + 경고 메시지. - orders/save & orders/new: 관리자/무제한(unlimited_qty='Y') 은 재고 초과(음수) 출고 허용. 총 재고가 남아 있으면 기준 창고가 비어도 출고 후 재고이동으로 정리. (실제 차감 approve 는 이미 음수 허용) 카드/리스트 품절표시도 unlimited 는 해제. --- src/app/(main)/m/orders/new/page.tsx | 20 ++++++++++++------- src/app/api/m/items/list/route.ts | 8 ++++++-- src/app/api/m/orders/save/route.ts | 29 ++++++++++++++++++++++++++-- 3 files changed, 46 insertions(+), 11 deletions(-) diff --git a/src/app/(main)/m/orders/new/page.tsx b/src/app/(main)/m/orders/new/page.tsx index 13705c1..3c71b6f 100644 --- a/src/app/(main)/m/orders/new/page.tsx +++ b/src/app/(main)/m/orders/new/page.tsx @@ -142,7 +142,8 @@ function ItemsBrowse() { const maxQ = Number(item.MAX_ORDER_QTY ?? 0); const isDelivery = item.REQUIRES_DELIVERY === "Y"; const effStock = isDelivery ? Number.MAX_SAFE_INTEGER : stock; - const limit = unlimitedQty || maxQ <= 0 ? effStock : Math.min(effStock, maxQ); + // unlimitedQty(관리자 / 무제한 거래처) 는 재고도 무시 — 음수 재고 출고 허용 (이후 재고이동으로 정리). + const limit = unlimitedQty ? Number.MAX_SAFE_INTEGER : (maxQ <= 0 ? effStock : Math.min(effStock, maxQ)); // setCart 함수형 업데이트 안에서 외부 변수에 warned 세팅하면 비동기 타이밍 때문에 // 첫 클릭에는 if(warned) 체크가 한 박자 늦게 동작. 동기 체크로 변경. @@ -184,7 +185,8 @@ function ItemsBrowse() { const maxQ = Number(target.item.MAX_ORDER_QTY ?? 0); const isDelivery = target.item.REQUIRES_DELIVERY === "Y"; const effStock = isDelivery ? Number.MAX_SAFE_INTEGER : stock; - const limit = unlimitedQty || maxQ <= 0 ? effStock : Math.min(effStock, maxQ); + // unlimitedQty(관리자 / 무제한 거래처) 는 재고도 무시 — 음수 재고 출고 허용 (이후 재고이동으로 정리). + const limit = unlimitedQty ? Number.MAX_SAFE_INTEGER : (maxQ <= 0 ? effStock : Math.min(effStock, maxQ)); if (newQty > limit) { toastLimit(limit, maxQ <= 0 || stock <= maxQ); return; @@ -199,7 +201,8 @@ function ItemsBrowse() { const maxQ = Number(target.item.MAX_ORDER_QTY ?? 0); const isDelivery = target.item.REQUIRES_DELIVERY === "Y"; const effStock = isDelivery ? Number.MAX_SAFE_INTEGER : stock; - const limit = unlimitedQty || maxQ <= 0 ? effStock : Math.min(effStock, maxQ); + // unlimitedQty(관리자 / 무제한 거래처) 는 재고도 무시 — 음수 재고 출고 허용 (이후 재고이동으로 정리). + const limit = unlimitedQty ? Number.MAX_SAFE_INTEGER : (maxQ <= 0 ? effStock : Math.min(effStock, maxQ)); const requested = Math.floor(value || 0); if (requested > limit) { toastLimit(limit, maxQ <= 0 || stock <= maxQ); @@ -590,8 +593,10 @@ function ItemsBrowse() { 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 soldOut = !isDelivery && stock <= 0; + // unlimitedQty(관리자 / 무제한 거래처) 는 재고도 무시 — 음수 재고 출고 허용 (이후 재고이동으로 정리). + const limit = unlimitedQty ? Number.MAX_SAFE_INTEGER : (maxQ <= 0 ? effStock : Math.min(effStock, maxQ)); + // unlimitedQty 는 품절(재고 0/음수)이어도 담기 가능 + const soldOut = !isDelivery && stock <= 0 && !unlimitedQty; const dim = soldOut; return (
0 ? "border-emerald-400 ring-2 ring-emerald-100" : "border-slate-200 hover:shadow-md"}`}> @@ -745,8 +750,9 @@ function ListView({ items, cart, unlimitedQty, onAdd, onPlus, onMinus, onSetQty, 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 soldOut = !isDelivery && stock <= 0; + // unlimitedQty(관리자 / 무제한 거래처) 는 재고도 무시 — 음수 재고 출고 허용 (이후 재고이동으로 정리). + const limit = unlimitedQty ? Number.MAX_SAFE_INTEGER : (maxQ <= 0 ? effStock : Math.min(effStock, maxQ)); + const soldOut = !isDelivery && stock <= 0 && !unlimitedQty; return ( 0 ? "bg-emerald-50/40" : "hover:bg-slate-50"}`}> diff --git a/src/app/api/m/items/list/route.ts b/src/app/api/m/items/list/route.ts index 9411e60..bf2e1bd 100644 --- a/src/app/api/m/items/list/route.ts +++ b/src/app/api/m/items/list/route.ts @@ -110,16 +110,20 @@ export async function POST(req: NextRequest) { // 출고요청(orders/new) 메뉴: 판매 가능 기간(sale_start_date ~ sale_end_date) 안의 품목만. // 기간이 NULL 인 품목은 상시 노출. USER 항상 적용, ADMIN 도 forSale=true 이면 적용. // + // 시간대 주의: sale_start/end_date 는 관리자가 입력한 KST 벽시계 시각이 naive timestamp 로 저장됨. + // DB 서버 TZ 가 UTC 이면 NOW() 와 직접 비교 시 9시간 어긋나 마감 지난 품목이 계속 노출됨. + // → NOW() 를 'Asia/Seoul' 벽시계 시각(naive)으로 변환해서 비교한다. (서버 TZ 가 KST 여도 결과 동일) + // // 종료일 비교 규칙: // - sale_end_date 시간이 00:00:00 (자정 정각) 이면 → 그 날 23:59:59 까지 노출 // ("5월 22일 마감" 의도는 5월 22일 종일 노출이라는 통상 해석) // - 시간이 명시 (예: 22:00) 되어 있으면 → 그 시각까지 정확히 비교 if (isUser || forSale) { conditions.push( - `(I.sale_start_date IS NULL OR NOW() >= I.sale_start_date) + `((NOW() AT TIME ZONE 'Asia/Seoul') >= I.sale_start_date OR I.sale_start_date IS NULL) AND ( I.sale_end_date IS NULL - OR NOW() <= CASE + OR (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' - INTERVAL '1 second' ELSE I.sale_end_date diff --git a/src/app/api/m/orders/save/route.ts b/src/app/api/m/orders/save/route.ts index 3cda679..031852a 100644 --- a/src/app/api/m/orders/save/route.ts +++ b/src/app/api/m/orders/save/route.ts @@ -93,6 +93,19 @@ export async function POST(req: NextRequest) { I.max_order_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, + -- 마감 지났는지 서버에서 KST 기준으로 재판정 (목록 노출과 동일 규칙) + ( + (I.sale_start_date IS NULL OR (NOW() AT TIME ZONE 'Asia/Seoul') >= I.sale_start_date) + AND ( + I.sale_end_date IS NULL + OR (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' - INTERVAL '1 second' + ELSE I.sale_end_date + END + ) + ) AS on_sale, COALESCE(( SELECT SUM(S.qty) FROM momo_stocks S JOIN momo_warehouses W ON S.wh_objid = W.objid @@ -109,14 +122,26 @@ export async function POST(req: NextRequest) { return NextResponse.json({ success: false, message: `존재하지 않는 품목입니다: ${missing.itemObjid}` }, { status: 400 }); } - // 수량/숨김/택배 검증 + // 수량/숨김/택배/판매기간 검증 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) { + const endTxt = it.sale_end_txt ? ` (마감 ${it.sale_end_txt})` : ""; + return NextResponse.json({ + success: false, + message: `${it.item_name}${endTxt} — 판매가 마감된 품목입니다. 출고 요청할 수 없습니다.`, + }, { status: 400 }); + } // 택배 전용 품목(requires_delivery='Y')은 재고와 무관하게 발주 가능 const isDeliveryItem = it.requires_delivery === "Y"; - if (!isDeliveryItem && Number(ln.qty) > stock) { + // 재고 부족 차단은 일반 거래처만 — 관리자/무제한(unlimited_qty='Y') 은 음수 재고 허용. + // 총 재고가 남아 있으면 기준 창고가 비어도 출고 후 재고 이동으로 정리 가능. + // 실제 차감은 approve 단계에서 수행 (거기서도 음수 허용). + if (!isAdmin && !unlimitedQty && !isDeliveryItem && Number(ln.qty) > stock) { return NextResponse.json({ success: false, message: `${it.item_name} — 재고(${stock})를 초과할 수 없습니다.`,