diff --git a/src/app/(main)/m/admin/items/page.tsx b/src/app/(main)/m/admin/items/page.tsx index 8620483..abc19f8 100644 --- a/src/app/(main)/m/admin/items/page.tsx +++ b/src/app/(main)/m/admin/items/page.tsx @@ -25,6 +25,7 @@ interface Item { VENDOR_NAME?: string; SALE_START_DATE?: string | null; SALE_END_DATE?: string | null; + IS_ALWAYS_SALE?: string; } interface Vendor { OBJID: string; VENDOR_NAME: string } @@ -127,6 +128,7 @@ export default function AdminItemsPage() { vendorObjid: editing.VENDOR_OBJID || null, saleStartDate: editing.SALE_START_DATE || null, saleEndDate: editing.SALE_END_DATE || null, + isAlwaysSale: editing.IS_ALWAYS_SALE === "Y" ? "Y" : "N", }; const res = await fetch("/api/m/items/save", { method: "POST", @@ -336,7 +338,9 @@ export default function AdminItemsPage() { {it.SALE_START_DATE || it.SALE_END_DATE ? <>{it.SALE_START_DATE ?? "—"} ~ {it.SALE_END_DATE ?? "—"} - : 상시} + : it.IS_ALWAYS_SALE === "Y" + ? 상시 + : 미노출} {it.IS_HIDDEN === "Y" && ( @@ -505,20 +509,42 @@ export default function AdminItemsPage() { +
+ + +

미체크 + 날짜 미입력 시 출고요청 화면에서 노출되지 않습니다.

+
+
setEditing({ ...editing, SALE_START_DATE: e.target.value || null })} - className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none" + className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none disabled:bg-slate-100 disabled:text-slate-400" /> setEditing({ ...editing, SALE_END_DATE: e.target.value || null })} - className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none" + className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none disabled:bg-slate-100 disabled:text-slate-400" />
diff --git a/src/app/api/m/items/list/route.ts b/src/app/api/m/items/list/route.ts index ad9ffbb..6db1f88 100644 --- a/src/app/api/m/items/list/route.ts +++ b/src/app/api/m/items/list/route.ts @@ -15,7 +15,8 @@ async function ensureColumns() { ADD COLUMN IF NOT EXISTS is_hidden CHAR(1) DEFAULT 'N', ADD COLUMN IF NOT EXISTS requires_delivery CHAR(1) DEFAULT 'N', ADD COLUMN IF NOT EXISTS sale_start_date TIMESTAMP, - ADD COLUMN IF NOT EXISTS sale_end_date TIMESTAMP; + ADD COLUMN IF NOT EXISTS sale_end_date TIMESTAMP, + ADD COLUMN IF NOT EXISTS is_always_sale CHAR(1) DEFAULT 'N'; `); // sale_start_date / sale_end_date 가 이전에 DATE 로 만들어졌다면 TIMESTAMP 로 자동 승격 await pool.query(` @@ -119,15 +120,24 @@ export async function POST(req: NextRequest) { // ("5월 22일 마감" 의도는 5월 22일 종일 노출이라는 통상 해석) // - 시간이 명시 (예: 22:00) 되어 있으면 → 그 시각까지 정확히 비교 if (isUser || forSale) { + // 출고요청 노출 규칙(새 정책): + // 상시(is_always_sale='Y') 이거나, 시작/종료일이 최소 하나라도 설정되고 현재 기간 안. + // 날짜도 없고 상시도 아니면 = 미노출 (이전엔 "상시" 취급이었음). conditions.push( - `((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() 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 + `( + COALESCE(I.is_always_sale,'N') = 'Y' + ) + OR ( + (I.sale_start_date IS NOT NULL OR I.sale_end_date IS NOT NULL) + AND ((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() 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 + ) )` ); } @@ -152,6 +162,7 @@ export async function POST(req: NextRequest) { COALESCE(I.requires_delivery, 'N') AS "REQUIRES_DELIVERY", TO_CHAR(I.sale_start_date, 'YYYY-MM-DD HH24:MI') AS "SALE_START_DATE", TO_CHAR(I.sale_end_date, 'YYYY-MM-DD HH24:MI') AS "SALE_END_DATE", + COALESCE(I.is_always_sale, 'N') AS "IS_ALWAYS_SALE", I.vendor_objid AS "VENDOR_OBJID", V.supply_name AS "VENDOR_NAME", COALESCE(( diff --git a/src/app/api/m/items/save/route.ts b/src/app/api/m/items/save/route.ts index 3c6cb3c..27b82b7 100644 --- a/src/app/api/m/items/save/route.ts +++ b/src/app/api/m/items/save/route.ts @@ -7,44 +7,59 @@ import { sendPush } from "@/lib/push"; interface SaleInfo { startTxt: string | null; // 'YYYY-MM-DD HH:MM' endTxt: string | null; - sellable: boolean; // 판매 일정이 있고 마감이 안 지남(=지금 또는 미래에 판매) + ACTIVE/비숨김/미삭제 - orderableNow: boolean; // 지금 출고요청 가능 (판매기간 내) + alwaysSale: boolean; // 상시 판매 플래그 + sellable: boolean; // 출고요청 노출 가능 (상시 OR 판매기간 안) + orderableNow: boolean; // 지금 출고요청 가능 } -// 품목의 판매 일정/상태 정보 (KST 기준). items/list 노출 규칙과 동일한 마감 해석. +// 품목의 판매 일정/상태 정보 (KST 기준). items/list 노출 규칙과 동일. +// sellable = ACTIVE/비숨김/미삭제 AND (상시 OR 날짜 설정+마감 미경과) async function getSaleInfo(objid: string): Promise { const row = await queryOne<{ - start_txt: string | null; end_txt: string | null; sellable: boolean; orderable_now: boolean; + start_txt: string | null; end_txt: string | null; always_sale: string; + sellable: boolean; orderable_now: boolean; }>( `SELECT TO_CHAR(sale_start_date, 'YYYY-MM-DD HH24:MI') AS start_txt, TO_CHAR(sale_end_date, 'YYYY-MM-DD HH24:MI') AS end_txt, + COALESCE(is_always_sale,'N') AS always_sale, ( COALESCE(is_del,'N') != 'Y' AND UPPER(COALESCE(status,'')) = 'ACTIVE' AND COALESCE(is_hidden,'N') != 'Y' - AND (sale_start_date IS NOT NULL OR sale_end_date IS NOT NULL) AND ( - sale_end_date IS NULL - OR (NOW() AT TIME ZONE 'Asia/Seoul') <= CASE - WHEN sale_end_date = date_trunc('day', sale_end_date) - THEN sale_end_date + INTERVAL '1 day' - INTERVAL '1 second' - ELSE sale_end_date - END + COALESCE(is_always_sale,'N') = 'Y' + OR ( + (sale_start_date IS NOT NULL OR sale_end_date IS NOT NULL) + AND ( + sale_end_date IS NULL + OR (NOW() AT TIME ZONE 'Asia/Seoul') <= CASE + WHEN sale_end_date = date_trunc('day', sale_end_date) + THEN sale_end_date + INTERVAL '1 day' - INTERVAL '1 second' + ELSE sale_end_date + END + ) + ) ) ) AS sellable, ( COALESCE(is_del,'N') != 'Y' AND UPPER(COALESCE(status,'')) = 'ACTIVE' AND COALESCE(is_hidden,'N') != 'Y' - AND (sale_start_date IS NULL OR (NOW() AT TIME ZONE 'Asia/Seoul') >= sale_start_date) AND ( - sale_end_date IS NULL - OR (NOW() AT TIME ZONE 'Asia/Seoul') <= CASE - WHEN sale_end_date = date_trunc('day', sale_end_date) - THEN sale_end_date + INTERVAL '1 day' - INTERVAL '1 second' - ELSE sale_end_date - END + COALESCE(is_always_sale,'N') = 'Y' + OR ( + (sale_start_date IS NULL OR (NOW() AT TIME ZONE 'Asia/Seoul') >= sale_start_date) + AND ( + sale_end_date IS NULL + OR (NOW() AT TIME ZONE 'Asia/Seoul') <= CASE + WHEN sale_end_date = date_trunc('day', sale_end_date) + THEN sale_end_date + INTERVAL '1 day' - INTERVAL '1 second' + ELSE sale_end_date + END + ) + AND (sale_start_date IS NOT NULL OR sale_end_date IS NOT NULL) + ) ) ) AS orderable_now FROM momo_items WHERE objid = $1`, @@ -53,6 +68,7 @@ async function getSaleInfo(objid: string): Promise { if (!row) return null; return { startTxt: row.start_txt, endTxt: row.end_txt, + alwaysSale: row.always_sale === "Y", sellable: !!row.sellable, orderableNow: !!row.orderable_now, }; } @@ -98,12 +114,17 @@ export async function POST(req: NextRequest) { vendorObjid, saleStartDate, saleEndDate, + isAlwaysSale, } = body; const maxQty = maxOrderQty == null || maxOrderQty === "" ? null : Number(maxOrderQty); const hidden = isHidden === "Y" ? "Y" : "N"; const reqDelivery = requiresDelivery === "Y" ? "Y" : "N"; - const saleStart = saleStartDate && String(saleStartDate).trim() !== "" ? saleStartDate : null; - const saleEnd = saleEndDate && String(saleEndDate).trim() !== "" ? saleEndDate : null; + // 상시 판매 체크 시 날짜는 강제로 비움 (상시 == 항상 노출). + const alwaysSale = isAlwaysSale === "Y" ? "Y" : "N"; + const saleStart = alwaysSale === "Y" ? null + : (saleStartDate && String(saleStartDate).trim() !== "" ? saleStartDate : null); + const saleEnd = alwaysSale === "Y" ? null + : (saleEndDate && String(saleEndDate).trim() !== "" ? saleEndDate : null); if (!itemName) { return NextResponse.json({ success: false, message: "품목명은 필수입니다." }, { status: 400 }); @@ -123,16 +144,16 @@ export async function POST(req: NextRequest) { 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, - sale_start_date, sale_end_date, + 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,'N',NOW(),$17)`, + ) 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)`, [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, - userId, saleStart, saleEnd] + userId, saleStart, saleEnd, alwaysSale] ); // 신규 등록 품목에 판매 일정(현재/미래)이 잡혀 있으면 일반 사용자에게 알림 const newInfo = await getSaleInfo(newId); @@ -151,6 +172,7 @@ export async function POST(req: NextRequest) { 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 WHERE objid=$1`, [objid, cleanName, itemDetail ?? null, makerObjid ?? null, unit ?? "EA", @@ -160,14 +182,15 @@ export async function POST(req: NextRequest) { status ?? "ACTIVE", maxQty, hidden, reqDelivery, vendorObjid ?? null, - userId, saleStart, saleEnd] + userId, saleStart, saleEnd, alwaysSale] ); - // 판매 일정(시작/마감)이 바뀌었고, 바뀐 일정이 아직 유효(오늘 또는 미래)면 일반 사용자에게 알림. - // 단가/이미지 등만 수정한 경우(날짜 동일)는 알림 안 함. 이미 지난 날짜로 바꾼 경우도 제외. + // 판매 일정(시작/마감) 또는 상시 플래그가 바뀌었고, 바뀐 결과가 출고요청 노출 대상(sellable)이면 알림. + // 단가/이미지 등만 수정(노출 조건 동일)이거나, 마감 지난 날짜로 바뀐 경우는 알림 안 함. const afterInfo = await getSaleInfo(objid); const datesChanged = (beforeInfo?.startTxt ?? null) !== (afterInfo?.startTxt ?? null) || - (beforeInfo?.endTxt ?? null) !== (afterInfo?.endTxt ?? null); + (beforeInfo?.endTxt ?? null) !== (afterInfo?.endTxt ?? null) || + (beforeInfo?.alwaysSale ?? false) !== (afterInfo?.alwaysSale ?? false); if (datesChanged && afterInfo?.sellable) { await notifyItemSale(cleanName, objid, afterInfo); } diff --git a/src/app/api/m/orders/save/route.ts b/src/app/api/m/orders/save/route.ts index f784dc3..65d5486 100644 --- a/src/app/api/m/orders/save/route.ts +++ b/src/app/api/m/orders/save/route.ts @@ -94,16 +94,21 @@ export async function POST(req: NextRequest) { 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 기준으로 재판정 (목록 노출과 동일 규칙) + -- 출고요청 가능 여부 서버 재판정 (items/list 노출 규칙과 동일): + -- 상시(is_always_sale='Y') 이거나, 시작/종료 중 하나 이상 설정 + 현재 기간 안. ( - (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 + COALESCE(I.is_always_sale,'N') = 'Y' + OR ( + (I.sale_start_date IS NOT NULL OR I.sale_end_date IS NOT NULL) + AND (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((