feat(proc-payment): 매입 입금관리 메뉴/페이지/API 신설
Deploy momo-erp / deploy (push) Successful in 2m9s

매입 흐름: 매입발주(REQUESTED) → 매입 입금관리(PAID) → 입고 처리(RECEIVED)

DB (마이그레이션 024):
- momo_procurements 에 paid_date, paid_amount, paid_method, paid_memo 컬럼 추가
- 매입 그룹 메뉴 sort 재정렬: 매입발주 30, 입금관리(신설) 31, 입고처리 32, 재고관리 33
- M_APROCPAY '매입 입금관리' /m/admin/proc-payments 메뉴 INSERT (ON CONFLICT idempotent)

UI/API:
- /m/admin/proc-payments 페이지 — 발주요청/입금완료 분리 카드 + 입금 처리 모달 (금액/방법/메모)
- 조회조건: 날짜 from~to + 공급업체 + 상태 (즉시 반영)
- POST /api/m/admin/proc-payments/list — REQUESTED|PAID 만 노출
- POST /api/m/admin/proc-payments/confirm — REQUESTED → PAID 전환 + paid_* 채움

다음 단계 (별도 batch): 입고 처리 페이지에서 PAID 만 노출 + 입고 시 RECEIVED 전환

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chpark
2026-05-13 12:30:31 +09:00
parent 9293029631
commit b781722614
4 changed files with 353 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
-- 매입 입금관리: 메뉴 신설 + procurements 에 paid_* 컬럼 추가
-- 1) 컬럼 추가 (idempotent)
ALTER TABLE momo_procurements
ADD COLUMN IF NOT EXISTS paid_date TIMESTAMP,
ADD COLUMN IF NOT EXISTS paid_amount NUMERIC(15,2),
ADD COLUMN IF NOT EXISTS paid_method VARCHAR(40),
ADD COLUMN IF NOT EXISTS paid_memo TEXT;
-- 2) 매입 그룹의 sort 재정렬 — 입금관리(신설) 끼울 자리 확보
UPDATE momo_menus SET sort_order = 33 WHERE objid = 'M_AINV'; -- 재고 관리 32→33
UPDATE momo_menus SET sort_order = 32 WHERE objid = 'M_AINB'; -- 입고 처리 31→32
-- (M_APROC 매입 발주는 30 그대로)
-- 3) 매입 입금관리 메뉴 신설 (insert OR update; idempotent)
INSERT INTO momo_menus (objid, menu_code, menu_name, menu_url, parent_code, sort_order, group_name, is_system, is_del, regdate)
VALUES ('M_APROCPAY', 'A_PROCPAY', '매입 입금관리', '/m/admin/proc-payments', NULL, 31, '매입', 'Y', 'N', NOW())
ON CONFLICT (objid) DO UPDATE
SET menu_name = EXCLUDED.menu_name,
menu_url = EXCLUDED.menu_url,
sort_order = EXCLUDED.sort_order,
group_name = EXCLUDED.group_name,
is_del = 'N';
@@ -0,0 +1,244 @@
"use client";
import { useEffect, useState, useMemo, useCallback } from "react";
import Swal from "sweetalert2";
import { SearchableSelect } from "@/components/ui/searchable-select";
interface Proc {
OBJID: string;
PROC_NO: string;
PROC_DATE: string;
VENDOR_OBJID: string;
VENDOR_NAME: string;
VENDOR_CONTACT: string;
TOTAL_AMOUNT: number;
STATUS: string;
PAYMENT_TERMS: string | null;
PAID_DATE: string | null;
PAID_AMOUNT: number | null;
PAID_METHOD: string | null;
PAID_MEMO: string | null;
}
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> = { REQUESTED: "발주요청", PAID: "입금완료" };
const STATUS_COLOR: Record<string, string> = {
REQUESTED: "bg-amber-100 text-amber-700",
PAID: "bg-emerald-100 text-emerald-700",
};
function defaultRange() {
const e = new Date(), s = new Date();
s.setDate(s.getDate() - 60);
return [s.toISOString().slice(0, 10), e.toISOString().slice(0, 10)];
}
export default function ProcPaymentsPage() {
const [list, setList] = useState<Proc[]>([]);
const [vendors, setVendors] = useState<Vendor[]>([]);
const [[from, to], setRange] = useState(defaultRange());
const [vendorFilter, setVendorFilter] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
const res = await fetch("/api/m/admin/proc-payments/list", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
dateFrom: from || undefined,
dateTo: to || undefined,
vendorObjid: vendorFilter || undefined,
status: statusFilter || undefined,
}),
});
setList((await res.json()).RESULTLIST ?? []);
}, [from, to, vendorFilter, statusFilter]);
const loadVendors = useCallback(async () => {
const res = await fetch("/api/m/vendors/list", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
});
setVendors((await res.json()).RESULTLIST ?? []);
}, []);
useEffect(() => { load(); }, [load]);
useEffect(() => { loadVendors(); }, [loadVendors]);
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; }
}
return { requested, requestedAmt, paid, paidAmt };
}, [list]);
const onPay = async (p: Proc) => {
const result = await Swal.fire({
title: "입금 처리",
html: `
<div class="text-left text-sm space-y-2">
<div><b>발주번호</b> ${p.PROC_NO}</div>
<div><b>공급업체</b> ${p.VENDOR_NAME ?? "-"}</div>
<div><b>발주금액</b> ₩${fmt(p.TOTAL_AMOUNT)}</div>
</div>
<div class="mt-3 space-y-2 text-left">
<input id="sw-amount" class="swal2-input" placeholder="입금 금액 (기본 발주금액)" value="${p.TOTAL_AMOUNT}" type="number" />
<input id="sw-method" class="swal2-input" placeholder="입금 방법 (예: 계좌이체)" />
<input id="sw-memo" class="swal2-input" placeholder="메모 (선택)" />
</div>
`,
showCancelButton: true,
confirmButtonText: "입금 완료",
confirmButtonColor: "#0f766e",
cancelButtonText: "취소",
focusConfirm: false,
preConfirm: () => {
const a = (document.getElementById("sw-amount") as HTMLInputElement)?.value;
const m = (document.getElementById("sw-method") as HTMLInputElement)?.value;
const memo = (document.getElementById("sw-memo") as HTMLInputElement)?.value;
return { amount: Number(a) || Number(p.TOTAL_AMOUNT), method: m, memo };
},
});
if (!result.isConfirmed || !result.value) return;
setBusy(true);
try {
const res = await fetch("/api/m/admin/proc-payments/confirm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ objid: p.OBJID, ...result.value }),
});
const j = await res.json();
if (j.success) {
await Swal.fire({ icon: "success", title: "입금 처리 완료", timer: 1200, showConfirmButton: false });
load();
} else {
Swal.fire({ icon: "error", title: "처리 실패", text: j.message });
}
} finally {
setBusy(false);
}
};
return (
<div className="space-y-3">
<div>
<h1 className="text-xl sm:text-2xl font-bold"> </h1>
<p className="text-xs text-slate-500 mt-0.5"> . .</p>
</div>
{/* 조회조건 — 입력 즉시 반영 */}
<div className="bg-white border border-slate-200 rounded-xl p-3 flex flex-wrap gap-2 items-center text-xs">
<span className="text-slate-500 font-semibold mr-1"></span>
<input type="date" value={from} onChange={(e) => setRange([e.target.value, to])}
className="h-9 px-2 rounded border border-slate-200" />
<span className="text-slate-400">~</span>
<input type="date" value={to} onChange={(e) => setRange([from, e.target.value])}
className="h-9 px-2 rounded border border-slate-200" />
<div className="min-w-[200px]">
<SearchableSelect
options={[{ value: "", label: "전체 공급업체" }, ...vendors.map((v) => ({ value: v.OBJID, label: v.VENDOR_NAME }))]}
value={vendorFilter}
onChange={setVendorFilter}
placeholder="공급업체"
/>
</div>
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}
className="h-9 px-2 rounded border border-slate-200 bg-white">
<option value=""> </option>
<option value="REQUESTED"> ()</option>
<option value="PAID"></option>
</select>
</div>
{/* 합계 카드 */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<div className="text-[11px] text-amber-700 font-semibold mb-1.5"> ({summary.requested})</div>
<div className="text-lg font-bold tabular-nums text-amber-700">{fmt(summary.requestedAmt)}</div>
</div>
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-3">
<div className="text-[11px] text-emerald-700 font-semibold mb-1.5"> ({summary.paid})</div>
<div className="text-lg font-bold tabular-nums text-emerald-700">{fmt(summary.paidAmt)}</div>
</div>
</div>
{/* 모바일: 카드 */}
<div className="space-y-2 sm:hidden">
{list.length === 0 ? (
<div className="bg-white border border-slate-200 rounded-xl p-6 text-center text-slate-400 text-sm"> .</div>
) : list.map((p) => (
<div key={p.OBJID} className="bg-white border border-slate-200 rounded-xl p-3 shadow-sm">
<div className="flex items-start justify-between gap-2 mb-2">
<div className="min-w-0">
<div className="font-semibold text-sm truncate">{p.PROC_NO}</div>
<div className="text-[11px] text-slate-500">{p.PROC_DATE} · {p.VENDOR_NAME}</div>
</div>
<span className={`text-[10px] px-1.5 py-0.5 rounded font-semibold ${STATUS_COLOR[p.STATUS]}`}>
{STATUS_LABEL[p.STATUS] || p.STATUS}
</span>
</div>
<div className="text-right text-sm font-bold tabular-nums mb-2">{fmt(p.TOTAL_AMOUNT)}</div>
{p.STATUS === "REQUESTED" ? (
<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>
) : (
<div className="text-[11px] text-emerald-700">
{p.PAID_DATE} · {fmt(p.PAID_AMOUNT)} {p.PAID_METHOD && `· ${p.PAID_METHOD}`}
</div>
)}
</div>
))}
</div>
{/* 데스크탑: 표 */}
<div className="hidden sm:block bg-white border border-slate-200 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-600 text-xs">
<tr>
<th className="text-left px-3 py-2.5"></th>
<th className="text-left px-3 py-2.5"></th>
<th className="text-left px-3 py-2.5"></th>
<th className="text-right px-3 py-2.5"></th>
<th className="text-center px-3 py-2.5"></th>
<th className="text-left px-3 py-2.5"></th>
<th className="text-right px-3 py-2.5"></th>
<th className="text-center px-3 py-2.5 w-[120px]"></th>
</tr>
</thead>
<tbody>
{list.length === 0 ? (
<tr><td colSpan={8} 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>
<td className="px-3 py-2.5">{p.PROC_DATE}</td>
<td className="px-3 py-2.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]}`}>
{STATUS_LABEL[p.STATUS] || p.STATUS}
</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" ? (
<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>
) : <span className="text-[11px] text-slate-400"></span>}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
@@ -0,0 +1,41 @@
// 매입 입금처리 — procurement 의 status 를 PAID 로 변경하고 paid_* 컬럼 채움
import { NextRequest, NextResponse } from "next/server";
import { execute, queryOne } from "@/lib/db";
import { requireMomoAdmin } from "@/lib/momo-guard";
export async function POST(req: NextRequest) {
const g = await requireMomoAdmin();
if (g instanceof NextResponse) return g;
const body = await req.json().catch(() => ({}));
const { objid, amount, method, memo } = body as {
objid?: string; amount?: number; method?: string; memo?: string;
};
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`,
[objid]
);
if (!proc) return NextResponse.json({ success: false, message: "발주를 찾을 수 없습니다." }, { status: 404 });
if (proc.status !== "REQUESTED") {
return NextResponse.json({ success: false, message: `현재 상태(${proc.status})에서는 입금 처리할 수 없습니다.` }, { status: 400 });
}
const paidAmount = Number(amount) > 0 ? Number(amount) : Number(proc.total_amount);
await execute(
`UPDATE momo_procurements
SET status = 'PAID',
paid_date = NOW(),
paid_amount = $1,
paid_method = $2,
paid_memo = $3
WHERE objid::text = $4`,
[paidAmount, method || null, memo || null, objid]
);
return NextResponse.json({ success: true });
}
@@ -0,0 +1,46 @@
// 매입 입금관리 목록 — 발주요청(REQUESTED) 또는 입금완료(PAID) 매입발주 노출
import { NextRequest, NextResponse } from "next/server";
import { queryRows } from "@/lib/db";
import { requireMomoAdmin } from "@/lib/momo-guard";
export async function POST(req: NextRequest) {
const g = await requireMomoAdmin();
if (g instanceof NextResponse) return g;
const body = await req.json().catch(() => ({}));
const { dateFrom, dateTo, vendorObjid, status } = body as {
dateFrom?: string; dateTo?: string; vendorObjid?: string; status?: string;
};
const cond: string[] = ["COALESCE(P.is_del,'N') != 'Y'", "P.status IN ('REQUESTED','PAID')"];
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); }
const rows = await queryRows(
`SELECT
P.objid::text 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",
V.charge_user_name AS "VENDOR_CONTACT",
P.total_amount AS "TOTAL_AMOUNT",
P.status AS "STATUS",
P.payment_terms AS "PAYMENT_TERMS",
TO_CHAR(P.paid_date,'YYYY-MM-DD') AS "PAID_DATE",
P.paid_amount AS "PAID_AMOUNT",
P.paid_method AS "PAID_METHOD",
P.paid_memo AS "PAID_MEMO"
FROM momo_procurements P
LEFT JOIN supply_mng V ON V.objid::text = P.vendor_objid
WHERE ${cond.join(" AND ")}
ORDER BY P.proc_date DESC, P.regdate DESC
LIMIT 500`,
params
);
return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length });
}