feat(procurement): 진행상태/결재상태 분리 + 출고 거래처 미선택 차단
Deploy momo-erp / deploy (push) Successful in 1m55s

매입 발주의 status='PAID'(진행상태 덮어쓰기) 를 폐기하고 결재(입금)는
paid_date 로 별도 관리. 진행상태(작성중→발주요청→입고중/입고완료)와
결재상태(입금완료/미입금)를 독립적으로 표시·필터.

- lib/momo-proc: 기존 status='PAID' 행을 입고수량 기준 진행상태로 1회 복원
  (paid_date 보존). 모든 매입 목록 라우트 첫 호출 시 실행.
- proc-payments confirm/update: status 안 건드리고 paid_* 만. 결재취소도
  진행상태 유지. 입금 가능=진행상태 발주요청/입고중/입고완료 + 미입금.
- proc-payments list/page: 진행상태 배지 + 결재상태(입금완료/미입금) 배지
  분리. 결재 필터(전체/미입금/입금완료). 합계도 결재 기준.
- inbounds save/list/page: 입고 가능 = 진행상태 발주요청+입고중 (입금 무관).
  입고완료는 읽기전용. 목록에 결재 배지 표시.
- procurements list/page: 진행상태 + 입금완료/미입금 별도 배지.
- orders/approve + 출고처리: 거래처 미선택 발주는 출고 차단.
This commit is contained in:
chpark
2026-05-27 11:55:18 +09:00
parent 9eb13439f1
commit 8b064ea120
11 changed files with 165 additions and 103 deletions
+25 -23
View File
@@ -8,7 +8,7 @@ import { Loading } from "@/components/ui/loading";
interface ProcRow {
OBJID: string; PROC_NO: string; PROC_DATE: string;
VENDOR_OBJID: string | null; VENDOR_NAME: string | null;
STATUS: string; TOTAL_AMOUNT: number; LINE_CNT: number;
STATUS: string; IS_PAID?: boolean; TOTAL_AMOUNT: number; LINE_CNT: number;
TOTAL_QTY: number; RECEIVED_QTY: number;
}
interface ProcDetail { OBJID: string; PROC_NO: string; PROC_DATE: string; STATUS: string; VENDOR_NAME: string | null }
@@ -20,13 +20,13 @@ interface ProcLine {
interface Warehouse { OBJID: string; WH_NAME: string }
const fmt = (n: number | string | undefined) => Number(n || 0).toLocaleString("ko-KR");
// 진행상태 (입금/결재와 무관)
const STATUS_LABEL: Record<string, string> = {
REQUESTED: "발주요청", PAID: "입금완료", PARTIAL: "입고중", RECEIVED: "입고완료", OPEN: "작성중", CANCELLED: "취소",
REQUESTED: "발주요청", PARTIAL: "입고중", RECEIVED: "입고완료", OPEN: "작성중", CANCELLED: "취소",
};
const STATUS_COLOR: Record<string, string> = {
OPEN: "bg-slate-100 text-slate-600 border-slate-200",
REQUESTED: "bg-amber-100 text-amber-700 border-amber-200",
PAID: "bg-emerald-100 text-emerald-700 border-emerald-200",
PARTIAL: "bg-orange-100 text-orange-700 border-orange-200",
RECEIVED: "bg-emerald-100 text-emerald-800 border-emerald-300",
CANCELLED: "bg-rose-100 text-rose-600 border-rose-200",
@@ -68,10 +68,10 @@ export default function InboundsPage() {
});
const j = await res.json();
let rows: ProcRow[] = j.RESULTLIST ?? [];
// 입고 처리 대상: REQUESTED(발주요청) / PARTIAL(입고중) / PAID(입금완료).
// 입금 처리는 입고와 무관하게 별도 진행 가능 — 발주 요청 즉시 입고 가능.
// 입고 처리 대상: 진행상태 REQUESTED(발주요청) / PARTIAL(입고중) .
// 입금(결재)은 입고와 무관 — paid 여부와 상관없이 진행상태로만 판단.
if (statusFilter === "INBOUNDABLE") {
rows = rows.filter((r) => r.STATUS === "REQUESTED" || r.STATUS === "PARTIAL" || r.STATUS === "PAID");
rows = rows.filter((r) => r.STATUS === "REQUESTED" || r.STATUS === "PARTIAL");
} else if (statusFilter && statusFilter !== "ALL") {
rows = rows.filter((r) => r.STATUS === statusFilter);
}
@@ -224,10 +224,9 @@ export default function InboundsPage() {
<div className="flex gap-2">
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}
className="h-9 px-3 rounded border border-slate-300 bg-white text-sm">
<option value="INBOUNDABLE"> ( + + )</option>
<option value="INBOUNDABLE"> ( + )</option>
<option value="ALL"></option>
<option value="REQUESTED"></option>
<option value="PAID"></option>
<option value="PARTIAL"></option>
<option value="RECEIVED"></option>
</select>
@@ -283,6 +282,11 @@ export default function InboundsPage() {
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold border ${STATUS_COLOR[p.STATUS] ?? "bg-slate-100 text-slate-500 border-slate-200"}`}>
{STATUS_LABEL[p.STATUS] ?? p.STATUS}
</span>
<div className="mt-0.5">
<span className={`inline-block px-1 py-0.5 rounded text-[9px] font-bold ${p.IS_PAID ? "bg-emerald-100 text-emerald-700" : "bg-rose-100 text-rose-600"}`}>
{p.IS_PAID ? "입금완료" : "미입금"}
</span>
</div>
</td>
</tr>
);
@@ -296,18 +300,16 @@ export default function InboundsPage() {
<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 flex items-center justify-between">
<span> </span>
{detail && ["REQUESTED", "PARTIAL", "PAID", "RECEIVED"].includes(detail.proc.STATUS) && (
<div className="flex items-center gap-2">
{detail.proc.STATUS === "RECEIVED" && (
<span className="text-[11px] text-emerald-700 inline-flex items-center gap-1">
<CheckCircle2 size={12} />
</span>
)}
<button onClick={submitInbound} disabled={busy}
className="inline-flex items-center gap-1 h-8 px-3 rounded bg-emerald-700 text-white text-xs font-bold hover:bg-emerald-800 disabled:opacity-50">
<Save size={12} />
</button>
</div>
{detail && detail.proc.STATUS === "RECEIVED" && (
<span className="text-[11px] text-emerald-700 inline-flex items-center gap-1">
<CheckCircle2 size={12} />
</span>
)}
{detail && ["REQUESTED", "PARTIAL"].includes(detail.proc.STATUS) && (
<button onClick={submitInbound} disabled={busy}
className="inline-flex items-center gap-1 h-8 px-3 rounded bg-emerald-700 text-white text-xs font-bold hover:bg-emerald-800 disabled:opacity-50">
<Save size={12} />
</button>
)}
</div>
<div className="flex-1 lg:overflow-auto p-4">
@@ -344,9 +346,9 @@ function InboundForm({ detail, warehouses, inputs, onUpdate, checklist, onCheckl
onChecklistChange: (patch: Partial<Checklist>) => void;
logistics: { id: string; name: string }[];
}) {
// 입고 입력 허용 상태: 발주요청 / 입고중 / 입금완료 / 입고완료.
// OPEN(작성중) 과 CANCELLED 만 차단. RECEIVED 라도 화면에 라인을 보여주고 수정 가능하게.
const editable = ["REQUESTED", "PARTIAL", "PAID", "RECEIVED"].includes(detail.proc.STATUS);
// 입고 입력 허용: 진행상태 발주요청 / 입고중 만 (입금 여부 무관).
// 입고완료(RECEIVED)는 더 받을 게 없어 읽기전용, OPEN/CANCELLED 도 불가.
const editable = ["REQUESTED", "PARTIAL"].includes(detail.proc.STATUS);
return (
<div className="text-[12px]">
<div className="flex items-center justify-between mb-3">
+5
View File
@@ -640,6 +640,11 @@ function StatementPreview({
const [shipping, setShipping] = useState(false);
const shipNow = async () => {
// 거래처 미선택 발주는 출고 불가
if (!order.CUSTOMER_OBJID) {
Swal.fire({ icon: "warning", title: "거래처를 선택하세요", text: "거래처가 선택되지 않으면 출고할 수 없습니다." });
return;
}
// 품목(ITEM) 라인이 없는 발주는 출고 불가
if (items.filter((it) => it.KIND === "ITEM").length === 0) {
Swal.fire({ icon: "warning", title: "품목이 없습니다", text: "품목을 1개 이상 추가해야 출고할 수 있습니다." });
+33 -26
View File
@@ -14,6 +14,7 @@ interface Proc {
VENDOR_CONTACT: string;
TOTAL_AMOUNT: number;
STATUS: string;
IS_PAID: boolean;
PAYMENT_TERMS: string | null;
PAID_DATE: string | null;
PAID_AMOUNT: number | null;
@@ -23,16 +24,15 @@ interface Proc {
interface Vendor { OBJID: string; VENDOR_NAME: string }
const fmt = (n: number | string | null | undefined) => Number(n || 0).toLocaleString("ko-KR");
// 진행상태 (입금과 무관)
const STATUS_LABEL: Record<string, string> = {
OPEN: "작성중", REQUESTED: "발주요청", PARTIAL: "부분입고", RECEIVED: "입고완료",
PAID: "입금완료", CANCELLED: "취소",
OPEN: "작성중", REQUESTED: "발주요청", PARTIAL: "부분입고", RECEIVED: "입고완료", CANCELLED: "취소",
};
const STATUS_COLOR: Record<string, string> = {
OPEN: "bg-slate-100 text-slate-600",
REQUESTED: "bg-amber-100 text-amber-700",
PARTIAL: "bg-sky-100 text-sky-700",
RECEIVED: "bg-blue-100 text-blue-700",
PAID: "bg-emerald-100 text-emerald-700",
CANCELLED: "bg-rose-100 text-rose-600",
};
@@ -47,7 +47,7 @@ export default function ProcPaymentsPage() {
const [vendors, setVendors] = useState<Vendor[]>([]);
const [[from, to], setRange] = useState(defaultRange());
const [vendorFilter, setVendorFilter] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [payFilter, setPayFilter] = useState(""); // "" | PAID | UNPAID
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
@@ -58,11 +58,11 @@ export default function ProcPaymentsPage() {
dateFrom: from || undefined,
dateTo: to || undefined,
vendorObjid: vendorFilter || undefined,
status: statusFilter || undefined,
payStatus: payFilter || undefined,
}),
});
setList((await res.json()).RESULTLIST ?? []);
}, [from, to, vendorFilter, statusFilter]);
}, [from, to, vendorFilter, payFilter]);
const loadVendors = useCallback(async () => {
const res = await fetch("/api/m/vendors/list", {
@@ -79,8 +79,8 @@ export default function ProcPaymentsPage() {
const summary = useMemo(() => {
let requested = 0, requestedAmt = 0, paid = 0, paidAmt = 0;
for (const p of list) {
if (p.STATUS === "REQUESTED") { requested++; requestedAmt += Number(p.TOTAL_AMOUNT) || 0; }
if (p.STATUS === "PAID") { paid++; paidAmt += Number(p.PAID_AMOUNT || p.TOTAL_AMOUNT) || 0; }
if (p.IS_PAID) { paid++; paidAmt += Number(p.PAID_AMOUNT || p.TOTAL_AMOUNT) || 0; }
else { requested++; requestedAmt += Number(p.TOTAL_AMOUNT) || 0; }
}
return { requested, requestedAmt, paid, paidAmt };
}, [list]);
@@ -223,10 +223,10 @@ export default function ProcPaymentsPage() {
placeholder="공급업체"
/>
</div>
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}
<select value={payFilter} onChange={(e) => setPayFilter(e.target.value)}
className="h-8 px-2 rounded border border-slate-200 bg-white shrink-0">
<option value=""> </option>
<option value="REQUESTED"> ()</option>
<option value=""> </option>
<option value="UNPAID"></option>
<option value="PAID"></option>
</select>
</div>
@@ -258,15 +258,20 @@ export default function ProcPaymentsPage() {
<span className="font-semibold text-slate-600">{p.PROC_NO}</span>
</div>
</div>
<span className={`shrink-0 text-[10px] px-1.5 py-0.5 rounded font-semibold ${STATUS_COLOR[p.STATUS]}`}>
{STATUS_LABEL[p.STATUS] || p.STATUS}
</span>
<div className="flex flex-col items-end gap-1 shrink-0">
<span className={`text-[10px] px-1.5 py-0.5 rounded font-semibold ${STATUS_COLOR[p.STATUS] ?? "bg-slate-100 text-slate-500"}`}>
{STATUS_LABEL[p.STATUS] || p.STATUS}
</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded font-bold ${p.IS_PAID ? "bg-emerald-100 text-emerald-700" : "bg-rose-100 text-rose-600"}`}>
{p.IS_PAID ? "입금완료" : "미입금"}
</span>
</div>
</div>
<div className="text-right text-sm font-bold tabular-nums mb-2">{fmt(p.TOTAL_AMOUNT)}</div>
{p.STATUS === "REQUESTED" ? (
{!p.IS_PAID ? (
<button onClick={() => onPay(p)} disabled={busy}
className="w-full h-8 rounded bg-emerald-700 text-white text-xs font-bold disabled:opacity-50"> </button>
) : p.STATUS === "PAID" ? (
) : (
<div className="flex items-center justify-between gap-2">
<div className="text-[11px] text-emerald-700 min-w-0 truncate">
{p.PAID_DATE} · {fmt(p.PAID_AMOUNT)} {p.PAID_METHOD && `· ${p.PAID_METHOD}`}
@@ -274,10 +279,6 @@ export default function ProcPaymentsPage() {
<button onClick={() => onEdit(p)} disabled={busy}
className="shrink-0 h-7 px-2 rounded border border-slate-300 bg-white text-slate-600 text-[11px] font-bold disabled:opacity-50"></button>
</div>
) : (
<div className="text-[11px] text-slate-500">
{p.PAID_DATE ?? "-"} · {fmt(p.PAID_AMOUNT)} {p.PAID_METHOD && `· ${p.PAID_METHOD}`}
</div>
)}
</div>
))}
@@ -292,7 +293,8 @@ export default function ProcPaymentsPage() {
<th className="text-left px-3 py-1.5"></th>
<th className="text-left px-3 py-1.5"></th>
<th className="text-right px-3 py-1.5"></th>
<th className="text-center px-3 py-1.5"></th>
<th className="text-center px-3 py-1.5"></th>
<th className="text-center px-3 py-1.5"></th>
<th className="text-left px-3 py-1.5"></th>
<th className="text-right px-3 py-1.5"></th>
<th className="text-center px-3 py-2.5 w-[120px]"></th>
@@ -300,7 +302,7 @@ export default function ProcPaymentsPage() {
</thead>
<tbody>
{list.length === 0 ? (
<tr><td colSpan={8} className="text-center py-10 text-slate-400"> .</td></tr>
<tr><td colSpan={9} className="text-center py-10 text-slate-400"> .</td></tr>
) : list.map((p) => (
<tr key={p.OBJID} className="border-t border-slate-100 hover:bg-slate-50">
<td className="px-3 py-2.5 font-semibold">{p.PROC_NO}</td>
@@ -308,24 +310,29 @@ export default function ProcPaymentsPage() {
<td className="px-3 py-1.5">{p.VENDOR_NAME ?? "-"}</td>
<td className="px-3 py-2.5 text-right tabular-nums font-bold">{fmt(p.TOTAL_AMOUNT)}</td>
<td className="px-3 py-2.5 text-center">
<span className={`text-[10px] px-1.5 py-0.5 rounded font-semibold ${STATUS_COLOR[p.STATUS]}`}>
<span className={`text-[10px] px-1.5 py-0.5 rounded font-semibold ${STATUS_COLOR[p.STATUS] ?? "bg-slate-100 text-slate-500"}`}>
{STATUS_LABEL[p.STATUS] || p.STATUS}
</span>
</td>
<td className="px-3 py-2.5 text-center">
<span className={`text-[10px] px-1.5 py-0.5 rounded font-bold ${p.IS_PAID ? "bg-emerald-100 text-emerald-700" : "bg-rose-100 text-rose-600"}`}>
{p.IS_PAID ? "입금완료" : "미입금"}
</span>
</td>
<td className="px-3 py-2.5 text-[11px] text-slate-600">{p.PAID_DATE || "-"}</td>
<td className="px-3 py-2.5 text-right tabular-nums text-emerald-700">{p.PAID_AMOUNT ? `${fmt(p.PAID_AMOUNT)}` : "-"}</td>
<td className="px-3 py-2.5 text-center">
{p.STATUS === "REQUESTED" ? (
{!p.IS_PAID ? (
<button onClick={() => onPay(p)} disabled={busy}
className="h-7 px-2 rounded bg-emerald-700 text-white text-[11px] font-bold disabled:opacity-50">
</button>
) : p.STATUS === "PAID" ? (
) : (
<button onClick={() => onEdit(p)} disabled={busy}
className="h-7 px-2 rounded border border-slate-300 bg-white text-slate-600 text-[11px] font-bold hover:bg-slate-50 disabled:opacity-50">
</button>
) : <span className="text-[11px] text-slate-400"></span>}
)}
</td>
</tr>
))}
+11 -6
View File
@@ -9,7 +9,7 @@ import { SearchableSelect } from "@/components/ui/searchable-select";
interface ProcRow {
OBJID: string; PROC_NO: string; PROC_DATE: string;
VENDOR_OBJID: string | null; VENDOR_NAME: string | null;
STATUS: string; TOTAL_AMOUNT: number; LINE_CNT: number; MEMO?: string;
STATUS: string; IS_PAID?: boolean; TOTAL_AMOUNT: number; LINE_CNT: number; MEMO?: string;
}
interface ProcDetail {
OBJID: string; PROC_NO: string; PROC_DATE: string; STATUS: string;
@@ -35,16 +35,15 @@ interface Item {
}
const fmt = (n: number | string | undefined | null) => Number(n || 0).toLocaleString("ko-KR");
// 진행상태 (결재/입금과 무관) — 입금완료는 별도 결재 배지로 표시
const STATUS_LABEL: Record<string, string> = {
OPEN: "작성중", REQUESTED: "발주요청", PARTIAL: "부분입고", RECEIVED: "입고완료",
PAID: "입금완료", CANCELLED: "취소",
OPEN: "작성중", REQUESTED: "발주요청", PARTIAL: "부분입고", RECEIVED: "입고완료", CANCELLED: "취소",
};
const STATUS_COLOR: Record<string, string> = {
OPEN: "bg-slate-100 text-slate-600 border-slate-200",
REQUESTED: "bg-amber-100 text-amber-700 border-amber-200",
PARTIAL: "bg-sky-100 text-sky-700 border-sky-200",
RECEIVED: "bg-emerald-100 text-emerald-700 border-emerald-200",
PAID: "bg-violet-100 text-violet-700 border-violet-200",
CANCELLED: "bg-rose-100 text-rose-600 border-rose-200",
CANCELED: "bg-rose-100 text-rose-600 border-rose-200",
};
@@ -261,12 +260,13 @@ export default function ProcurementsPage() {
<th className="text-left px-2 py-2"></th>
<th className="text-left px-2 py-2"></th>
<th className="text-right px-2 py-2"></th>
<th className="text-center px-2 py-2"></th>
<th className="text-center px-2 py-2"></th>
<th className="text-center px-2 py-2"></th>
</tr>
</thead>
<tbody>
{list.length === 0 ? (
<tr><td colSpan={5} className="text-center py-12 text-slate-400"> .</td></tr>
<tr><td colSpan={6} className="text-center py-12 text-slate-400"> .</td></tr>
) : list.map((p) => (
<tr key={p.OBJID}
onClick={() => setActiveId(p.OBJID)}
@@ -280,6 +280,11 @@ export default function ProcurementsPage() {
{STATUS_LABEL[p.STATUS] ?? p.STATUS}
</span>
</td>
<td className="px-2 py-2 text-center">
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-bold ${p.IS_PAID ? "bg-emerald-100 text-emerald-700" : "bg-rose-100 text-rose-600"}`}>
{p.IS_PAID ? "입금완료" : "미입금"}
</span>
</td>
</tr>
))}
</tbody>
@@ -1,11 +1,14 @@
// 매입 입금처리 — procurement 의 status 를 PAID 로 변경하고 paid_* 컬럼 채움
// 매입 입금처리 — 결재상태(입금)만 기록. 진행상태(status)는 건드리지 않는다.
// 결재(입금)와 입고 진행은 독립. paid_date 로 입금완료 표시.
import { NextRequest, NextResponse } from "next/server";
import { execute, queryOne } from "@/lib/db";
import { requireMomoAdmin } from "@/lib/momo-guard";
import { ensureProcPaymentSeparation } from "@/lib/momo-proc";
export async function POST(req: NextRequest) {
const g = await requireMomoAdmin();
if (g instanceof NextResponse) return g;
await ensureProcPaymentSeparation();
const body = await req.json().catch(() => ({}));
const { objid, amount, method, memo } = body as {
@@ -15,23 +18,25 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ success: false, message: "objid 필수" }, { status: 400 });
}
const proc = await queryOne<{ status: string; total_amount: number }>(
`SELECT status, total_amount FROM momo_procurements WHERE objid::text = $1`,
const proc = await queryOne<{ status: string; total_amount: number; paid_date: string | null }>(
`SELECT status, total_amount, paid_date FROM momo_procurements WHERE objid::text = $1`,
[objid]
);
if (!proc) return NextResponse.json({ success: false, message: "발주를 찾을 수 없습니다." }, { status: 404 });
// 입금 가능 상태: 발주요청(REQUESTED) / 입고중(PARTIAL) / 입고완료(RECEIVED).
// 입고 전후 어느 시점에서나 입금 처리 가능 — OPEN/PAID/CANCELLED 만 차단.
// 입금 가능 진행상태: 발주요청/입고중/입고완료. (작성중/취소는 불가)
if (!["REQUESTED", "PARTIAL", "RECEIVED"].includes(proc.status)) {
return NextResponse.json({ success: false, message: `현재 상태(${proc.status})에서는 입금 처리할 수 없습니다.` }, { status: 400 });
}
if (proc.paid_date) {
return NextResponse.json({ success: false, message: "이미 입금완료된 발주입니다." }, { status: 400 });
}
const paidAmount = Number(amount) > 0 ? Number(amount) : Number(proc.total_amount);
// status 는 그대로 두고 결재정보만 채운다.
await execute(
`UPDATE momo_procurements
SET status = 'PAID',
paid_date = NOW(),
SET paid_date = NOW(),
paid_amount = $1,
paid_method = $2,
paid_memo = $3
@@ -1,26 +1,31 @@
// 매입 입금관리 목록 — 입금이 의미 있는 모든 단계 노출
// REQUESTED(발주요청) / PARTIAL(입고중) / RECEIVED(입고완료) / PAID(입금완료)
// 입고 전·후 어느 시점에서나 입금 가능하도록 입고 진행 상태도 함께 보여줌.
// 매입 입금관리 목록 — 입금이 의미 있는 진행상태(발주요청/입고중/입고완료) 노출.
// 진행상태(STATUS)와 결재상태(IS_PAID = paid_date 유무) 를 분리해서 내려준다.
// payStatus 파라미터: PAID(입금완료) / UNPAID(미입금) / 전체.
import { NextRequest, NextResponse } from "next/server";
import { queryRows } from "@/lib/db";
import { requireMomoAdmin } from "@/lib/momo-guard";
import { ensureProcPaymentSeparation } from "@/lib/momo-proc";
export async function POST(req: NextRequest) {
const g = await requireMomoAdmin();
if (g instanceof NextResponse) return g;
await ensureProcPaymentSeparation();
const body = await req.json().catch(() => ({}));
const { dateFrom, dateTo, vendorObjid, status } = body as {
dateFrom?: string; dateTo?: string; vendorObjid?: string; status?: string;
const { dateFrom, dateTo, vendorObjid, status, payStatus } = body as {
dateFrom?: string; dateTo?: string; vendorObjid?: string; status?: string; payStatus?: string;
};
const cond: string[] = ["COALESCE(P.is_del,'N') != 'Y'", "P.status IN ('REQUESTED','PARTIAL','RECEIVED','PAID')"];
const cond: string[] = ["COALESCE(P.is_del,'N') != 'Y'", "P.status IN ('REQUESTED','PARTIAL','RECEIVED')"];
const params: unknown[] = [];
let i = 1;
if (dateFrom) { cond.push(`P.proc_date >= $${i++}::date`); params.push(dateFrom); }
if (dateTo) { cond.push(`P.proc_date <= $${i++}::date`); params.push(dateTo); }
if (vendorObjid) { cond.push(`P.vendor_objid = $${i++}::text`); params.push(vendorObjid); }
if (status) { cond.push(`P.status = $${i++}`); params.push(status); }
// 결재상태 필터 (입금완료/미입금). 하위호환: 기존 status=PAID/REQUESTED 도 결재필터로 해석.
const pay = payStatus || (status === "PAID" ? "PAID" : status === "REQUESTED" ? "UNPAID" : "");
if (pay === "PAID") cond.push(`P.paid_date IS NOT NULL`);
if (pay === "UNPAID") cond.push(`P.paid_date IS NULL`);
const rows = await queryRows(
`SELECT
@@ -32,6 +37,7 @@ export async function POST(req: NextRequest) {
V.charge_user_name AS "VENDOR_CONTACT",
P.total_amount AS "TOTAL_AMOUNT",
P.status AS "STATUS",
(P.paid_date IS NOT NULL) AS "IS_PAID",
P.payment_terms AS "PAYMENT_TERMS",
TO_CHAR(P.paid_date,'YYYY-MM-DD') AS "PAID_DATE",
P.paid_amount AS "PAID_AMOUNT",
@@ -1,11 +1,14 @@
// 입금완료(PAID) 건 수정 — 입금액/입금일/방법/메모 수정 또는 입금 취소(상태 복원)
// 입금완료 건 수정 — 입금액/입금일/방법/메모 수정 또는 입금 취소(결재상태 해제)
// 진행상태(status)는 입금과 무관하게 별도이므로 건드리지 않는다.
import { NextRequest, NextResponse } from "next/server";
import { execute, queryOne } from "@/lib/db";
import { requireMomoAdmin } from "@/lib/momo-guard";
import { ensureProcPaymentSeparation } from "@/lib/momo-proc";
export async function POST(req: NextRequest) {
const g = await requireMomoAdmin();
if (g instanceof NextResponse) return g;
await ensureProcPaymentSeparation();
const body = await req.json().catch(() => ({}));
const { objid, action, amount, method, memo, paidDate } = body as {
@@ -15,38 +18,27 @@ export async function POST(req: NextRequest) {
};
if (!objid) return NextResponse.json({ success: false, message: "objid 필수" }, { status: 400 });
const proc = await queryOne<{ status: string; total_amount: number }>(
`SELECT status, total_amount FROM momo_procurements WHERE objid::text = $1`,
const proc = await queryOne<{ total_amount: number; paid_date: string | null }>(
`SELECT total_amount, paid_date FROM momo_procurements WHERE objid::text = $1`,
[objid]
);
if (!proc) return NextResponse.json({ success: false, message: "발주를 찾을 수 없습니다." }, { status: 404 });
if (proc.status !== "PAID") {
if (!proc.paid_date) {
return NextResponse.json({ success: false, message: "입금완료 건만 수정할 수 있습니다." }, { status: 400 });
}
// 입금 취소 — 입고 진행 상태로 복원하고 입금 정보 제거
// 입금 취소 — 결재정보만 제거 (진행상태는 그대로 유지)
if (action === "cancel") {
const st = await queryOne<{ pending: string; started: string; cnt: string }>(
`SELECT COUNT(*) FILTER (WHERE COALESCE(received_qty,0) < qty) AS pending,
COUNT(*) FILTER (WHERE COALESCE(received_qty,0) > 0) AS started,
COUNT(*) AS cnt
FROM momo_procurement_items WHERE proc_objid::text = $1`,
[objid]
);
const pending = Number(st?.pending ?? 0);
const started = Number(st?.started ?? 0);
const cnt = Number(st?.cnt ?? 0);
const restored = cnt > 0 && pending === 0 ? "RECEIVED" : started > 0 ? "PARTIAL" : "REQUESTED";
await execute(
`UPDATE momo_procurements
SET status = $1, paid_date = NULL, paid_amount = NULL, paid_method = NULL, paid_memo = NULL
WHERE objid::text = $2`,
[restored, objid]
SET paid_date = NULL, paid_amount = NULL, paid_method = NULL, paid_memo = NULL
WHERE objid::text = $1`,
[objid]
);
return NextResponse.json({ success: true, status: restored });
return NextResponse.json({ success: true });
}
// 입금 정보 수정 (상태는 PAID 유지)
// 입금 정보 수정
const paidAmount = Number(amount) > 0 ? Number(amount) : Number(proc.total_amount);
await execute(
`UPDATE momo_procurements
+7 -12
View File
@@ -105,28 +105,23 @@ export async function POST(req: NextRequest) {
}
}
// 매입발주 상태 갱신: 모두 입고 완료면 RECEIVED, 일부만이면 PARTIAL.
// 단 이미 PAID(입금완료) 인 발주는 입금 마커 보존 — 상태를 덮어쓰지 않음.
// 매입발주 진행상태 갱신: 모두 입고 완료면 RECEIVED, 일부만이면 PARTIAL.
// 결재(입금)는 별도 트랙(paid_date)이라 진행상태 갱신과 무관 — 항상 진행상태만 반영.
if (procObjid) {
const status = await client.query(
`SELECT
COUNT(*) FILTER (WHERE COALESCE(received_qty,0) < qty) AS pending,
COUNT(*) FILTER (WHERE COALESCE(received_qty,0) > 0) AS started,
(SELECT status FROM momo_procurements WHERE objid = $1) AS proc_status
COUNT(*) FILTER (WHERE COALESCE(received_qty,0) > 0) AS started
FROM momo_procurement_items
WHERE proc_objid = $1`,
[procObjid]
);
const pending = Number(status.rows[0].pending);
const started = Number(status.rows[0].started);
const procStatus = String(status.rows[0].proc_status || "");
// PAID 상태에서는 입고가 들어와도 PAID 유지 (입금 사실은 별도 트랙)
if (procStatus !== "PAID") {
if (pending === 0) {
await client.query(`UPDATE momo_procurements SET status='RECEIVED' WHERE objid=$1`, [procObjid]);
} else if (started > 0) {
await client.query(`UPDATE momo_procurements SET status='PARTIAL' WHERE objid=$1`, [procObjid]);
}
if (pending === 0) {
await client.query(`UPDATE momo_procurements SET status='RECEIVED' WHERE objid=$1`, [procObjid]);
} else if (started > 0) {
await client.query(`UPDATE momo_procurements SET status='PARTIAL' WHERE objid=$1`, [procObjid]);
}
}
+5
View File
@@ -32,6 +32,11 @@ export async function POST(req: NextRequest) {
await client.query("ROLLBACK");
return NextResponse.json({ success: false, message: `현재 상태(${order.status})에서는 승인할 수 없습니다.` }, { status: 400 });
}
// 거래처 미선택 발주는 출고 불가 (거래명세표 발송 대상이 없음)
if (!order.customer_objid || String(order.customer_objid).trim() === "") {
await client.query("ROLLBACK");
return NextResponse.json({ success: false, message: "거래처가 선택되지 않은 발주는 출고할 수 없습니다." }, { status: 400 });
}
// ITEM 종류만 — 택배/용차/환불 라인은 재고 차감 대상이 아님
const itemsRes = await client.query(
+7 -1
View File
@@ -1,11 +1,13 @@
import { NextRequest, NextResponse } from "next/server";
import { queryRows } from "@/lib/db";
import { requireMomoAdmin } from "@/lib/momo-guard";
import { ensureProcPaymentSeparation } from "@/lib/momo-proc";
export async function POST(req: NextRequest) {
const g = await requireMomoAdmin();
if (g instanceof NextResponse) return g;
const { dateFrom, dateTo, status, vendorObjid } = await req.json().catch(() => ({}));
await ensureProcPaymentSeparation();
const { dateFrom, dateTo, status, vendorObjid, payStatus } = await req.json().catch(() => ({}));
const conds: string[] = ["COALESCE(P.is_del,'N') != 'Y'"];
const params: unknown[] = [];
let i = 1;
@@ -13,12 +15,16 @@ export async function POST(req: NextRequest) {
if (vendorObjid) { conds.push(`P.vendor_objid = $${i++}`); params.push(vendorObjid); }
if (dateFrom) { conds.push(`P.proc_date >= $${i++}::date`); params.push(dateFrom); }
if (dateTo) { conds.push(`P.proc_date <= $${i++}::date`); params.push(dateTo); }
if (payStatus === "PAID") conds.push(`P.paid_date IS NOT NULL`);
if (payStatus === "UNPAID") conds.push(`P.paid_date IS NULL`);
const rows = await queryRows(
`SELECT P.objid AS "OBJID", P.proc_no AS "PROC_NO",
TO_CHAR(P.proc_date,'YYYY-MM-DD') AS "PROC_DATE",
P.vendor_objid AS "VENDOR_OBJID", V.supply_name AS "VENDOR_NAME",
P.status AS "STATUS", P.total_amount AS "TOTAL_AMOUNT", P.memo AS "MEMO",
(P.paid_date IS NOT NULL) AS "IS_PAID",
TO_CHAR(P.paid_date,'YYYY-MM-DD') AS "PAID_DATE",
(SELECT COUNT(*) FROM momo_procurement_items WHERE proc_objid = P.objid) AS "LINE_CNT",
COALESCE((SELECT SUM(qty) FROM momo_procurement_items WHERE proc_objid = P.objid), 0) AS "TOTAL_QTY",
COALESCE((SELECT SUM(received_qty) FROM momo_procurement_items WHERE proc_objid = P.objid), 0) AS "RECEIVED_QTY"
+34
View File
@@ -0,0 +1,34 @@
// 매입 발주 상태 분리 — 진행상태(status)와 결재상태(입금: paid_date) 를 별도로 관리.
// 진행상태: OPEN(작성중) → REQUESTED(발주요청) → PARTIAL(입고중) / RECEIVED(입고완료) / CANCELLED
// 결재상태: paid_date IS NOT NULL = 입금완료, NULL = 미입금 (status 와 무관)
//
// 과거에는 status='PAID' 가 진행상태를 덮어써서 입고 진행도를 알 수 없었음.
// 아래 1회성 마이그레이션으로 status='PAID' 행을 입고수량 기준 진행상태로 복원하고
// paid_date 를 보존(없으면 NOW())해 결재상태만 별도로 남긴다.
import { pool } from "./db";
let migrated = false;
export async function ensureProcPaymentSeparation() {
if (migrated) return;
try {
await pool.query(`
UPDATE momo_procurements P SET
paid_date = COALESCE(P.paid_date, NOW()),
status = CASE
WHEN (SELECT COUNT(*) FROM momo_procurement_items WHERE proc_objid = P.objid) = 0 THEN 'REQUESTED'
WHEN (SELECT COUNT(*) FROM momo_procurement_items
WHERE proc_objid = P.objid AND COALESCE(received_qty,0) < qty) = 0 THEN 'RECEIVED'
WHEN (SELECT COUNT(*) FROM momo_procurement_items
WHERE proc_objid = P.objid AND COALESCE(received_qty,0) > 0) > 0 THEN 'PARTIAL'
ELSE 'REQUESTED'
END
WHERE P.status = 'PAID'
`);
migrated = true;
} catch (err) {
console.error("[momo-proc/ensureProcPaymentSeparation]", err);
}
}
// 결재(입금) 완료 여부 SQL 식 — paid_date 기준
export const IS_PAID_SQL = "(P.paid_date IS NOT NULL)";