feat(statement-branch-admin): 기준 명세표 관리 메뉴/페이지/API + 창고 카테고리 7개로 확장
Deploy momo-erp / deploy (push) Successful in 3m5s

마이그레이션 028:
- momo_statement_branches 테이블 신설 (code PK / name / bank_account / phone / email 등)
- HQ, KIMPO 기본 시드 INSERT (사용자가 관리자 페이지에서 편집 가능)
- 메뉴: 시스템 그룹에 '기준 명세표 관리' (M_ASBR / menu_info 9000310)

라이브러리 (src/lib/momo-branches.ts):
- 하드코딩 → DB 조회로 변경 (60초 in-memory 캐시)
- getSupplierByBranch 가 async — detail/statement/approve API 도 await 추가
- 저장 시 invalidateBranchCache() 호출

페이지/API (관리자 전용):
- /m/admin/statement-branches : list + 등록/수정/삭제 모달
- POST /api/m/admin/statement-branches/list
- POST /api/m/admin/statement-branches/save (regist / update / delete)

사용자 수정 폼:
- "기준 거래명세서" select 옵션이 하드코딩 본사/김포 → DB 의 branches list 동적 fetch

창고 관리:
- WH_TYPE 카테고리 5개 → 7개 (옛 enum 도 라벨 매핑은 유지)
  · HQ_STOCK 본사 창고
  · HQ_CHARTER 본사 용차
  · HQ_MARKET 본사 시장
  · KIMPO_BRANCH 김포지사
  · KIMPO_STOCK 김포 창고
  · KIMPO_CHARTER 김포 용차
  · KIMPO_MARKET 김포 시장
