feat(branch-fee): 지사 수수료 관리 메뉴 — 본사 20% 수수료 산정
Deploy momo-erp / deploy (push) Successful in 2m9s

신규 메뉴 /m/admin/branch-fee — 거래처의 statement_branch(HQ/KIMPO 등)
기준으로 매출/원가/순수 마진 그룹핑 + 지사(HQ 외) 의 마진 × 20% =
본사 수수료 자동 계산.

표시:
- 합계 카드 4개: 총 매출 / 총 마진 / 본사 수수료 합 / 지사 실수령 합
- 지사별 표: 매출/원가/마진/수수료/실수령(마진-수수료)
- 본사(HQ) 행은 수수료 0 (— 표시)

집계 범위: 출고완료(APPROVED)/계산서발행(INVOICED)/입금완료(PAID) 발주.
운영 DB 의 menu_info objid=9000510 으로 등록 완료. (parent 9000500 통계)
This commit is contained in:
chpark
2026-05-14 14:49:43 +09:00
parent b568a8858a
commit 9fd1160b38
2 changed files with 213 additions and 0 deletions
+147
View File
@@ -0,0 +1,147 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Building2, MapPin, TrendingUp, RefreshCcw } from "lucide-react";
interface BranchRow {
BRANCH: string; BRANCH_NAME: string;
REVENUE: number; COST: number; MARGIN: number; ORDER_CNT: number;
HQ_FEE_RATE: number; HQ_FEE_AMOUNT: number; NET_TO_BRANCH: number;
}
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
export default function BranchFeePage() {
const [year, setYear] = useState(new Date().getFullYear());
const [month, setMonth] = useState(new Date().getMonth() + 1);
const [rows, setRows] = useState<BranchRow[]>([]);
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await fetch("/api/m/admin/branch-fee", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ year, month }),
});
setRows((await res.json()).RESULTLIST ?? []);
} finally { setLoading(false); }
}, [year, month]);
useEffect(() => { load(); }, [load]);
// 합계
const totalRevenue = rows.reduce((a, r) => a + r.REVENUE, 0);
const totalMargin = rows.reduce((a, r) => a + r.MARGIN, 0);
const totalHqFee = rows.reduce((a, r) => a + r.HQ_FEE_AMOUNT, 0);
const totalNet = rows.reduce((a, r) => a + r.NET_TO_BRANCH, 0);
return (
<div className="space-y-4">
<div className="flex items-end justify-between flex-wrap gap-2">
<div>
<h1 className="text-xl font-bold flex items-center gap-2">
<Building2 size={20} className="text-emerald-700" />
</h1>
<p className="text-xs text-slate-500 mt-0.5">
/ + ( 20%) . <b> </b> .
</p>
</div>
<div className="flex items-center gap-2">
<select value={year} onChange={(e) => setYear(Number(e.target.value))}
className="h-9 px-3 rounded border border-slate-300 bg-white text-sm">
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i).map((y) => (
<option key={y} value={y}>{y}</option>
))}
</select>
<select value={month} onChange={(e) => setMonth(Number(e.target.value))}
className="h-9 px-3 rounded border border-slate-300 bg-white text-sm">
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => (
<option key={m} value={m}>{m}</option>
))}
</select>
<button onClick={load} disabled={loading}
className="h-9 px-3 rounded bg-slate-800 text-white text-sm font-bold inline-flex items-center gap-1 hover:bg-slate-900 disabled:opacity-50">
<RefreshCcw size={14} />
</button>
</div>
</div>
{/* 합계 카드 */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<div className="bg-white border border-slate-200 rounded-xl p-4">
<div className="text-[11px] text-slate-500 mb-1"> </div>
<div className="text-xl font-bold text-slate-800 tabular-nums">{fmt(totalRevenue)}</div>
</div>
<div className="bg-white border border-slate-200 rounded-xl p-4">
<div className="text-[11px] text-slate-500 mb-1"> </div>
<div className="text-xl font-bold text-emerald-700 tabular-nums">{fmt(totalMargin)}</div>
</div>
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
<div className="text-[11px] text-amber-700 mb-1 font-semibold"> ( 20%)</div>
<div className="text-xl font-bold text-amber-700 tabular-nums">{fmt(totalHqFee)}</div>
</div>
<div className="bg-white border border-slate-200 rounded-xl p-4">
<div className="text-[11px] text-slate-500 mb-1"> ( )</div>
<div className="text-xl font-bold text-slate-800 tabular-nums">{fmt(totalNet)}</div>
</div>
</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">
<tr>
<th className="text-left px-4 py-3"></th>
<th className="text-right px-4 py-3"></th>
<th className="text-right px-4 py-3">()</th>
<th className="text-right px-4 py-3"></th>
<th className="text-right px-4 py-3"> </th>
<th className="text-right px-4 py-3 bg-amber-50 text-amber-700"> (20%)</th>
<th className="text-right px-4 py-3"> </th>
</tr>
</thead>
<tbody className="tabular-nums">
{rows.length === 0 ? (
<tr><td colSpan={7} className="text-center py-12 text-slate-400">{loading ? "조회 중..." : "데이터가 없습니다."}</td></tr>
) : rows.map((r) => {
const isHQ = r.BRANCH === "HQ";
return (
<tr key={r.BRANCH} className="border-t border-slate-100">
<td className="px-4 py-3">
<div className="inline-flex items-center gap-1.5">
{isHQ
? <Building2 size={14} className="text-emerald-700" />
: <MapPin size={14} className="text-sky-700" />}
<span className="font-semibold">{r.BRANCH_NAME}</span>
<span className="text-xs text-slate-400">({r.BRANCH})</span>
</div>
</td>
<td className="px-4 py-3 text-right">{fmt(r.ORDER_CNT)}</td>
<td className="px-4 py-3 text-right">{fmt(r.REVENUE)}</td>
<td className="px-4 py-3 text-right text-slate-500">{fmt(r.COST)}</td>
<td className="px-4 py-3 text-right font-semibold text-emerald-700">{fmt(r.MARGIN)}</td>
<td className="px-4 py-3 text-right bg-amber-50/60 font-bold text-amber-700">
{isHQ
? <span className="text-slate-300"></span>
: <>{fmt(r.HQ_FEE_AMOUNT)}</>}
</td>
<td className="px-4 py-3 text-right font-bold">{fmt(r.NET_TO_BRANCH)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="text-[11px] text-slate-500 flex items-start gap-1.5">
<TrendingUp size={12} className="mt-0.5 text-slate-400" />
<div>
= (HQ ) × 20%. (HQ) 0.
/ (APPROVED) (/ ) .
</div>
</div>
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
// 지사별 매출/마진/본사 수수료(20%) 집계
// statement_branch 컬럼이 user_info(거래처) 에 박혀있어 그 값으로 그룹핑.
// 본사(HQ) 는 수수료 0, 지사(KIMPO 등) 는 순수 마진의 20% 가 본사 수수료.
import { NextRequest, NextResponse } from "next/server";
import { queryRows } from "@/lib/db";
import { requireMomoAdmin } from "@/lib/momo-guard";
const HQ_FEE_RATE = 0.2; // 본사 수수료 20%
export async function POST(req: NextRequest) {
const g = await requireMomoAdmin();
if (g instanceof NextResponse) return g;
const body = await req.json().catch(() => ({}));
const y = Number(body.year) || new Date().getFullYear();
const m = Number(body.month) || new Date().getMonth() + 1;
const rows = await queryRows<{ BRANCH: string; REVENUE: string; COST: string; MARGIN: string; CNT: string }>(
`SELECT
COALESCE(U.statement_branch, 'HQ') AS "BRANCH",
COALESCE(SUM(OI.supply_amount), 0) AS "REVENUE",
COALESCE(SUM(OI.qty * COALESCE(I.cost_price, 0)), 0) AS "COST",
COALESCE(SUM(OI.supply_amount) - SUM(OI.qty * COALESCE(I.cost_price, 0)), 0) AS "MARGIN",
COUNT(DISTINCT O.objid) AS "CNT"
FROM momo_orders O
JOIN momo_order_items OI ON O.objid = OI.order_objid
LEFT JOIN momo_items I ON OI.item_objid = I.objid
LEFT JOIN user_info U ON U.user_id = O.customer_objid
WHERE EXTRACT(YEAR FROM O.order_date) = $1
AND EXTRACT(MONTH FROM O.order_date) = $2
AND O.status IN ('APPROVED','PAID','INVOICED')
AND COALESCE(O.is_del,'N') != 'Y'
AND COALESCE(OI.kind, 'ITEM') = 'ITEM'
GROUP BY COALESCE(U.statement_branch, 'HQ')
ORDER BY "BRANCH"`,
[y, m]
);
// 지사명 매핑 + 본사 수수료 계산
const branchNames: Record<string, string> = await (async () => {
const b = await queryRows<{ CODE: string; NAME: string }>(
`SELECT code AS "CODE", name AS "NAME" FROM momo_statement_branches WHERE COALESCE(is_del,'N') != 'Y'`
);
const m: Record<string, string> = {};
for (const r of b) m[r.CODE] = r.NAME;
return m;
})();
const result = rows.map((r) => {
const margin = Number(r.MARGIN);
const isHQ = r.BRANCH === "HQ";
const hqFee = isHQ ? 0 : Math.round(margin * HQ_FEE_RATE);
return {
BRANCH: r.BRANCH,
BRANCH_NAME: branchNames[r.BRANCH] ?? (r.BRANCH === "HQ" ? "본사" : r.BRANCH),
REVENUE: Number(r.REVENUE),
COST: Number(r.COST),
MARGIN: margin,
ORDER_CNT: Number(r.CNT),
HQ_FEE_RATE: isHQ ? 0 : HQ_FEE_RATE,
HQ_FEE_AMOUNT: hqFee,
NET_TO_BRANCH: margin - hqFee, // 지사가 가져가는 금액 (수수료 제외)
};
});
return NextResponse.json({ RESULTLIST: result, YEAR: y, MONTH: m });
}