fix(orders): 음수 재고 출고 허용(관리자/무제한) + 판매 마감 KST 재판정
Deploy momo-erp / deploy (push) Successful in 4m6s

- 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 는 해제.
This commit is contained in:
chpark
2026-05-26 23:29:27 +09:00
parent a06a5d551e
commit bbd4f84a12
3 changed files with 46 additions and 11 deletions
+13 -7
View File
@@ -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 (
<div key={it.OBJID} className={`bg-white border rounded-lg p-2 transition ${dim ? "opacity-50" : ""} ${inCart > 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 (
<tr key={it.OBJID} className={`border-t border-slate-100 ${soldOut ? "opacity-50" : ""} ${inCart > 0 ? "bg-emerald-50/40" : "hover:bg-slate-50"}`}>
<td className="px-2 py-2 overflow-hidden">
+6 -2
View File
@@ -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
+27 -2
View File
@@ -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})를 초과할 수 없습니다.`,