- 신규 추가 시 select 는 위 7개만 노출, 기존 데이터 (STOCK 등) 는 라벨로 자연 표시

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chpark
2026-05-13 15:48:08 +09:00
parent 6ad57356a0
commit 19e3cf9048
10 changed files with 423 additions and 25 deletions
@@ -0,0 +1,48 @@
-- 1) 기준 명세표 관리 테이블 신설 (관리자가 supplier 정보 편집)
CREATE TABLE IF NOT EXISTS momo_statement_branches (
code VARCHAR(20) PRIMARY KEY,
name VARCHAR(100) NOT NULL,
bank_account VARCHAR(200) NOT NULL,
phone VARCHAR(40),
email VARCHAR(100),
ceo_name VARCHAR(50),
biz_no VARCHAR(40),
address VARCHAR(200),
is_default CHAR(1) DEFAULT 'N',
sort_order INTEGER DEFAULT 0,
is_del CHAR(1) DEFAULT 'N',
regdate TIMESTAMP DEFAULT NOW()
);
-- 기본 시드 (idempotent: code 가 PK 라 ON CONFLICT DO NOTHING 안전)
INSERT INTO momo_statement_branches (code, name, bank_account, phone, email, ceo_name, is_default, sort_order)
VALUES
('HQ', '본사', '기업은행 434-115361-01-016 (이상용)', '010-6369-8443', 'momo8443@daum.net', '이상용', 'Y', 1),
('KIMPO', '김포', '농협 351-1383-7634-13 (모모유통)', '010-5789-9431', 'momokimpo@nate.com', '이상용', 'N', 2)
ON CONFLICT (code) DO NOTHING;
-- 2) 기준 명세표 관리 메뉴 (admin-panel 의 시스템 관리 또는 별도)
-- momo_menus 시스템 그룹에 추가
INSERT INTO momo_menus (objid, menu_code, menu_name, menu_url, parent_code, sort_order, group_name, is_system, is_del, regdate)
VALUES ('M_ASBR', 'A_SBR', '기준 명세표 관리', '/m/admin/statement-branches', NULL, 92, '시스템', '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';
-- menu_info (FITO 사이드바 ground truth) 도 추가 — 매입/입고 부모(9000300) 와 별도, 시스템 그룹에
-- 9000306 으로 신설, 부모 -395553955 (메뉴 root) 사용
INSERT INTO menu_info (objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_url, status, system_name, regdate)
SELECT '9000310', '1', '-395553955', '기준 명세표 관리', 'Statement Branch', '710', '/m/admin/statement-branches', 'active', 'PMS', NOW()
WHERE NOT EXISTS (SELECT 1 FROM menu_info WHERE objid = '9000310');
UPDATE menu_info SET
menu_name_kor = '기준 명세표 관리',
menu_name_eng = 'Statement Branch',
parent_obj_id = '-395553955',
seq = '710',
menu_url = '/m/admin/statement-branches',
status = 'active'
WHERE objid = '9000310';
@@ -0,0 +1,203 @@
"use client";
import { useEffect, useState, FormEvent } from "react";
import { Plus, Pencil, Trash2 } from "lucide-react";
import Swal from "sweetalert2";
interface Branch {
CODE: string;
NAME: string;
BANK_ACCOUNT: string;
PHONE: string | null;
EMAIL: string | null;
CEO_NAME: string | null;
BIZ_NO: string | null;
ADDRESS: string | null;
IS_DEFAULT: string;
SORT_ORDER: number;
}
export default function StatementBranchesPage() {
const [list, setList] = useState<Branch[]>([]);
const [editing, setEditing] = useState<Partial<Branch> | null>(null);
const [isNew, setIsNew] = useState(false);
const load = async () => {
const res = await fetch("/api/m/admin/statement-branches/list", { method: "POST" });
setList((await res.json()).RESULTLIST ?? []);
};
useEffect(() => { load(); }, []);
const openNew = () => {
setIsNew(true);
setEditing({ CODE: "", NAME: "", BANK_ACCOUNT: "", IS_DEFAULT: "N", SORT_ORDER: list.length + 1 });
};
const openEdit = (b: Branch) => { setIsNew(false); setEditing({ ...b }); };
const save = async (e: FormEvent) => {
e.preventDefault();
if (!editing) return;
if (!editing.CODE || !editing.NAME || !editing.BANK_ACCOUNT) {
Swal.fire({ icon: "warning", title: "코드/이름/계좌번호는 필수" });
return;
}
const res = await fetch("/api/m/admin/statement-branches/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
actionType: isNew ? "regist" : "update",
code: editing.CODE, name: editing.NAME, bankAccount: editing.BANK_ACCOUNT,
phone: editing.PHONE ?? "", email: editing.EMAIL ?? "",
ceoName: editing.CEO_NAME ?? "", bizNo: editing.BIZ_NO ?? "", address: editing.ADDRESS ?? "",
isDefault: editing.IS_DEFAULT ?? "N", sortOrder: String(editing.SORT_ORDER ?? 0),
}),
});
const j = await res.json();
if (j.success) {
await Swal.fire({ icon: "success", title: "저장 완료", timer: 1200, showConfirmButton: false });
setEditing(null); load();
} else {
Swal.fire({ icon: "error", title: "저장 실패", text: j.message });
}
};
const del = async (b: Branch) => {
if (b.IS_DEFAULT === "Y") {
Swal.fire({ icon: "warning", title: "기본 명세표는 삭제 불가" });
return;
}
const ok = await Swal.fire({ icon: "question", title: `${b.NAME} (${b.CODE}) 삭제?`, showCancelButton: true });
if (!ok.isConfirmed) return;
const res = await fetch("/api/m/admin/statement-branches/save", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ actionType: "delete", code: b.CODE, name: b.NAME, bankAccount: b.BANK_ACCOUNT }),
});
if ((await res.json()).success) load();
};
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold"> </h1>
<p className="text-xs text-slate-500 mt-0.5">/ () . .</p>
</div>
<button onClick={openNew} className="h-9 px-3 rounded bg-emerald-700 text-white text-sm font-bold inline-flex items-center gap-1 hover:bg-emerald-800">
<Plus size={14} />
</button>
</div>
<div className="bg-white border border-slate-200 rounded-xl overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-600 text-xs">
<tr>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left"> </th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-center"></th>
<th className="px-3 py-2 text-center w-[100px]"></th>
</tr>
</thead>
<tbody>
{list.length === 0 ? (
<tr><td colSpan={7} className="text-center py-12 text-slate-400"> .</td></tr>
) : list.map((b) => (
<tr key={b.CODE} className="border-t border-slate-100">
<td className="px-3 py-2 font-mono font-semibold text-emerald-700">{b.CODE}</td>
<td className="px-3 py-2 font-semibold">{b.NAME}</td>
<td className="px-3 py-2 text-xs">{b.BANK_ACCOUNT}</td>
<td className="px-3 py-2 text-xs">{b.PHONE}</td>
<td className="px-3 py-2 text-xs text-blue-700">{b.EMAIL}</td>
<td className="px-3 py-2 text-center">
{b.IS_DEFAULT === "Y" && <span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 font-bold"></span>}
</td>
<td className="px-3 py-2 text-center">
<button onClick={() => openEdit(b)} className="text-emerald-700 hover:text-emerald-800 mr-2"><Pencil size={14} /></button>
<button onClick={() => del(b)} className="text-rose-500 hover:text-rose-600"><Trash2 size={14} /></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{editing && (
<div className="fixed inset-0 bg-slate-900/60 z-50 flex items-center justify-center p-3" onClick={() => setEditing(null)}>
<form onSubmit={save} className="bg-white rounded-xl shadow-xl w-full max-w-lg p-5" onClick={(e) => e.stopPropagation()}>
<h3 className="font-bold text-lg mb-4">{isNew ? "기준 명세표 등록" : `기준 명세표 수정 (${editing.CODE})`}</h3>
<div className="space-y-3">
<Field label="코드 *">
<input required disabled={!isNew} value={editing.CODE ?? ""}
onChange={(e) => setEditing({ ...editing, CODE: e.target.value.toUpperCase() })}
placeholder="HQ / KIMPO 등" className="w-full h-9 px-3 rounded border border-slate-200 text-sm font-mono disabled:bg-slate-100" />
</Field>
<Field label="이름 *">
<input required value={editing.NAME ?? ""}
onChange={(e) => setEditing({ ...editing, NAME: e.target.value })}
className="w-full h-9 px-3 rounded border border-slate-200 text-sm" />
</Field>
<Field label="결제 계좌번호 *">
<input required value={editing.BANK_ACCOUNT ?? ""}
onChange={(e) => setEditing({ ...editing, BANK_ACCOUNT: e.target.value })}
placeholder="예: 기업은행 434-115361-01-016 (이상용)"
className="w-full h-9 px-3 rounded border border-slate-200 text-sm" />
</Field>
<div className="grid grid-cols-2 gap-2">
<Field label="전화">
<input value={editing.PHONE ?? ""} onChange={(e) => setEditing({ ...editing, PHONE: e.target.value })}
className="w-full h-9 px-3 rounded border border-slate-200 text-sm" />
</Field>
<Field label="이메일">
<input type="email" value={editing.EMAIL ?? ""} onChange={(e) => setEditing({ ...editing, EMAIL: e.target.value })}
className="w-full h-9 px-3 rounded border border-slate-200 text-sm" />
</Field>
</div>
<div className="grid grid-cols-2 gap-2">
<Field label="대표자">
<input value={editing.CEO_NAME ?? ""} onChange={(e) => setEditing({ ...editing, CEO_NAME: e.target.value })}
className="w-full h-9 px-3 rounded border border-slate-200 text-sm" />
</Field>
<Field label="사업자등록번호">
<input value={editing.BIZ_NO ?? ""} onChange={(e) => setEditing({ ...editing, BIZ_NO: e.target.value })}
className="w-full h-9 px-3 rounded border border-slate-200 text-sm" />
</Field>
</div>
<Field label="주소">
<input value={editing.ADDRESS ?? ""} onChange={(e) => setEditing({ ...editing, ADDRESS: e.target.value })}
className="w-full h-9 px-3 rounded border border-slate-200 text-sm" />
</Field>
<div className="grid grid-cols-2 gap-2">
<label className="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" checked={editing.IS_DEFAULT === "Y"}
onChange={(e) => setEditing({ ...editing, IS_DEFAULT: e.target.checked ? "Y" : "N" })}
className="w-4 h-4 accent-emerald-600" />
</label>
<Field label="정렬순">
<input type="number" value={editing.SORT_ORDER ?? 0}
onChange={(e) => setEditing({ ...editing, SORT_ORDER: Number(e.target.value) })}
className="w-full h-9 px-3 rounded border border-slate-200 text-sm" />
</Field>
</div>
</div>
<div className="flex gap-2 justify-end mt-5">
<button type="button" onClick={() => setEditing(null)} className="h-10 px-4 rounded-lg border border-slate-200 text-sm font-semibold"></button>
<button type="submit" className="h-10 px-5 rounded-lg bg-emerald-700 text-white text-sm font-bold hover:bg-emerald-800"></button>
</div>
</form>
</div>
)}
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block">
<div className="text-[11px] font-semibold text-slate-600 mb-1">{label}</div>
{children}
</label>
);
}
+27 -5
View File
@@ -7,16 +7,38 @@ import Swal from "sweetalert2";
interface Warehouse {
OBJID: string; WH_CODE: string; WH_NAME: string; LOCATION: string; WH_TYPE: string;
}
// 본사/김포 각각 창고/용차/시장 + 김포지사 자체 = 총 7가지
const TYPE_LABEL: Record<string, string> = {
STOCK: "본사 창고", KIMPO: "김포 창고", PICKUP_TEAM: "창고픽업팀", MARKET: "시장픽업", DELIVERY: "용차배송",
HQ_STOCK: "본사 창고",
HQ_CHARTER: "본사 용차",
HQ_MARKET: "본사 시장",
KIMPO_BRANCH:"김포지사",
KIMPO_STOCK: "김포 창고",
KIMPO_CHARTER:"김포 용차",
KIMPO_MARKET:"김포 시장",
// ↓ 옛 enum (기존 데이터 표시 유지)
STOCK: "본사 창고",
KIMPO: "김포 창고",
PICKUP_TEAM: "창고픽업팀",
MARKET: "시장픽업",
DELIVERY: "용차배송",
};
const TYPE_COLOR: Record<string, string> = {
HQ_STOCK: "bg-emerald-100 text-emerald-700",
HQ_CHARTER: "bg-orange-100 text-orange-700",
HQ_MARKET: "bg-amber-100 text-amber-700",
KIMPO_BRANCH: "bg-teal-100 text-teal-700",
KIMPO_STOCK: "bg-teal-100 text-teal-700",
KIMPO_CHARTER:"bg-orange-100 text-orange-700",
KIMPO_MARKET: "bg-amber-100 text-amber-700",
STOCK: "bg-emerald-100 text-emerald-700",
KIMPO: "bg-teal-100 text-teal-700",
PICKUP_TEAM: "bg-sky-100 text-sky-700",
MARKET: "bg-amber-100 text-amber-700",
DELIVERY: "bg-orange-100 text-orange-700",
};
// 신규 카테고리 (select 옵션). 옛 enum 은 데이터 표시만 유지, 신규 추가는 새 enum 만.
const NEW_TYPE_ORDER = ["HQ_STOCK", "HQ_CHARTER", "HQ_MARKET", "KIMPO_BRANCH", "KIMPO_STOCK", "KIMPO_CHARTER", "KIMPO_MARKET"];
export default function WarehousesPage() {
const [list, setList] = useState<Warehouse[]>([]);
@@ -36,7 +58,7 @@ export default function WarehousesPage() {
body: JSON.stringify({
objid: editing.OBJID, actionType: editing.OBJID ? "update" : "regist",
whName: editing.WH_NAME,
location: editing.LOCATION, whType: editing.WH_TYPE || "STOCK",
location: editing.LOCATION, whType: editing.WH_TYPE || "HQ_STOCK",
}),
});
if ((await res.json()).success) {
@@ -52,7 +74,7 @@ export default function WarehousesPage() {
<h1 className="text-xl sm:text-2xl font-bold"> </h1>
<p className="text-xs sm:text-sm text-slate-500 mt-1"> {list.length}</p>
</div>
<button onClick={() => setEditing({ WH_TYPE: "STOCK" })}
<button onClick={() => setEditing({ WH_TYPE: "HQ_STOCK" })}
className="h-10 px-3 sm:px-4 inline-flex items-center gap-1.5 rounded-lg bg-emerald-700 text-white text-xs sm:text-sm font-bold hover:bg-emerald-800">
<Plus size={16} />
</button>
@@ -129,8 +151,8 @@ export default function WarehousesPage() {
/>
</div>
<input required placeholder="이름" value={editing.WH_NAME ?? ""} onChange={(e) => setEditing({ ...editing, WH_NAME: e.target.value })} className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm" />
<select value={editing.WH_TYPE ?? "STOCK"} onChange={(e) => setEditing({ ...editing, WH_TYPE: e.target.value })} className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm bg-white">
{Object.entries(TYPE_LABEL).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
<select value={editing.WH_TYPE ?? "HQ_STOCK"} onChange={(e) => setEditing({ ...editing, WH_TYPE: e.target.value })} className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm bg-white">
{NEW_TYPE_ORDER.map((k) => <option key={k} value={k}>{TYPE_LABEL[k]}</option>)}
</select>
<input placeholder="위치 (선택)" value={editing.LOCATION ?? ""} onChange={(e) => setEditing({ ...editing, LOCATION: e.target.value })} className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm" />
</div>
+8 -2
View File
@@ -13,6 +13,7 @@ function UserForm() {
const [form, setForm] = useState<Record<string, string>>({});
const [depts, setDepts] = useState<{ DEPT_CODE: string; DEPT_NAME: string }[]>([]);
const [whs, setWhs] = useState<{ OBJID: string; WH_NAME: string; WH_CODE: string }[]>([]);
const [branches, setBranches] = useState<{ CODE: string; NAME: string }[]>([]);
const [loading, setLoading] = useState(false);
const set = (k: string, v: string) => setForm((p) => ({ ...p, [k]: v }));
@@ -21,6 +22,8 @@ function UserForm() {
.then((r) => r.json()).then((d) => setDepts(d.RESULTLIST || [])).catch(() => {});
fetch("/api/m/warehouses/list", { method: "POST" })
.then((r) => r.json()).then((d) => setWhs(d.RESULTLIST || [])).catch(() => {});
fetch("/api/m/admin/statement-branches/list", { method: "POST" })
.then((r) => r.json()).then((d) => setBranches(d.RESULTLIST || [])).catch(() => {});
}, []);
useEffect(() => {
@@ -118,8 +121,11 @@ function UserForm() {
onChange={(e) => set("statement_branch", e.target.value)}
className="h-8 w-full rounded border border-gray-300 bg-white px-2 text-[12px]"
>
<option value="HQ"> ( / )</option>
<option value="KIMPO"> ( / )</option>
{branches.length === 0 ? (
<option value="HQ"></option>
) : branches.map((b) => (
<option key={b.CODE} value={b.CODE}>{b.NAME} ({b.CODE})</option>
))}
</select>
</div>
<div>
@@ -0,0 +1,25 @@
// 기준 명세표 (공급자 정보) 목록 — admin
import { NextResponse } from "next/server";
import { queryRows } from "@/lib/db";
import { requireMomoAdmin } from "@/lib/momo-guard";
export async function POST() {
const g = await requireMomoAdmin();
if (g instanceof NextResponse) return g;
const rows = await queryRows(
`SELECT code AS "CODE",
name AS "NAME",
bank_account AS "BANK_ACCOUNT",
phone AS "PHONE",
email AS "EMAIL",
ceo_name AS "CEO_NAME",
biz_no AS "BIZ_NO",
address AS "ADDRESS",
COALESCE(is_default, 'N') AS "IS_DEFAULT",
COALESCE(sort_order, 0) AS "SORT_ORDER"
FROM momo_statement_branches
WHERE COALESCE(is_del,'N') != 'Y'
ORDER BY sort_order ASC, code ASC`
);
return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length });
}
@@ -0,0 +1,45 @@
// 기준 명세표 (공급자 정보) 저장 — admin
import { NextRequest, NextResponse } from "next/server";
import { execute } from "@/lib/db";
import { requireMomoAdmin } from "@/lib/momo-guard";
import { invalidateBranchCache } from "@/lib/momo-branches";
export async function POST(req: NextRequest) {
const g = await requireMomoAdmin();
if (g instanceof NextResponse) return g;
const body = await req.json().catch(() => ({}));
const {
code, name, bankAccount, phone, email, ceoName, bizNo, address,
isDefault, sortOrder, actionType,
} = body as Record<string, string>;
if (!code) return NextResponse.json({ success: false, message: "code 필수" }, { status: 400 });
if (!name) return NextResponse.json({ success: false, message: "name 필수" }, { status: 400 });
if (!bankAccount) return NextResponse.json({ success: false, message: "bankAccount 필수" }, { status: 400 });
if (actionType === "delete") {
await execute(`UPDATE momo_statement_branches SET is_del='Y' WHERE code=$1`, [code]);
} else if (actionType === "regist") {
await execute(
`INSERT INTO momo_statement_branches (code, name, bank_account, phone, email, ceo_name, biz_no, address, is_default, sort_order, is_del, regdate)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'N',NOW())
ON CONFLICT (code) DO UPDATE SET
name=EXCLUDED.name, bank_account=EXCLUDED.bank_account,
phone=EXCLUDED.phone, email=EXCLUDED.email,
ceo_name=EXCLUDED.ceo_name, biz_no=EXCLUDED.biz_no,
address=EXCLUDED.address, is_default=EXCLUDED.is_default,
sort_order=EXCLUDED.sort_order, is_del='N'`,
[code, name, bankAccount, phone ?? null, email ?? null, ceoName ?? null, bizNo ?? null, address ?? null, isDefault === "Y" ? "Y" : "N", Number(sortOrder) || 0]
);
} else {
await execute(
`UPDATE momo_statement_branches SET
name=$2, bank_account=$3, phone=$4, email=$5, ceo_name=$6, biz_no=$7, address=$8,
is_default=$9, sort_order=$10
WHERE code=$1`,
[code, name, bankAccount, phone ?? null, email ?? null, ceoName ?? null, bizNo ?? null, address ?? null, isDefault === "Y" ? "Y" : "N", Number(sortOrder) || 0]
);
}
invalidateBranchCache();
return NextResponse.json({ success: true });
}
+2 -2
View File
@@ -139,8 +139,8 @@ export async function POST(req: NextRequest) {
bizNo: order.biz_no as string | undefined,
phone: order.phone as string | undefined,
},
supplier: (() => {
const b = getSupplierByBranch(order.statement_branch as string | undefined);
supplier: await (async () => {
const b = await getSupplierByBranch(order.statement_branch as string | undefined);
return { companyName: b.NAME, bankAccount: b.BANK_ACCOUNT, phone: b.PHONE, email: b.EMAIL };
})(),
items: items.map((it) => ({
+2 -2
View File
@@ -77,9 +77,9 @@ export async function POST(req: NextRequest) {
[objid]
);
// 공급자(모모유통) 정보 — 거래처(사용자)의 statement_branch (HQ / KIMPO) 에 따라 분기
// 공급자(모모유통) 정보 — 거래처(사용자)의 statement_branch 에 따라 DB 분기
const branch = (order as { STATEMENT_BRANCH?: string }).STATEMENT_BRANCH ?? "HQ";
const supplier = getSupplierByBranch(branch);
const supplier = await getSupplierByBranch(branch);
return NextResponse.json({ success: true, order, items, supplier });
}
+2 -2
View File
@@ -57,8 +57,8 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
bizNo: order.biz_no as string | undefined,
phone: order.phone as string | undefined,
},
supplier: (() => {
const b = getSupplierByBranch(order.statement_branch as string | undefined);
supplier: await (async () => {
const b = await getSupplierByBranch(order.statement_branch as string | undefined);
return { companyName: b.NAME, bankAccount: b.BANK_ACCOUNT, phone: b.PHONE, email: b.EMAIL };
})(),
items: items.map((it, idx) => {
+56 -7
View File
@@ -1,8 +1,13 @@
// 모모유통 공급자(supplier) 정보 — 거래명세표/계산서 발행 시 사용자별로 분기
// 사용자(user_info.statement_branch) 가 'HQ' / 'KIMPO' 중 어느 값이냐에 따라 적용
export type StatementBranch = "HQ" | "KIMPO";
// 모모유통 공급자(supplier) 정보 — DB(momo_statement_branches) 기반
// 거래명세표/계산서 발행 시 사용자(user_info.statement_branch) 별로 분기
//
// 관리자가 /m/admin/statement-branches 페이지에서 정보 편집 가능.
// API 요청 시마다 DB 조회하면 부담이라 60초 in-memory 캐시.
import { queryRows } from "@/lib/db";
export interface BranchSupplier {
CODE: string;
NAME: string;
CEO: string;
BANK_ACCOUNT: string;
@@ -12,8 +17,10 @@ export interface BranchSupplier {
ADDRESS: string;
}
export const SUPPLIER_BRANCHES: Record<StatementBranch, BranchSupplier> = {
// fallback (DB 가 비어있거나 마이그레이션 028 적용 전 환경)
const FALLBACK: Record<string, BranchSupplier> = {
HQ: {
CODE: "HQ",
NAME: "모모유통",
CEO: "이상용",
BANK_ACCOUNT: "기업은행 434-115361-01-016 (이상용)",
@@ -23,6 +30,7 @@ export const SUPPLIER_BRANCHES: Record<StatementBranch, BranchSupplier> = {
ADDRESS: "",
},
KIMPO: {
CODE: "KIMPO",
NAME: "모모유통",
CEO: "이상용",
BANK_ACCOUNT: "농협 351-1383-7634-13 (모모유통)",
@@ -33,7 +41,48 @@ export const SUPPLIER_BRANCHES: Record<StatementBranch, BranchSupplier> = {
},
};
export function getSupplierByBranch(branch?: string | null): BranchSupplier {
const key = (branch === "KIMPO" ? "KIMPO" : "HQ") as StatementBranch;
return SUPPLIER_BRANCHES[key];
let cache: { at: number; map: Record<string, BranchSupplier> } | null = null;
const CACHE_TTL_MS = 60_000;
async function loadBranches(): Promise<Record<string, BranchSupplier>> {
if (cache && Date.now() - cache.at < CACHE_TTL_MS) return cache.map;
try {
const rows = await queryRows<{
CODE: string; NAME: string; CEO: string | null; BANK_ACCOUNT: string;
PHONE: string | null; EMAIL: string | null; BIZ_NO: string | null; ADDRESS: string | null;
}>(
`SELECT code AS "CODE", name AS "NAME", ceo_name AS "CEO",
bank_account AS "BANK_ACCOUNT", phone AS "PHONE",
email AS "EMAIL", biz_no AS "BIZ_NO", address AS "ADDRESS"
FROM momo_statement_branches
WHERE COALESCE(is_del,'N') != 'Y'`
);
const map: Record<string, BranchSupplier> = {};
for (const r of rows) {
map[r.CODE] = {
CODE: r.CODE,
NAME: r.NAME,
CEO: r.CEO ?? "",
BANK_ACCOUNT: r.BANK_ACCOUNT,
PHONE: r.PHONE ?? "",
EMAIL: r.EMAIL ?? "",
BIZ_NO: r.BIZ_NO ?? "",
ADDRESS: r.ADDRESS ?? "",
};
}
cache = { at: Date.now(), map: { ...FALLBACK, ...map } };
return cache.map;
} catch {
return FALLBACK;
}
}
export async function getSupplierByBranch(branch?: string | null): Promise<BranchSupplier> {
const code = branch || "HQ";
const map = await loadBranches();
return map[code] ?? map.HQ ?? FALLBACK.HQ;
}
export function invalidateBranchCache() {
cache = null;
}