From 6be903fbc9e66fa15e518f333ef24f844aa6eb95 Mon Sep 17 00:00:00 2001 From: Hermes CI Fix Date: Fri, 2 Oct 2026 11:22:38 +0800 Subject: [PATCH] fix(bep-caliber): expense and profit sums use net (debit minus credit) under carry_forward=0 ASK-20261002-06 fix04u: admin(5602%) and depreciation(560213) take net amount in both the dashboard card helper and the BEP deriver; sales (5001) keeps credit_amount. Verbatim per proposal section 2. No DB writes, no kpi_values backfill. --- backend/app/api/dashboard.py | 15 ++- backend/scripts/kpi_bep_deriver.py | 21 ++-- backend/tests/test_bep_expense_net_caliber.py | 97 +++++++++++++++++++ 3 files changed, 121 insertions(+), 12 deletions(-) create mode 100644 backend/tests/test_bep_expense_net_caliber.py diff --git a/backend/app/api/dashboard.py b/backend/app/api/dashboard.py index bff1af69..5e624815 100644 --- a/backend/app/api/dashboard.py +++ b/backend/app/api/dashboard.py @@ -27,8 +27,8 @@ router = APIRouter(prefix="/api/cma/dashboard", tags=["驾驶舱"], # ── 本量利·保本点 卡片只读数据(cma-bep-kpi Phase2 2026-09-08)── # 口径与 backend/scripts/kpi_bep_deriver.py 完全一致: # 月销 = voucher_details subject 5001(主营业务收入) 贷方 YTD 月均(万元) -# 月管理费 = 5602% 借方 YTD 月均(账面口径含折旧) -# 折旧 = 560213 借方 YTD 月均 +# 月管理费 = 5602% 借−贷 净额 YTD 月均(carry_forward=0 行域;账面口径含折旧) +# 折旧 = 560213 借−贷 净额 YTD 月均(同上;口径4 见 _decisions/cma-expense-profit-net-caliber-20261002.md) # 渠补率 = 厂家政策 84.61%(财务Bot权威口径) # 保本额 = 月固定成本 ÷ (1-渠补率) # 账面口径(含折旧) / 现金口径(剔除折旧) 双口径只读返回,无写操作。 @@ -46,9 +46,14 @@ def _bep_sum_field(db, entity_id: int, period_from: str, period_to: str, field: str, subject_filter=None) -> float: """聚合 voucher_details 金额(元),subject_filter: ('eq', code) / ('like', prefix) + field="net" = 借−贷 净额(口径4:费用/损益一律净额)。 + ⚠️ 净额只在 carry_forward==0 行域成立(含结转腿时损益类净额恒 0) + —— 见 /srv/hermes/wisdom/_decisions/cma-expense-profit-net-caliber-20261002.md。 作废/撤单标记过滤(2026-09-14 收口): 已作废凭证不参与 BEP 均值。 """ - q = db.query(func.sum(getattr(VoucherDetail, field))).filter( + expr = (VoucherDetail.debit_amount - VoucherDetail.credit_amount + if field == "net" else getattr(VoucherDetail, field)) + q = db.query(func.sum(expr)).filter( VoucherDetail.entity_id == entity_id, VoucherDetail.carry_forward == 0, VoucherDetail.period >= period_from, @@ -131,9 +136,9 @@ def get_bep_card( sales_avg = _bep_sum_field(db, entity_id, period_from, period_to, "credit_amount", ("eq", BEP_SALES_SUBJECT)) / 10000.0 / months admin_avg = _bep_sum_field(db, entity_id, period_from, period_to, - "debit_amount", ("like", BEP_ADMIN_PREFIX)) / 10000.0 / months + "net", ("like", BEP_ADMIN_PREFIX)) / 10000.0 / months dep_avg = _bep_sum_field(db, entity_id, period_from, period_to, - "debit_amount", ("eq", BEP_DEP_SUBJECT)) / 10000.0 / months + "net", ("eq", BEP_DEP_SUBJECT)) / 10000.0 / months rebate_pct = BEP_REBATE_POLICY_PCT cm_pct = 100.0 - rebate_pct diff --git a/backend/scripts/kpi_bep_deriver.py b/backend/scripts/kpi_bep_deriver.py index b770c150..0429f92a 100644 --- a/backend/scripts/kpi_bep_deriver.py +++ b/backend/scripts/kpi_bep_deriver.py @@ -9,8 +9,8 @@ 口径(与财务Bot报告一致): - 销售收入: voucher_details.subject_code='5001'(主营业务收入) 贷方合计,当年1月至目标月 YTD 月均 -- 管理费: subject_code LIKE '5602%'(管理费用,含560213折旧) 借方合计,YTD 月均 → 账面口径 -- 折旧: subject_code = '560213'(管理费用-折旧叶子) 借方合计,YTD 月均 → 现金口径剔除项(eq精确匹配,防5602130误含) +- 管理费: subject_code LIKE '5602%'(管理费用,含560213折旧) 借−贷净额合计(carry_forward=0),YTD 月均 → 账面口径 +- 折旧: subject_code = '560213'(管理费用-折旧叶子) 借−贷净额合计(carry_forward=0),YTD 月均 → 现金口径剔除项(eq精确匹配,防5602130误含) - 渠补率: 厂家政策定死 84.61% (571103渠补支出/5001销售, 1-7月累计凭证验证≈84.0%~84.6%) - 边际贡献率 = 1 - 渠补率 = 15.39% - 目标月: --period 显式指定;默认自动选最近「已结账」完整月(当月跳过)。 @@ -137,8 +137,15 @@ def _month_range(period: str): def _sum_field(db, entity_id: int, period_from: str, period_to: str, field: str, subject_filter=None) -> float: - """聚合 voucher_details 金额(元)。subject_filter: ('eq', code) / ('like', prefix)""" - q = db.query(func.sum(getattr(VoucherDetail, field))).filter( + """聚合 voucher_details 金额(元)。subject_filter: ('eq', code) / ('like', prefix) + + field="net" = 借−贷 净额(口径4:费用/损益一律净额)。 + ⚠️ 净额只在 carry_forward==0 行域成立(含结转腿时损益类净额恒 0) + —— 见 /srv/hermes/wisdom/_decisions/cma-expense-profit-net-caliber-20261002.md。 + """ + expr = (VoucherDetail.debit_amount - VoucherDetail.credit_amount + if field == "net" else getattr(VoucherDetail, field)) + q = db.query(func.sum(expr)).filter( VoucherDetail.entity_id == entity_id, VoucherDetail.carry_forward == 0, VoucherDetail.period >= period_from, @@ -299,9 +306,9 @@ def run_derive(db, entity_id: int, period: str, rebate_rate_pct: float, # 1. 取数(元 → 万元) sales_sum_w = _sum_field(db, entity_id, period_from, period_to, "credit_amount", ("eq", SALES_CODE)) / 10000.0 - admin_sum_w = _sum_field(db, entity_id, period_from, period_to, "debit_amount", ("like", ADMIN_PREFIX)) / 10000.0 - rebate_sum_w = _sum_field(db, entity_id, period_from, period_to, "debit_amount", ("eq", REBATE_CODE)) / 10000.0 - dep_sum_w = _sum_field(db, entity_id, period_from, period_to, "debit_amount", ("eq", DEP_SUBJECT)) / 10000.0 + admin_sum_w = _sum_field(db, entity_id, period_from, period_to, "net", ("like", ADMIN_PREFIX)) / 10000.0 + rebate_sum_w = _sum_field(db, entity_id, period_from, period_to, "net", ("eq", REBATE_CODE)) / 10000.0 + dep_sum_w = _sum_field(db, entity_id, period_from, period_to, "net", ("eq", DEP_SUBJECT)) / 10000.0 sales_avg = sales_sum_w / months admin_avg = admin_sum_w / months diff --git a/backend/tests/test_bep_expense_net_caliber.py b/backend/tests/test_bep_expense_net_caliber.py new file mode 100644 index 00000000..a9560d04 --- /dev/null +++ b/backend/tests/test_bep_expense_net_caliber.py @@ -0,0 +1,97 @@ +"""口径4「费用取净额」单测 — ASK-20261002-06(2026-10-02) + +口径依据: /srv/hermes/wisdom/_decisions/cma-expense-profit-net-caliber-20261002.md + 「任何费用/利润取数一律净额(借−贷)」 + +覆盖两条实现面取数助手(改前均只认 debit/credit 单边字段): + · app/api/dashboard.py::_bep_sum_field(卡片现算面) + · scripts/kpi_bep_deriver.py::_sum_field(派生落库写入面) + +断言要点(含负对照,退回 debit_amount 口径时本文件必红): + 1. field="net" = 借−贷(行域上存在贷方冲销行 ⇒ 净额 ≠ 借方合计); + 2. carry_forward==0 过滤仍在位(结转腿行不参与); + 3. 收入侧 sales 仍取 credit_amount,未改为净额(改净额会变负)。 + 4. `5602%` LIKE 前缀天然包含 `560213`(账面口径含折旧),故 admin 净额 = 两科目之和。 +""" +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import datetime # noqa: E402 + +from app.models import VoucherDetail # noqa: E402 +from app.api.dashboard import _bep_sum_field # noqa: E402 +from scripts import kpi_bep_deriver as D # noqa: E402 + +D_DATE = datetime.datetime(2026, 8, 31) +PERIOD = "2026-08" + + +def _row(db, no, code, debit=0.0, credit=0.0, cf=0): + db.add(VoucherDetail(entity_id=1, voucher_no=str(no), voucher_date=D_DATE, + subject_code=code, subject_name="单测科目", + debit_amount=debit, credit_amount=credit, + summary="口径4 单测", carry_forward=cf, period=PERIOD)) + db.flush() + + +def _fixture(db): + """借贷双侧行域 + 一条结转腿(carry_forward=1)行。 + + 560202 : 借 1000 / 贷 200 → 净额 800(借方合计 1000) + 560213 : 借 500 / 贷 100 → 净额 400(借方合计 500) + 5001 : 贷 8000 / 借 0 → 收入仍取贷方 8000(取净额 = -8000) + 560202 cf=1 : 借 9999 → 结转腿,任何口径下都不得计入 + + LIKE '5602%' 命中 560202 + 560213(账面口径含折旧)⇒ admin 净额 = 1200。 + """ + _row(db, 91001, "560202", debit=1000.0) + _row(db, 91002, "560202", credit=200.0) + _row(db, 91003, "560213", debit=500.0) + _row(db, 91004, "560213", credit=100.0) + _row(db, 91005, "5001", credit=8000.0) + _row(db, 91006, "560202", debit=9999.0, cf=1) + + +def test_dashboard_net_caliber_admin_and_dep(db): + """改后:dashboard._bep_sum_field 支持 field='net'=借−贷;借方口径仍可用(负对照)。""" + _fixture(db) + net_admin = _bep_sum_field(db, 1, PERIOD, PERIOD, "net", ("like", "5602")) + debit_admin = _bep_sum_field(db, 1, PERIOD, PERIOD, "debit_amount", ("like", "5602")) + net_dep = _bep_sum_field(db, 1, PERIOD, PERIOD, "net", ("eq", "560213")) + debit_dep = _bep_sum_field(db, 1, PERIOD, PERIOD, "debit_amount", ("eq", "560213")) + + assert abs(net_admin - 1200.0) < 1e-6, net_admin # (1000-200) + (500-100) + assert abs(net_dep - 400.0) < 1e-6, net_dep # 500 - 100 + # 负对照:退回 debit_amount 口径时上面的 net 断言必红(1500/500 ≠ 1200/400) + assert abs(debit_admin - 1500.0) < 1e-6, debit_admin + assert abs(debit_dep - 500.0) < 1e-6, debit_dep + + +def test_dashboard_net_excludes_carry_forward_rows(db): + """carry_forward==0 过滤在位:结转腿行(cf=1,借 9999)不参与。""" + _fixture(db) + net_admin = _bep_sum_field(db, 1, PERIOD, PERIOD, "net", ("like", "5602")) + assert abs(net_admin - 1200.0) < 1e-6, net_admin # 含 cf=1 行则会是 1200+9999 + + +def test_dashboard_sales_still_credit_amount(db): + """收入侧未改:5001 仍取 credit_amount(若改净额 → 负数)。""" + _fixture(db) + sales_credit = _bep_sum_field(db, 1, PERIOD, PERIOD, "credit_amount", ("eq", "5001")) + sales_net = _bep_sum_field(db, 1, PERIOD, PERIOD, "net", ("eq", "5001")) + assert abs(sales_credit - 8000.0) < 1e-6, sales_credit + assert sales_net < 0, sales_net # 这正是「收入不得取净额」的反证 + + +def test_deriver_net_caliber_parity(db): + """派生器 _sum_field 与卡片助手同口径(两文件同批改的目的:避免同页两口径)。""" + _fixture(db) + d_net_admin = D._sum_field(db, 1, PERIOD, PERIOD, "net", ("like", "5602")) + d_net_dep = D._sum_field(db, 1, PERIOD, PERIOD, "net", ("eq", "560213")) + d_net_rebate = D._sum_field(db, 1, PERIOD, PERIOD, "net", ("eq", "571103")) + assert abs(d_net_admin - 1200.0) < 1e-6, d_net_admin + assert abs(d_net_dep - 400.0) < 1e-6, d_net_dep + assert abs(d_net_rebate - 0.0) < 1e-6, d_net_rebate # 本夹具无 571103 行 + assert abs(d_net_admin - _bep_sum_field(db, 1, PERIOD, PERIOD, "net", ("like", "5602"))) < 1e-6