feat(items): 상시 판매 플래그 신설 — 날짜 없으면 출고요청 미노출
Deploy momo-erp / deploy (push) Successful in 1m58s

요구 정정: 기존엔 날짜 없으면 자동 "상시" 였으나, 이제 명시적 [상시 판매] 체크가
있어야 출고요청에 노출되고 날짜 없으면 미노출(=거래처 화면에서 안 보임).

- DB: momo_items.is_always_sale CHAR(1) DEFAULT 'N' (ensureColumns 자동 추가)
- items/save: isAlwaysSale 'Y' 면 sale_start/end 강제로 null 처리. INSERT/UPDATE
  에 is_always_sale 컬럼 반영. 알림 트리거에 상시 플래그 변경도 포함.
- items/list forSale 필터: is_always_sale='Y' 이거나, 시작/종료 중 하나 설정 +
  현재 기간 안 일 때만 노출. (둘 다 NULL + 상시 미체크 = 미노출)
- orders/save on_sale 재판정: items/list 와 동일 규칙으로 마감 차단.
- 품목 관리 편집 폼: [상시 판매] 체크박스 + 체크 시 날짜 입력 비활성/비움.
- 품목 관리 목록: 상시(초록) / 날짜범위 / 미노출(빨강) 3가지 구분 표시.
This commit is contained in:
chpark
2026-05-29 01:04:21 +09:00
parent 72227883a0
commit 8f26ed496d
4 changed files with 113 additions and 48 deletions
+29 -3
View File
@@ -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() {
<td className="px-3 py-2 text-center text-[11px] tabular-nums text-slate-600 whitespace-nowrap">
{it.SALE_START_DATE || it.SALE_END_DATE
? <>{it.SALE_START_DATE ?? "—"} <span className="text-slate-300">~</span> {it.SALE_END_DATE ?? "—"}</>
: <span className="text-slate-300"></span>}
: it.IS_ALWAYS_SALE === "Y"
? <span className="text-emerald-700 font-bold"></span>
: <span className="text-rose-500"></span>}
</td>
<td className="px-3 py-2 text-center">
{it.IS_HIDDEN === "Y" && (
@@ -505,20 +509,42 @@ export default function AdminItemsPage() {
</label>
</div>
</Field>
<div className="sm:col-span-2">
<Field label="상시 판매">
<label className="inline-flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={editing.IS_ALWAYS_SALE === "Y"}
onChange={(e) => setEditing({
...editing,
IS_ALWAYS_SALE: e.target.checked ? "Y" : "N",
// 상시 체크 시 날짜 비우기 (요청 정책)
SALE_START_DATE: e.target.checked ? null : editing.SALE_START_DATE,
SALE_END_DATE: e.target.checked ? null : editing.SALE_END_DATE,
})}
className="w-4 h-4 accent-emerald-600"
/>
<span className="text-sm"> ( )</span>
</label>
<p className="text-[11px] text-slate-500 mt-1"> + .</p>
</Field>
</div>
<Field label="판매 시작일시 (분 단위)">
<input
type="datetime-local"
value={toLocal(editing.SALE_START_DATE)}
disabled={editing.IS_ALWAYS_SALE === "Y"}
onChange={(e) => 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"
/>
</Field>
<Field label="판매 종료일시 (분 단위)">
<input
type="datetime-local"
value={toLocal(editing.SALE_END_DATE)}
disabled={editing.IS_ALWAYS_SALE === "Y"}
onChange={(e) => 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"
/>
</Field>
<div className="sm:col-span-2">
+20 -9
View File
@@ -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((
+50 -27
View File
@@ -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<SaleInfo | null> {
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<SaleInfo | null> {
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);
}
+14 -9
View File
@@ -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((