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((
|