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:
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user