From 6be1633a310f4fd1d52bea8acf423a1ba2d889eb Mon Sep 17 00:00:00 2001 From: chpark Date: Wed, 20 May 2026 23:09:44 +0900 Subject: [PATCH] =?UTF-8?q?feat(orders):=20=EB=B0=9C=EC=A3=BC=20=EB=8B=A8?= =?UTF-8?q?=EA=B1=B4=20=ED=8E=B8=EC=A7=91=20=EB=9D=BD=20=E2=80=94=20?= =?UTF-8?q?=EB=8F=99=EC=8B=9C=20=EC=88=98=EC=A0=95=20=EC=B6=A9=EB=8F=8C=20?= =?UTF-8?q?=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/app/(main)/m/admin/orders/page.tsx | 159 +++++++++++++++++++-- src/app/(main)/m/orders/page.tsx | 11 +- src/app/api/m/admin/orders/lock/route.ts | 98 +++++++++++++ src/app/api/m/orders/cancel/route.ts | 18 ++- src/app/api/m/orders/detail/route.ts | 8 +- src/app/api/m/orders/items/add/route.ts | 18 ++- src/app/api/m/orders/items/update/route.ts | 18 ++- src/app/api/m/orders/list/route.ts | 8 +- 8 files changed, 321 insertions(+), 17 deletions(-) create mode 100644 src/app/api/m/admin/orders/lock/route.ts diff --git a/src/app/(main)/m/admin/orders/page.tsx b/src/app/(main)/m/admin/orders/page.tsx index bf640a2..d648fef 100644 --- a/src/app/(main)/m/admin/orders/page.tsx +++ b/src/app/(main)/m/admin/orders/page.tsx @@ -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(""); + const heartbeatRef = useRef(null); + const lockedOrderRef = useRef(""); // 현재 락 보유 중인 발주 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 (
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"}`} >
@@ -337,6 +445,9 @@ export default function AdminOrdersPage() { className="accent-emerald-600 disabled:opacity-30" /> {o.ORDER_NO} + {lockedByOtherUser && ( + 🔒 + )}
{STATUS_LABEL[o.STATUS] ?? o.STATUS} @@ -344,6 +455,9 @@ export default function AdminOrdersPage() {
{o.ORDER_DATE}
{o.COMPANY_NAME}
+ {lockedByOtherUser && ( +
🔒 {o.EDITING_BY_NAME} 수정 중
+ )}
₩{fmt(o.TOTAL_AMOUNT)}
); @@ -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 ( 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} 님이 수정 중` : ""} > e.stopPropagation()}> - {o.ORDER_NO} + + {lockedByOtherUser && 🔒} + {o.ORDER_NO} + {o.ORDER_DATE} - {o.COMPANY_NAME} + + {o.COMPANY_NAME} + {lockedByOtherUser &&
🔒 {o.EDITING_BY_NAME}
} + ₩{fmt(o.TOTAL_AMOUNT)} @@ -408,8 +530,18 @@ export default function AdminOrdersPage() { {/* 우측: 거래명세표 미리보기 */}
-
- 거래명세표 미리보기 +
+ 거래명세표 미리보기 + {lockedByOther && ( + + 🔒 {lockedByOther.name} 님이 수정 중 + + )} + {lockedByMe && activeId && ( + + ✏️ 편집 가능 + + )}
{!detail ? ( @@ -418,7 +550,14 @@ export default function AdminOrdersPage() {
왼쪽에서 발주를 선택하세요.
) : ( - +
+ {lockedByOther && ( +
+ 🔒 {lockedByOther.name} 님이 이 발주를 수정 중입니다. (자동 해제: 마지막 활동 후 2분) +
+ )} + +
)}
diff --git a/src/app/(main)/m/orders/page.tsx b/src/app/(main)/m/orders/page.tsx index 61954fa..6753325 100644 --- a/src/app/(main)/m/orders/page.tsx +++ b/src/app/(main)/m/orders/page.tsx @@ -225,7 +225,10 @@ function DetailModal({ order, items, supplier, onClose, onCancel, onReload }: { }) { const ref = useRef(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 }: { + {lockedByStaff && (order.STATUS === "REQUESTED" || order.STATUS === "APPROVED") && ( +

+ 🔒 {lockedByName} 담당자가 이 발주를 수정 중입니다. 잠시 후 다시 시도해주세요. + (자동 해제: 마지막 활동 후 2분) +

+ )} {editable && ( <>

diff --git a/src/app/api/m/admin/orders/lock/route.ts b/src/app/api/m/admin/orders/lock/route.ts new file mode 100644 index 0000000..f772301 --- /dev/null +++ b/src/app/api/m/admin/orders/lock/route.ts @@ -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(); + } +} diff --git a/src/app/api/m/orders/cancel/route.ts b/src/app/api/m/orders/cancel/route.ts index ef1e829..8b80e48 100644 --- a/src/app/api/m/orders/cancel/route.ts +++ b/src/app/api/m/orders/cancel/route.ts @@ -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 }); } diff --git a/src/app/api/m/orders/detail/route.ts b/src/app/api/m/orders/detail/route.ts index 6aefe5d..4e897ec 100644 --- a/src/app/api/m/orders/detail/route.ts +++ b/src/app/api/m/orders/detail/route.ts @@ -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] ); diff --git a/src/app/api/m/orders/items/add/route.ts b/src/app/api/m/orders/items/add/route.ts index 68734dc..892cdda 100644 --- a/src/app/api/m/orders/items/add/route.ts +++ b/src/app/api/m/orders/items/add/route.ts @@ -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); diff --git a/src/app/api/m/orders/items/update/route.ts b/src/app/api/m/orders/items/update/route.ts index 7f282b0..759c795 100644 --- a/src/app/api/m/orders/items/update/route.ts +++ b/src/app/api/m/orders/items/update/route.ts @@ -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) { diff --git a/src/app/api/m/orders/list/route.ts b/src/app/api/m/orders/list/route.ts index 8832635..451d81b 100644 --- a/src/app/api/m/orders/list/route.ts +++ b/src/app/api/m/orders/list/route.ts @@ -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`,