매입 발주의 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:
@@ -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">
|
||||
|
||||
@@ -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개 이상 추가해야 출고할 수 있습니다." });
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)";
|
||||
Reference in New Issue
Block a user