feat(orders): 발주 단건 편집 락 — 동시 수정 충돌 방지
Deploy momo-erp / deploy (push) Successful in 1m57s

DB:
- momo_orders.editing_by (TEXT), editing_at (TIMESTAMP) — 자동 증설
- /api/m/admin/orders/lock 신규: action=acquire|heartbeat|release
- TTL 2분 (페이지가 죽거나 비정상 종료되어도 2분 후 자동 해제)

거래처 측 보호 (양방향 락):
- /api/m/orders/items/add, items/update, cancel : 락이 살아있고 본인 락이 아니면 409
  "○○ 담당자가 수정 중입니다" 메시지 반환
- /m/orders DetailModal: editable=false + 상단 적색 배너로 차단 안내

출고관리 (/m/admin/orders):
- activeId 변경 시 이전 락 release → 새 락 acquire (useEffect)
- 30초마다 heartbeat — 락 갱신
- 헤더 옆에 [✏️ 편집 가능] / [🔒 ○○ 수정 중] 배지
- 다른 사람 락이면 우측 미리보기 영역 pointer-events-none + opacity-60
- 발주 리스트 (테이블/카드) 행에 🔒 + 보유자명 표시
- beforeunload + 컴포넌트 언마운트 시 sendBeacon 으로 release

list/detail API: 편집중 컬럼 두 개 노출 (EDITING_BY / EDITING_BY_NAME) — 2분 TTL CASE 절로 만료된 락은 자동 NULL

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chpark
2026-05-20 23:09:44 +09:00
parent a7fa932f9f
commit 6be1633a31
8 changed files with 321 additions and 17 deletions
+149 -10
View File
@@ -12,11 +12,15 @@ interface Order {
COMPANY_NAME: string; EMAIL: string; STATUS: string;
TOTAL_TAXFREE: number; TOTAL_TAXABLE: number;
TOTAL_SUPPLY: number; TOTAL_VAT: number; TOTAL_AMOUNT: number;
EDITING_BY?: string | null;
EDITING_BY_NAME?: string | null;
}
interface DetailOrder extends Order {
CEO_NAME?: string; BIZ_NO?: string; PHONE?: string; ADDRESS?: string;
MEMO?: string; APPROVE_DATE?: string;
CUSTOMER_OBJID?: string;
EDITING_BY?: string | null;
EDITING_BY_NAME?: string | null;
}
interface DetailLine {
OBJID: string;
@@ -86,6 +90,31 @@ export default function AdminOrdersPage() {
// 발주 리스트 보기 모드 — 'list'(테이블) | 'card'(카드)
const [listViewMode, setListViewMode] = useState<"list" | "card">("list");
const [busy, setBusy] = useState(false);
// 편집 락: 내가 보유 / 다른 사람이 보유 / 미보유
const [lockedByMe, setLockedByMe] = useState(false);
const [lockedByOther, setLockedByOther] = useState<{ name: string } | null>(null);
const [myUserId, setMyUserId] = useState<string>("");
const heartbeatRef = useRef<NodeJS.Timeout | null>(null);
const lockedOrderRef = useRef<string>(""); // 현재 락 보유 중인 발주 id
// 본인 user_id 1회 확보
useEffect(() => {
fetch("/api/auth/me").then((r) => r.json()).then((d) => {
if (d?.user) setMyUserId(String(d.user.objid || d.user.userId));
}).catch(() => {});
}, []);
// 락 해제 헬퍼 (best-effort)
const releaseLock = useCallback(async (orderObjid: string) => {
if (!orderObjid) return;
try {
await fetch("/api/m/admin/orders/lock", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ orderObjid, action: "release" }),
keepalive: true,
});
} catch { /* ignore */ }
}, []);
const load = useCallback(async () => {
setLoading(true);
@@ -131,22 +160,100 @@ export default function AdminOrdersPage() {
}
}, [activeId]);
// 활성 행 변경 시 상세 로드
// 활성 행 변경 시 상세 로드 + 편집 락 획득/해제
useEffect(() => {
let cancelled = false;
// 이전 락 해제 (다른 발주로 이동했을 때)
const previousLocked = lockedOrderRef.current;
if (previousLocked && previousLocked !== activeId) {
releaseLock(previousLocked);
lockedOrderRef.current = "";
}
// 이전 heartbeat 정지
if (heartbeatRef.current) { clearInterval(heartbeatRef.current); heartbeatRef.current = null; }
setLockedByMe(false);
setLockedByOther(null);
(async () => {
if (!activeId) { setDetail(null); return; }
// 1) 상세 로드
const res = await fetch("/api/m/orders/detail", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ objid: activeId }),
});
const j = await res.json();
if (!cancelled && j.success) {
if (cancelled) return;
if (j.success) {
setDetail({ order: j.order as DetailOrder, items: j.items as DetailLine[], supplier: j.supplier as Supplier });
}
// 2) 편집 락 시도
try {
const lockRes = await fetch("/api/m/admin/orders/lock", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ orderObjid: activeId, action: "acquire" }),
});
const lj = await lockRes.json();
if (cancelled) return;
if (lj.success) {
setLockedByMe(true);
setLockedByOther(null);
lockedOrderRef.current = activeId;
// 30초마다 heartbeat
heartbeatRef.current = setInterval(async () => {
try {
const hr = await fetch("/api/m/admin/orders/lock", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ orderObjid: activeId, action: "heartbeat" }),
});
const hj = await hr.json();
if (!hj.success) {
// 락 뺏김
setLockedByMe(false);
setLockedByOther(hj.lockedByName ? { name: hj.lockedByName } : { name: "다른 담당자" });
if (heartbeatRef.current) { clearInterval(heartbeatRef.current); heartbeatRef.current = null; }
}
} catch { /* ignore */ }
}, 30000);
} else if (lj.locked) {
setLockedByMe(false);
setLockedByOther({ name: lj.lockedByName || lj.lockedBy || "다른 담당자" });
Swal.fire({
icon: "warning",
title: "수정 중인 발주",
text: lj.message || `${lj.lockedByName} 님이 수정 중입니다.`,
confirmButtonColor: "#0f766e",
confirmButtonText: "확인",
});
}
} catch { /* ignore */ }
})();
return () => { cancelled = true; };
}, [activeId]);
}, [activeId, releaseLock]);
// 페이지 떠날 때 락 해제 (best-effort) — sendBeacon 으로 keepalive
useEffect(() => {
const cleanup = () => {
const id = lockedOrderRef.current;
if (!id) return;
try {
const body = JSON.stringify({ orderObjid: id, action: "release" });
if (navigator.sendBeacon) {
navigator.sendBeacon("/api/m/admin/orders/lock", new Blob([body], { type: "application/json" }));
} else {
fetch("/api/m/admin/orders/lock", { method: "POST", headers: { "Content-Type": "application/json" }, body, keepalive: true });
}
} catch { /* ignore */ }
};
window.addEventListener("beforeunload", cleanup);
return () => {
window.removeEventListener("beforeunload", cleanup);
// 컴포넌트 언마운트 시도 정리
cleanup();
if (heartbeatRef.current) { clearInterval(heartbeatRef.current); heartbeatRef.current = null; }
};
}, []);
const requestedSelectedIds = useMemo(
() => Array.from(selected).filter((id) => orders.find((o) => o.OBJID === id)?.STATUS === "REQUESTED"),
@@ -320,11 +427,12 @@ export default function AdminOrdersPage() {
{orders.map((o) => {
const checked = selected.has(o.OBJID);
const active = o.OBJID === activeId;
const lockedByOtherUser = !!o.EDITING_BY && o.EDITING_BY !== myUserId;
return (
<div
key={o.OBJID}
onClick={() => setActiveId(o.OBJID)}
className={`border rounded-lg p-3 cursor-pointer transition ${active ? "border-emerald-500 bg-emerald-50/60 shadow-sm" : "border-slate-200 bg-white hover:bg-slate-50"}`}
className={`border rounded-lg p-3 cursor-pointer transition ${active ? "border-emerald-500 bg-emerald-50/60 shadow-sm" : lockedByOtherUser ? "border-rose-200 bg-rose-50/40" : "border-slate-200 bg-white hover:bg-slate-50"}`}
>
<div className="flex items-center justify-between gap-2 mb-1.5">
<div className="flex items-center gap-2 min-w-0">
@@ -337,6 +445,9 @@ export default function AdminOrdersPage() {
className="accent-emerald-600 disabled:opacity-30"
/>
<span className="font-bold text-sm text-slate-800 truncate">{o.ORDER_NO}</span>
{lockedByOtherUser && (
<span className="text-[10px] font-bold text-rose-700" title={`${o.EDITING_BY_NAME} 수정 중`}>🔒</span>
)}
</div>
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold border whitespace-nowrap ${STATUS_COLOR[o.STATUS] ?? "bg-slate-100 text-slate-500 border-slate-200"}`}>
{STATUS_LABEL[o.STATUS] ?? o.STATUS}
@@ -344,6 +455,9 @@ export default function AdminOrdersPage() {
</div>
<div className="text-[11px] text-slate-500">{o.ORDER_DATE}</div>
<div className="text-xs text-slate-700 truncate" title={o.COMPANY_NAME}>{o.COMPANY_NAME}</div>
{lockedByOtherUser && (
<div className="text-[10px] font-bold text-rose-700 mt-0.5">🔒 {o.EDITING_BY_NAME} </div>
)}
<div className="text-right tabular-nums font-bold text-emerald-700 mt-1">{fmt(o.TOTAL_AMOUNT)}</div>
</div>
);
@@ -372,11 +486,13 @@ export default function AdminOrdersPage() {
) : orders.map((o) => {
const checked = selected.has(o.OBJID);
const active = o.OBJID === activeId;
const lockedByOtherUser = !!o.EDITING_BY && o.EDITING_BY !== myUserId;
return (
<tr
key={o.OBJID}
onClick={() => setActiveId(o.OBJID)}
className={`border-t border-slate-100 cursor-pointer ${active ? "bg-emerald-50/60" : "hover:bg-slate-50"}`}
className={`border-t border-slate-100 cursor-pointer ${active ? "bg-emerald-50/60" : lockedByOtherUser ? "bg-rose-50/30 hover:bg-rose-50/60" : "hover:bg-slate-50"}`}
title={lockedByOtherUser ? `🔒 ${o.EDITING_BY_NAME} 님이 수정 중` : ""}
>
<td className="px-2 py-2 text-center" onClick={(e) => e.stopPropagation()}>
<input
@@ -388,9 +504,15 @@ export default function AdminOrdersPage() {
title={o.STATUS !== "REQUESTED" ? "출고요청 상태만 선택할 수 있습니다." : ""}
/>
</td>
<td className="px-1.5 py-1.5 font-semibold text-slate-800 whitespace-nowrap text-[11px]">{o.ORDER_NO}</td>
<td className="px-1.5 py-1.5 font-semibold text-slate-800 whitespace-nowrap text-[11px]">
{lockedByOtherUser && <span className="mr-1" title={`${o.EDITING_BY_NAME} 수정 중`}>🔒</span>}
{o.ORDER_NO}
</td>
<td className="px-1.5 py-1.5 text-slate-600 whitespace-nowrap text-[11px]">{o.ORDER_DATE}</td>
<td className="px-1.5 py-1.5 truncate max-w-[100px] text-[11px]" title={o.COMPANY_NAME}>{o.COMPANY_NAME}</td>
<td className="px-1.5 py-1.5 truncate max-w-[100px] text-[11px]" title={o.COMPANY_NAME}>
{o.COMPANY_NAME}
{lockedByOtherUser && <div className="text-[9px] font-bold text-rose-600">🔒 {o.EDITING_BY_NAME}</div>}
</td>
<td className="px-1.5 py-1.5 text-right tabular-nums font-semibold whitespace-nowrap text-[11px]">{fmt(o.TOTAL_AMOUNT)}</td>
<td className="px-1.5 py-1.5 text-center">
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold border ${STATUS_COLOR[o.STATUS] ?? "bg-slate-100 text-slate-500 border-slate-200"}`}>
@@ -408,8 +530,18 @@ export default function AdminOrdersPage() {
{/* 우측: 거래명세표 미리보기 */}
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden flex flex-col">
<div className="px-3 py-2 bg-slate-50 border-b border-slate-200 text-xs font-semibold text-slate-600">
<div className="px-3 py-2 bg-slate-50 border-b border-slate-200 text-xs font-semibold text-slate-600 flex items-center justify-between">
<span> </span>
{lockedByOther && (
<span className="inline-flex items-center gap-1 text-[11px] font-bold text-rose-700 bg-rose-100 border border-rose-200 px-2 py-0.5 rounded">
🔒 {lockedByOther.name}
</span>
)}
{lockedByMe && activeId && (
<span className="inline-flex items-center gap-1 text-[11px] font-bold text-emerald-700 bg-emerald-50 border border-emerald-200 px-2 py-0.5 rounded">
</span>
)}
</div>
<div className="flex-1 lg:overflow-auto p-4">
{!detail ? (
@@ -418,7 +550,14 @@ export default function AdminOrdersPage() {
<div className="text-sm"> .</div>
</div>
) : (
<StatementPreview order={detail.order} items={detail.items} supplier={detail.supplier} onCancel={cancelOne} busy={busy} onReload={reloadDetail} onReloadList={load} />
<div className={lockedByOther ? "pointer-events-none opacity-60" : ""}>
{lockedByOther && (
<div className="mb-3 p-3 rounded-lg bg-rose-50 border border-rose-200 text-rose-800 text-sm font-semibold">
🔒 <b>{lockedByOther.name}</b> . ( 해제: 마지막 2)
</div>
)}
<StatementPreview order={detail.order} items={detail.items} supplier={detail.supplier} onCancel={cancelOne} busy={busy} onReload={reloadDetail} onReloadList={load} />
</div>
)}
</div>
</div>
+10 -1
View File
@@ -225,7 +225,10 @@ function DetailModal({ order, items, supplier, onClose, onCancel, onReload }: {
}) {
const ref = useRef<HTMLDivElement>(null);
// 입금완료 전까지 — 출고요청(REQUESTED) + 출고완료(APPROVED) 모두 수정 가능
const editable = order.STATUS === "REQUESTED" || order.STATUS === "APPROVED";
// 담당자가 편집 락을 잡고 있으면 거래처는 수정 불가
const lockedByStaff = !!(order as { EDITING_BY?: string }).EDITING_BY;
const lockedByName = (order as { EDITING_BY_NAME?: string }).EDITING_BY_NAME || "담당자";
const editable = (order.STATUS === "REQUESTED" || order.STATUS === "APPROVED") && !lockedByStaff;
const updateItemLine = async (lineObjid: string, qty: number) => {
const res = await fetch("/api/m/orders/items/update", {
@@ -361,6 +364,12 @@ function DetailModal({ order, items, supplier, onClose, onCancel, onReload }: {
</div>
</div>
{lockedByStaff && (order.STATUS === "REQUESTED" || order.STATUS === "APPROVED") && (
<p className="mt-3 text-[12px] text-rose-800 bg-rose-50 border border-rose-300 rounded p-2.5 font-bold js-no-export">
🔒 <b>{lockedByName}</b> . .
<span className="block text-[10px] font-normal text-rose-600 mt-0.5">( 해제: 마지막 2)</span>
</p>
)}
{editable && (
<>
<p className="mt-3 text-[11px] text-amber-700 bg-amber-50 border border-amber-200 rounded p-2">
+98
View File
@@ -0,0 +1,98 @@
// 출고 관리 — 발주 단건 편집 락(비관적 락)
// action=acquire: 락 획득. 다른 사용자가 보유 중(2분 이내 갱신)이면 거부.
// action=heartbeat: 자기 락 갱신 (30초마다 호출 권장)
// action=release: 자기 락 해제
//
// 만료 정책: editing_at < NOW() - INTERVAL '2 minutes' 면 자동 만료로 간주 → 다른 사람이 가져갈 수 있음.
// 페이지가 그냥 닫혀도 최대 2분 이내에 자동 해제.
import { NextRequest, NextResponse } from "next/server";
import { pool } from "@/lib/db";
import { requireMomoAdmin } from "@/lib/momo-guard";
let colsEnsured = false;
async function ensureLockCols() {
if (colsEnsured) return;
try {
await pool.query(`
ALTER TABLE momo_orders
ADD COLUMN IF NOT EXISTS editing_by TEXT,
ADD COLUMN IF NOT EXISTS editing_at TIMESTAMP;
`);
colsEnsured = true;
} catch (err) {
console.error("[orders/lock/ensureLockCols]", err);
}
}
const TTL_MINUTES = 2;
export async function POST(req: NextRequest) {
const g = await requireMomoAdmin();
if (g instanceof NextResponse) return g;
await ensureLockCols();
const me = String(g.user.objid || g.user.userId);
const myName = g.user.userName || me;
const body = await req.json().catch(() => ({}));
const orderObjid = String(body.orderObjid || "");
const action = String(body.action || "");
if (!orderObjid || !["acquire", "heartbeat", "release"].includes(action)) {
return NextResponse.json({ success: false, message: "invalid params" }, { status: 400 });
}
const client = await pool.connect();
try {
if (action === "release") {
// 자기가 가진 락만 해제 (다른 사람 거 건드리지 않음)
await client.query(
`UPDATE momo_orders SET editing_by = NULL, editing_at = NULL
WHERE objid::text = $1 AND editing_by = $2`,
[orderObjid, me]
);
return NextResponse.json({ success: true });
}
// acquire / heartbeat 공통: 현재 락 상태 조회
const cur = await client.query<{ editing_by: string | null; alive: boolean; lock_name: string | null }>(
`SELECT O.editing_by,
(O.editing_at IS NOT NULL AND O.editing_at > NOW() - INTERVAL '${TTL_MINUTES} minutes') AS alive,
U.user_name AS lock_name
FROM momo_orders O
LEFT JOIN user_info U ON U.user_id = O.editing_by
WHERE O.objid::text = $1`,
[orderObjid]
);
if (cur.rowCount === 0) {
return NextResponse.json({ success: false, message: "발주를 찾을 수 없습니다." }, { status: 404 });
}
const { editing_by, alive, lock_name } = cur.rows[0];
// 다른 사람의 유효 락이 있으면 거부
if (alive && editing_by && editing_by !== me) {
return NextResponse.json({
success: false,
locked: true,
lockedBy: editing_by,
lockedByName: lock_name || editing_by,
message: `${lock_name || editing_by} 님이 수정 중입니다.`,
}, { status: 409 });
}
// 빈 락이거나 자기 락 — UPSERT
await client.query(
`UPDATE momo_orders SET editing_by = $2, editing_at = NOW() WHERE objid::text = $1`,
[orderObjid, me]
);
return NextResponse.json({
success: true,
action,
lockedBy: me,
lockedByName: myName,
});
} catch (err) {
console.error("[orders/lock]", err);
return NextResponse.json({ success: false, message: "락 처리 오류" }, { status: 500 });
} finally {
client.release();
}
}
+16 -2
View File
@@ -7,8 +7,10 @@ export async function POST(req: NextRequest) {
if (r instanceof NextResponse) return r;
const { objid } = await req.json();
const order = await queryOne<{ customer_objid: string; status: string }>(
`SELECT customer_objid, status FROM momo_orders WHERE objid = $1`,
const order = await queryOne<{ customer_objid: string; status: string; editing_by: string | null; lock_alive: boolean }>(
`SELECT customer_objid, status, editing_by,
(editing_at IS NOT NULL AND editing_at > NOW() - INTERVAL '2 minutes') AS lock_alive
FROM momo_orders WHERE objid = $1`,
[objid]
);
if (!order) return NextResponse.json({ success: false, message: "발주를 찾을 수 없습니다." }, { status: 404 });
@@ -20,6 +22,18 @@ export async function POST(req: NextRequest) {
if (order.status !== "REQUESTED") {
return NextResponse.json({ success: false, message: "요청 상태에서만 취소 가능합니다." }, { status: 400 });
}
// 편집 락 체크 — 다른 사람이 락 보유 중이면 취소 불가
const myId = String(r.user.objid || r.user.userId);
if (order.lock_alive && order.editing_by && order.editing_by !== myId) {
const lockName = await queryOne<{ user_name: string }>(
`SELECT user_name FROM user_info WHERE user_id = $1`, [order.editing_by]
);
const name = lockName?.user_name || order.editing_by;
return NextResponse.json({
success: false, locked: true,
message: `${name} 담당자가 수정 중입니다. 잠시 후 다시 시도하세요.`,
}, { status: 409 });
}
await execute(`UPDATE momo_orders SET status='CANCELLED', update_date=NOW() WHERE objid=$1`, [objid]);
return NextResponse.json({ success: true });
}
+7 -1
View File
@@ -36,9 +36,15 @@ export async function POST(req: NextRequest) {
O.supplier_phone AS "SUPPLIER_PHONE",
O.supplier_email AS "SUPPLIER_EMAIL",
O.supplier_biz_no AS "SUPPLIER_BIZ_NO",
O.supplier_address AS "SUPPLIER_ADDRESS"
O.supplier_address AS "SUPPLIER_ADDRESS",
-- 편집 락 (2분 이내 갱신만 유효)
CASE WHEN COALESCE(O.editing_at, 'epoch'::timestamp) > NOW() - INTERVAL '2 minutes'
THEN O.editing_by ELSE NULL END AS "EDITING_BY",
CASE WHEN COALESCE(O.editing_at, 'epoch'::timestamp) > NOW() - INTERVAL '2 minutes'
THEN E.user_name ELSE NULL END AS "EDITING_BY_NAME"
FROM momo_orders O
LEFT JOIN user_info U ON U.user_id = O.customer_objid
LEFT JOIN user_info E ON E.user_id = O.editing_by
WHERE O.objid = $1 AND COALESCE(O.is_del,'N') != 'Y'`,
[objid]
);
+17 -1
View File
@@ -28,7 +28,9 @@ export async function POST(req: NextRequest) {
try {
await client.query("BEGIN");
const orderRes = await client.query(
`SELECT customer_objid, status FROM momo_orders WHERE objid = $1 FOR UPDATE`,
`SELECT customer_objid, status, editing_by, editing_at,
(editing_at IS NOT NULL AND editing_at > NOW() - INTERVAL '2 minutes') AS lock_alive
FROM momo_orders WHERE objid = $1 FOR UPDATE`,
[orderObjid]
);
if (orderRes.rowCount === 0) {
@@ -45,6 +47,20 @@ export async function POST(req: NextRequest) {
await client.query("ROLLBACK");
return NextResponse.json({ success: false, message: "입금완료 이후 발주는 품목 추가 불가." }, { status: 400 });
}
// 편집 락 체크
const myId = String(r.user.objid || r.user.userId);
if (order.lock_alive && order.editing_by && order.editing_by !== myId) {
await client.query("ROLLBACK");
const lockName = await client.query<{ user_name: string }>(
`SELECT user_name FROM user_info WHERE user_id = $1`, [order.editing_by]
);
const name = lockName.rows[0]?.user_name || order.editing_by;
return NextResponse.json({
success: false,
locked: true,
message: `${name} 담당자가 수정 중입니다. 잠시 후 다시 시도하세요.`,
}, { status: 409 });
}
// 품목 정보 조회 (재고/단가 등)
const itemIds = items.map((i) => i.itemObjid);
+17 -1
View File
@@ -27,7 +27,9 @@ export async function POST(req: NextRequest) {
try {
await client.query("BEGIN");
const orderRes = await client.query(
`SELECT customer_objid, status FROM momo_orders WHERE objid = $1 FOR UPDATE`,
`SELECT customer_objid, status, editing_by, editing_at,
(editing_at IS NOT NULL AND editing_at > NOW() - INTERVAL '2 minutes') AS lock_alive
FROM momo_orders WHERE objid = $1 FOR UPDATE`,
[orderObjid]
);
if (orderRes.rowCount === 0) {
@@ -42,6 +44,20 @@ export async function POST(req: NextRequest) {
await client.query("ROLLBACK");
return NextResponse.json({ success: false, message: "권한이 없습니다." }, { status: 403 });
}
// 편집 락 체크 — 다른 사람이 락 보유 중이면 수정 불가
const myId = String(r.user.objid || r.user.userId);
if (order.lock_alive && order.editing_by && order.editing_by !== myId) {
await client.query("ROLLBACK");
const lockName = await client.query<{ user_name: string }>(
`SELECT user_name FROM user_info WHERE user_id = $1`, [order.editing_by]
);
const name = lockName.rows[0]?.user_name || order.editing_by;
return NextResponse.json({
success: false,
locked: true,
message: `${name} 담당자가 수정 중입니다. 잠시 후 다시 시도하세요.`,
}, { status: 409 });
}
// USER: REQUESTED 만. ADMIN: 입금 전 단계만 = REQUESTED/APPROVED.
// INVOICED(계산서발행)/PAID(입금완료)/CANCELED 는 수정 불가.
if (isAdmin) {
+7 -1
View File
@@ -69,9 +69,15 @@ export async function POST(req: NextRequest) {
TO_CHAR(O.invoice_date, 'YYYY-MM-DD') AS "INVOICE_DATE",
O.paid_amount AS "PAID_AMOUNT",
TO_CHAR(O.approve_date, 'YYYY-MM-DD HH24:MI') AS "APPROVE_DATE",
O.memo AS "MEMO"
O.memo AS "MEMO",
-- 편집 락 (2분 이내 갱신만 유효)
CASE WHEN COALESCE(O.editing_at, 'epoch'::timestamp) > NOW() - INTERVAL '2 minutes'
THEN O.editing_by ELSE NULL END AS "EDITING_BY",
CASE WHEN COALESCE(O.editing_at, 'epoch'::timestamp) > NOW() - INTERVAL '2 minutes'
THEN E.user_name ELSE NULL END AS "EDITING_BY_NAME"
FROM momo_orders O
LEFT JOIN user_info U ON U.user_id = O.customer_objid
LEFT JOIN user_info E ON E.user_id = O.editing_by
WHERE ${conditions.join(" AND ")}
ORDER BY O.order_date DESC, O.regdate DESC
LIMIT 500`,