From 9fd1160b38165484d8f583546351038b6ef1417f Mon Sep 17 00:00:00 2001 From: chpark Date: Thu, 14 May 2026 14:49:43 +0900 Subject: [PATCH] =?UTF-8?q?feat(branch-fee):=20=EC=A7=80=EC=82=AC=20?= =?UTF-8?q?=EC=88=98=EC=88=98=EB=A3=8C=20=EA=B4=80=EB=A6=AC=20=EB=A9=94?= =?UTF-8?q?=EB=89=B4=20=E2=80=94=20=EB=B3=B8=EC=82=AC=2020%=20=EC=88=98?= =?UTF-8?q?=EC=88=98=EB=A3=8C=20=EC=82=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 신규 메뉴 /m/admin/branch-fee — 거래처의 statement_branch(HQ/KIMPO 등) 기준으로 매출/원가/순수 마진 그룹핑 + 지사(HQ 외) 의 마진 × 20% = 본사 수수료 자동 계산. 표시: - 합계 카드 4개: 총 매출 / 총 마진 / 본사 수수료 합 / 지사 실수령 합 - 지사별 표: 매출/원가/마진/수수료/실수령(마진-수수료) - 본사(HQ) 행은 수수료 0 (— 표시) 집계 범위: 출고완료(APPROVED)/계산서발행(INVOICED)/입금완료(PAID) 발주. 운영 DB 의 menu_info objid=9000510 으로 등록 완료. (parent 9000500 통계) --- src/app/(main)/m/admin/branch-fee/page.tsx | 147 +++++++++++++++++++++ src/app/api/m/admin/branch-fee/route.ts | 66 +++++++++ 2 files changed, 213 insertions(+) create mode 100644 src/app/(main)/m/admin/branch-fee/page.tsx create mode 100644 src/app/api/m/admin/branch-fee/route.ts diff --git a/src/app/(main)/m/admin/branch-fee/page.tsx b/src/app/(main)/m/admin/branch-fee/page.tsx new file mode 100644 index 0000000..dde5588 --- /dev/null +++ b/src/app/(main)/m/admin/branch-fee/page.tsx @@ -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([]); + 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 ( +
+
+
+

+ + 지사 관리 — 본사 수수료 +

+

+ 지사별 매출/마진 + 본사 수수료(순수 마진의 20%) 산정. 거래처의 기준 거래명세서 값으로 그룹핑. +

+
+
+ + + +
+
+ + {/* 합계 카드 */} +
+
+
총 매출
+
₩{fmt(totalRevenue)}
+
+
+
총 순수 마진
+
₩{fmt(totalMargin)}
+
+
+
본사 수수료 (지사 마진의 20%)
+
₩{fmt(totalHqFee)}
+
+
+
지사 실수령 (마진 − 수수료)
+
₩{fmt(totalNet)}
+
+
+ + {/* 지사별 표 */} +
+ + + + + + + + + + + + + + {rows.length === 0 ? ( + + ) : rows.map((r) => { + const isHQ = r.BRANCH === "HQ"; + return ( + + + + + + + + + + ); + })} + +
지사건수매출(공급가)원가순수 마진본사 수수료 (20%)지사 실수령
{loading ? "조회 중..." : "데이터가 없습니다."}
+
+ {isHQ + ? + : } + {r.BRANCH_NAME} + ({r.BRANCH}) +
+
{fmt(r.ORDER_CNT)}{fmt(r.REVENUE)}{fmt(r.COST)}{fmt(r.MARGIN)} + {isHQ + ? + : <>₩{fmt(r.HQ_FEE_AMOUNT)}} + {fmt(r.NET_TO_BRANCH)}
+
+ +
+ +
+ 본사 수수료 = 지사(HQ 외)의 순수 마진 × 20%. 본사(HQ) 거래처 매출은 수수료 0. + 매출/마진은 출고완료(APPROVED) 이상 (계산서발행/입금완료 포함) 만 집계. +
+
+
+ ); +} diff --git a/src/app/api/m/admin/branch-fee/route.ts b/src/app/api/m/admin/branch-fee/route.ts new file mode 100644 index 0000000..77be0e3 --- /dev/null +++ b/src/app/api/m/admin/branch-fee/route.ts @@ -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 = 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 = {}; + 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 }); +}