From 8b064ea1206509472101e7f80bfa90d9f1aeb342 Mon Sep 17 00:00:00 2001 From: chpark Date: Wed, 27 May 2026 11:55:18 +0900 Subject: [PATCH] =?UTF-8?q?feat(procurement):=20=EC=A7=84=ED=96=89?= =?UTF-8?q?=EC=83=81=ED=83=9C/=EA=B2=B0=EC=9E=AC=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20+=20=EC=B6=9C=EA=B3=A0=20=EA=B1=B0?= =?UTF-8?q?=EB=9E=98=EC=B2=98=20=EB=AF=B8=EC=84=A0=ED=83=9D=20=EC=B0=A8?= =?UTF-8?q?=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 매입 발주의 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 + 출고처리: 거래처 미선택 발주는 출고 차단. --- src/app/(main)/m/admin/inbounds/page.tsx | 48 +++++++-------- src/app/(main)/m/admin/orders/page.tsx | 5 ++ src/app/(main)/m/admin/proc-payments/page.tsx | 59 +++++++++++-------- src/app/(main)/m/admin/procurements/page.tsx | 17 ++++-- .../m/admin/proc-payments/confirm/route.ts | 19 +++--- .../api/m/admin/proc-payments/list/route.ts | 20 ++++--- .../api/m/admin/proc-payments/update/route.ts | 34 ++++------- src/app/api/m/inbounds/save/route.ts | 19 +++--- src/app/api/m/orders/approve/route.ts | 5 ++ src/app/api/m/procurements/list/route.ts | 8 ++- src/lib/momo-proc.ts | 34 +++++++++++ 11 files changed, 165 insertions(+), 103 deletions(-) create mode 100644 src/lib/momo-proc.ts diff --git a/src/app/(main)/m/admin/inbounds/page.tsx b/src/app/(main)/m/admin/inbounds/page.tsx index 4c46915..9becc08 100644 --- a/src/app/(main)/m/admin/inbounds/page.tsx +++ b/src/app/(main)/m/admin/inbounds/page.tsx @@ -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 = { - REQUESTED: "발주요청", PAID: "입금완료", PARTIAL: "입고중", RECEIVED: "입고완료", OPEN: "작성중", CANCELLED: "취소", + REQUESTED: "발주요청", PARTIAL: "입고중", RECEIVED: "입고완료", OPEN: "작성중", CANCELLED: "취소", }; const STATUS_COLOR: Record = { 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() {
@@ -283,6 +282,11 @@ export default function InboundsPage() { {STATUS_LABEL[p.STATUS] ?? p.STATUS} +
+ + {p.IS_PAID ? "입금완료" : "미입금"} + +
); @@ -296,18 +300,16 @@ export default function InboundsPage() {
입고 처리 입력 - {detail && ["REQUESTED", "PARTIAL", "PAID", "RECEIVED"].includes(detail.proc.STATUS) && ( -
- {detail.proc.STATUS === "RECEIVED" && ( - - 입고 완료 - - )} - -
+ {detail && detail.proc.STATUS === "RECEIVED" && ( + + 입고 완료 + + )} + {detail && ["REQUESTED", "PARTIAL"].includes(detail.proc.STATUS) && ( + )}
@@ -344,9 +346,9 @@ function InboundForm({ detail, warehouses, inputs, onUpdate, checklist, onCheckl onChecklistChange: (patch: Partial) => 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 (
diff --git a/src/app/(main)/m/admin/orders/page.tsx b/src/app/(main)/m/admin/orders/page.tsx index 5db087b..633cc31 100644 --- a/src/app/(main)/m/admin/orders/page.tsx +++ b/src/app/(main)/m/admin/orders/page.tsx @@ -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개 이상 추가해야 출고할 수 있습니다." }); diff --git a/src/app/(main)/m/admin/proc-payments/page.tsx b/src/app/(main)/m/admin/proc-payments/page.tsx index 1ceae82..cf6d9cf 100644 --- a/src/app/(main)/m/admin/proc-payments/page.tsx +++ b/src/app/(main)/m/admin/proc-payments/page.tsx @@ -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 = { - OPEN: "작성중", REQUESTED: "발주요청", PARTIAL: "부분입고", RECEIVED: "입고완료", - PAID: "입금완료", CANCELLED: "취소", + OPEN: "작성중", REQUESTED: "발주요청", PARTIAL: "부분입고", RECEIVED: "입고완료", CANCELLED: "취소", }; const STATUS_COLOR: Record = { 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([]); 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="공급업체" />
- setPayFilter(e.target.value)} className="h-8 px-2 rounded border border-slate-200 bg-white shrink-0"> - - + +
@@ -258,15 +258,20 @@ export default function ProcPaymentsPage() { {p.PROC_NO}
- - {STATUS_LABEL[p.STATUS] || p.STATUS} - +
+ + {STATUS_LABEL[p.STATUS] || p.STATUS} + + + {p.IS_PAID ? "입금완료" : "미입금"} + +
₩{fmt(p.TOTAL_AMOUNT)}
- {p.STATUS === "REQUESTED" ? ( + {!p.IS_PAID ? ( - ) : p.STATUS === "PAID" ? ( + ) : (
입금일 {p.PAID_DATE} · ₩{fmt(p.PAID_AMOUNT)} {p.PAID_METHOD && `· ${p.PAID_METHOD}`} @@ -274,10 +279,6 @@ export default function ProcPaymentsPage() {
- ) : ( -
- 입금일 {p.PAID_DATE ?? "-"} · ₩{fmt(p.PAID_AMOUNT)} {p.PAID_METHOD && `· ${p.PAID_METHOD}`} -
)}
))} @@ -292,7 +293,8 @@ export default function ProcPaymentsPage() { 발주일 공급업체 발주금액 - 상태 + 진행상태 + 결재상태 입금일 입금액 동작 @@ -300,7 +302,7 @@ export default function ProcPaymentsPage() { {list.length === 0 ? ( - 발주가 없습니다. + 발주가 없습니다. ) : list.map((p) => ( {p.PROC_NO} @@ -308,24 +310,29 @@ export default function ProcPaymentsPage() { {p.VENDOR_NAME ?? "-"} ₩{fmt(p.TOTAL_AMOUNT)} - + {STATUS_LABEL[p.STATUS] || p.STATUS} + + + {p.IS_PAID ? "입금완료" : "미입금"} + + {p.PAID_DATE || "-"} {p.PAID_AMOUNT ? `₩${fmt(p.PAID_AMOUNT)}` : "-"} - {p.STATUS === "REQUESTED" ? ( + {!p.IS_PAID ? ( - ) : p.STATUS === "PAID" ? ( + ) : ( - ) : 완료} + )} ))} diff --git a/src/app/(main)/m/admin/procurements/page.tsx b/src/app/(main)/m/admin/procurements/page.tsx index 279ac50..d380dd4 100644 --- a/src/app/(main)/m/admin/procurements/page.tsx +++ b/src/app/(main)/m/admin/procurements/page.tsx @@ -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 = { - OPEN: "작성중", REQUESTED: "발주요청", PARTIAL: "부분입고", RECEIVED: "입고완료", - PAID: "입금완료", CANCELLED: "취소", + OPEN: "작성중", REQUESTED: "발주요청", PARTIAL: "부분입고", RECEIVED: "입고완료", CANCELLED: "취소", }; const STATUS_COLOR: Record = { 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() { 일자 공급업체 금액 - 상태 + 진행 + 결재 {list.length === 0 ? ( - 발주서가 없습니다. + 발주서가 없습니다. ) : list.map((p) => ( setActiveId(p.OBJID)} @@ -280,6 +280,11 @@ export default function ProcurementsPage() { {STATUS_LABEL[p.STATUS] ?? p.STATUS} + + + {p.IS_PAID ? "입금완료" : "미입금"} + + ))} diff --git a/src/app/api/m/admin/proc-payments/confirm/route.ts b/src/app/api/m/admin/proc-payments/confirm/route.ts index d25a985..0c4d28a 100644 --- a/src/app/api/m/admin/proc-payments/confirm/route.ts +++ b/src/app/api/m/admin/proc-payments/confirm/route.ts @@ -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 diff --git a/src/app/api/m/admin/proc-payments/list/route.ts b/src/app/api/m/admin/proc-payments/list/route.ts index ab7b7f9..93fe1a1 100644 --- a/src/app/api/m/admin/proc-payments/list/route.ts +++ b/src/app/api/m/admin/proc-payments/list/route.ts @@ -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", diff --git a/src/app/api/m/admin/proc-payments/update/route.ts b/src/app/api/m/admin/proc-payments/update/route.ts index e4bad77..3a941e7 100644 --- a/src/app/api/m/admin/proc-payments/update/route.ts +++ b/src/app/api/m/admin/proc-payments/update/route.ts @@ -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 diff --git a/src/app/api/m/inbounds/save/route.ts b/src/app/api/m/inbounds/save/route.ts index a84c503..5e69bb3 100644 --- a/src/app/api/m/inbounds/save/route.ts +++ b/src/app/api/m/inbounds/save/route.ts @@ -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]); } } diff --git a/src/app/api/m/orders/approve/route.ts b/src/app/api/m/orders/approve/route.ts index c0a0f88..fc541d8 100644 --- a/src/app/api/m/orders/approve/route.ts +++ b/src/app/api/m/orders/approve/route.ts @@ -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( diff --git a/src/app/api/m/procurements/list/route.ts b/src/app/api/m/procurements/list/route.ts index 4dac7a1..385bd0c 100644 --- a/src/app/api/m/procurements/list/route.ts +++ b/src/app/api/m/procurements/list/route.ts @@ -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" diff --git a/src/lib/momo-proc.ts b/src/lib/momo-proc.ts new file mode 100644 index 0000000..0721af6 --- /dev/null +++ b/src/lib/momo-proc.ts @@ -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)";