From b78172261421486c6063448624c830f99414d343 Mon Sep 17 00:00:00 2001 From: chpark Date: Wed, 13 May 2026 12:30:31 +0900 Subject: [PATCH] =?UTF-8?q?feat(proc-payment):=20=EB=A7=A4=EC=9E=85=20?= =?UTF-8?q?=EC=9E=85=EA=B8=88=EA=B4=80=EB=A6=AC=20=EB=A9=94=EB=89=B4/?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80/API=20=EC=8B=A0=EC=84=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 매입 흐름: 매입발주(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) --- db/migrations/024_proc_payment_menu.sql | 22 ++ src/app/(main)/m/admin/proc-payments/page.tsx | 244 ++++++++++++++++++ .../m/admin/proc-payments/confirm/route.ts | 41 +++ .../api/m/admin/proc-payments/list/route.ts | 46 ++++ 4 files changed, 353 insertions(+) create mode 100644 db/migrations/024_proc_payment_menu.sql create mode 100644 src/app/(main)/m/admin/proc-payments/page.tsx create mode 100644 src/app/api/m/admin/proc-payments/confirm/route.ts create mode 100644 src/app/api/m/admin/proc-payments/list/route.ts diff --git a/db/migrations/024_proc_payment_menu.sql b/db/migrations/024_proc_payment_menu.sql new file mode 100644 index 0000000..459a90b --- /dev/null +++ b/db/migrations/024_proc_payment_menu.sql @@ -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'; diff --git a/src/app/(main)/m/admin/proc-payments/page.tsx b/src/app/(main)/m/admin/proc-payments/page.tsx new file mode 100644 index 0000000..fd660a7 --- /dev/null +++ b/src/app/(main)/m/admin/proc-payments/page.tsx @@ -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 = { REQUESTED: "발주요청", PAID: "입금완료" }; +const STATUS_COLOR: Record = { + 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([]); + const [vendors, setVendors] = useState([]); + 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: ` +
+
발주번호 ${p.PROC_NO}
+
공급업체 ${p.VENDOR_NAME ?? "-"}
+
발주금액 ₩${fmt(p.TOTAL_AMOUNT)}
+
+
+ + + +
+ `, + 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 ( +
+
+

매입 입금관리

+

발주 요청된 매입에 대해 입금 처리합니다. 입금 완료된 건은 입고 처리 메뉴에서 입고 등록할 수 있습니다.

+
+ + {/* 조회조건 — 입력 즉시 반영 */} +
+ 조회조건 + setRange([e.target.value, to])} + className="h-9 px-2 rounded border border-slate-200" /> + ~ + setRange([from, e.target.value])} + className="h-9 px-2 rounded border border-slate-200" /> +
+ ({ value: v.OBJID, label: v.VENDOR_NAME }))]} + value={vendorFilter} + onChange={setVendorFilter} + placeholder="공급업체" + /> +
+ +
+ + {/* 합계 카드 */} +
+
+
미입금 ({summary.requested}건)
+
₩{fmt(summary.requestedAmt)}
+
+
+
입금완료 ({summary.paid}건)
+
₩{fmt(summary.paidAmt)}
+
+
+ + {/* 모바일: 카드 */} +
+ {list.length === 0 ? ( +
발주가 없습니다.
+ ) : list.map((p) => ( +
+
+
+
{p.PROC_NO}
+
{p.PROC_DATE} · {p.VENDOR_NAME}
+
+ + {STATUS_LABEL[p.STATUS] || p.STATUS} + +
+
₩{fmt(p.TOTAL_AMOUNT)}
+ {p.STATUS === "REQUESTED" ? ( + + ) : ( +
+ 입금일 {p.PAID_DATE} · ₩{fmt(p.PAID_AMOUNT)} {p.PAID_METHOD && `· ${p.PAID_METHOD}`} +
+ )} +
+ ))} +
+ + {/* 데스크탑: 표 */} +
+ + + + + + + + + + + + + + + {list.length === 0 ? ( + + ) : list.map((p) => ( + + + + + + + + + + + ))} + +
발주번호발주일공급업체발주금액상태입금일입금액동작
발주가 없습니다.
{p.PROC_NO}{p.PROC_DATE}{p.VENDOR_NAME ?? "-"}₩{fmt(p.TOTAL_AMOUNT)} + + {STATUS_LABEL[p.STATUS] || p.STATUS} + + {p.PAID_DATE || "-"}{p.PAID_AMOUNT ? `₩${fmt(p.PAID_AMOUNT)}` : "-"} + {p.STATUS === "REQUESTED" ? ( + + ) : 완료} +
+
+
+ ); +} diff --git a/src/app/api/m/admin/proc-payments/confirm/route.ts b/src/app/api/m/admin/proc-payments/confirm/route.ts new file mode 100644 index 0000000..c24c401 --- /dev/null +++ b/src/app/api/m/admin/proc-payments/confirm/route.ts @@ -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 }); +} diff --git a/src/app/api/m/admin/proc-payments/list/route.ts b/src/app/api/m/admin/proc-payments/list/route.ts new file mode 100644 index 0000000..b1c2bd2 --- /dev/null +++ b/src/app/api/m/admin/proc-payments/list/route.ts @@ -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 }); +}