feat(cma): external input ledgers for M2 capability eval (tax/hr/licence)

3 new tables + idempotent DDL migration (prod guard: refuses DB name cma without approval): tax_filing_log (P_TAX_TIMELY), hr_monthly_ledger (L_EMPLOYEE_TURNOVER), licence_ledger (P_LICENCE_OK).

Entry API /api/cma/ext-ledger: create / CSV batch import / query by entity+period / derive trigger. on_time is server-derived only; HR rows rejected when end != begin + hires - separations.

Deriver writes kpi_values only (source_type=derived, idempotent upsert); denominator 0 returns None and writes nothing; never creates or changes kpi_definitions.

Tests: 22 new pytest cases (22 passed); scratch-DB e2e battery 28 assertions PASS with zero production write.
This commit is contained in:
Hermes CI Fix
2026-09-15 18:12:05 +08:00
parent 5d0884cc3b
commit 9d9a79545a
9 changed files with 1956 additions and 1 deletions
@@ -0,0 +1,364 @@
"""外部输入台账 单测 — 3 张外部输入表 + 录入接口 + 台账→kpi_values 派生
(任务 0-P0-project-20260915-cma-eval-m2-ext-input-tables,2026-09-15)
覆盖任务单「验收标准」中的硬性项:
① on_time 由服务端派生(传入值被忽略/拒绝)
② 人事台账「期末 ≠ 期初 + 入职 − 离职」被拒
③ 已过期证照不计入有效数
④ 派生函数:**分母为 0 返回 None**(不写 0、不填大数)+ 3 个指标各 1 个正例
⑤ 幂等:台账导入与派生各跑两次,第二次输出/行数与第一次一致
⑥ 多租户:跨账套显式 entity_id → 403
另覆盖:CSV 批量导入、按 (entity_id, period) 查询、唯一键冲突 409、季频期间归集。
"""
import os
import sys
from datetime import date
from types import SimpleNamespace
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import pytest # noqa: E402
from app.models import KPIDefinition, KPIValue, HrMonthlyLedger, LicenceLedger, TaxFilingLog # noqa: E402
from app.services import ext_input_derive as D # noqa: E402
from tests.conftest import auth_header, create_test_kpi, create_test_user, get_token_for_user # noqa: E402
TAX_CSV = """period,tax_type,legal_deadline,filed_date,evidence_no
2026-08,增值税,2026-08-15,2026-08-12,HZ-001
2026-08,附加税,2026-08-15,2026-08-12,HZ-002
2026-08,印花税,2026-08-15,2026-08-20,HZ-003
"""
HR_CSV = """period,headcount_begin,hires,separations,headcount_end
2026-08,100,1,2,99
2026-07,98,2,0,100
"""
LIC_CSV = """licence_name,licence_no,valid_from,valid_to,annual_check_status,issuer
营业执照,91610100MA0001,2020-01-01,2030-01-01,已年检,市场监管局
食品经营许可证,JY1610100001,2024-01-01,2026-03-31,过期,市场监管局
"""
def _rows(db, model, **filters):
q = db.query(model)
for k, v in filters.items():
q = q.filter(getattr(model, k) == v)
return q.all()
# ============================================================
# 纯函数:on_time 派生 / 分母为 0 / 三个指标正例
# ============================================================
def test_on_time_is_server_derived():
"""on_time 唯一规则:按时=1 / 逾期=0 / 未申报=0 / 缺法定期限且已申报=无法判定(None)"""
assert D.derive_on_time(date(2026, 8, 12), date(2026, 8, 15)) == 1
assert D.derive_on_time(date(2026, 8, 15), date(2026, 8, 15)) == 1
assert D.derive_on_time(date(2026, 8, 20), date(2026, 8, 15)) == 0
assert D.derive_on_time(None, date(2026, 8, 15)) == 0 # 未申报
assert D.derive_on_time(date(2026, 8, 12), None) is None # 无法判定
def test_denominator_zero_returns_none():
"""硬规则:分母为 0 → None(无定义/不参评),不得写 0、不得填大数"""
assert D.compute_tax_timely_rate([]) is None
assert D.compute_employee_turnover(SimpleNamespace(headcount_begin=0, separations=3,
headcount_end=0, hires=0)) is None
assert D.compute_licence_ok([], date(2026, 6, 30)) is None
assert D.compute_employee_turnover(None) is None
# explain_* 同源,no_definition 同步为 True
assert D.explain_tax_timely_rate([])["no_definition"] is True
assert D.explain_employee_turnover(SimpleNamespace(headcount_begin=0, separations=0,
headcount_end=0, hires=0))["no_definition"] is True
assert D.explain_licence_ok([], date(2026, 6, 30))["no_definition"] is True
def test_tax_timely_rate_positive():
"""正例:3 个应申报税种,2 个按时 → 66.7%(未知 on_time 不计入分子,仍计入分母)"""
rows = [SimpleNamespace(on_time=1), SimpleNamespace(on_time=1), SimpleNamespace(on_time=0)]
assert D.compute_tax_timely_rate(rows) == 66.7
rows2 = [SimpleNamespace(on_time=1), SimpleNamespace(on_time=None)]
assert D.compute_tax_timely_rate(rows2) == 50.0
def test_employee_turnover_positive():
"""正例:期初 100 离职 2 → 2.0%;并列返回期初期末平均在职口径(99 人 → 2.02%)"""
row = SimpleNamespace(headcount_begin=100, headcount_end=98, hires=1, separations=2)
assert D.compute_employee_turnover(row) == 2.0
detail = D.explain_employee_turnover(row)
assert detail["avg_headcount"] == 99.0
assert detail["value_on_avg"] == 2.02
def test_licence_ok_excludes_expired():
"""正例:4 张证照,1 张过期 + 1 张已到期 → 有效 2 张 = 50.0%(valid_to 空=长期有效)"""
as_of = date(2026, 6, 30)
rows = [
SimpleNamespace(annual_check_status="已年检", valid_to=date(2030, 1, 1), licence_no="A"),
SimpleNamespace(annual_check_status="过期", valid_to=date(2030, 1, 1), licence_no="B"),
SimpleNamespace(annual_check_status="未到期", valid_to=date(2026, 3, 31), licence_no="C"),
SimpleNamespace(annual_check_status="未到期", valid_to=None, licence_no="D"),
]
assert D.compute_licence_ok(rows, as_of) == 50.0
detail = D.explain_licence_ok(rows, as_of)
assert detail["valid"] == 2 and detail["denominator"] == 4
assert detail["expired_or_outdated"] == ["B"]
def test_headcount_equation_check():
assert D.check_headcount_equation(100, 1, 2, 99) is None
msg = D.check_headcount_equation(100, 2, 1, 99)
assert msg and "期末(99)" in msg and "101" in msg
def test_quarter_helpers():
assert D.month_to_quarter("2026-06") == "2026-Q2"
assert D.month_to_quarter("2026-01") == "2026-Q1"
assert D.quarter_end_date("2026-Q2") == date(2026, 6, 30)
assert D.quarter_end_date("2026-06") == date(2026, 6, 30)
assert D.quarter_end_date("2026-Q4") == date(2026, 12, 31)
# ============================================================
# API:新增 / on_time 忽略 / 唯一键 409 / 校验拒绝
# ============================================================
def test_create_tax_filing_ignores_client_on_time(client, db):
"""① on_time 服务端派生:请求里带 on_time=1 但 filed_date 晚于法定期限 → 落库 0 + 回报被忽略"""
create_test_user(db)
token = get_token_for_user(client)
resp = client.post("/api/cma/ext-ledger/tax-filings", headers=auth_header(token), json={
"period": "2026-08", "tax_type": "增值税",
"legal_deadline": "2026-08-15", "filed_date": "2026-08-20",
"on_time": 1, "evidence_no": "HZ-001",
})
assert resp.status_code == 201, resp.text
body = resp.json()
assert body["data"]["on_time"] == 0 # 服务端派生(未被手填值污染)
assert body["on_time_derived"] == 0
assert body["on_time_ignored"] is True
row = _rows(db, TaxFilingLog, period="2026-08")[0]
assert (row.on_time, row.entity_id) == (0, 1)
def test_create_tax_filing_duplicate_409(client, db):
"""唯一键 (entity_id, period, tax_type) 冲突 → 409,不产生第二行"""
create_test_user(db)
token = get_token_for_user(client)
payload = {"period": "2026-08", "tax_type": "增值税",
"legal_deadline": "2026-08-15", "filed_date": "2026-08-12"}
assert client.post("/api/cma/ext-ledger/tax-filings", headers=auth_header(token),
json=payload).status_code == 201
dup = client.post("/api/cma/ext-ledger/tax-filings", headers=auth_header(token), json=payload)
assert dup.status_code == 409
assert "已存在" in dup.json()["detail"]
assert len(_rows(db, TaxFilingLog)) == 1
def test_hr_equation_rejected(client, db):
"""② 期末 ≠ 期初 + 入职 − 离职 → 400 拒绝写入(台账零新增)"""
create_test_user(db)
token = get_token_for_user(client)
resp = client.post("/api/cma/ext-ledger/hr-monthly", headers=auth_header(token), json={
"period": "2026-08", "headcount_begin": 100, "hires": 2, "separations": 1,
"headcount_end": 99,
})
assert resp.status_code == 400
assert "等式不成立" in resp.json()["detail"]
assert len(_rows(db, HrMonthlyLedger)) == 0
ok = client.post("/api/cma/ext-ledger/hr-monthly", headers=auth_header(token), json={
"period": "2026-08", "headcount_begin": 100, "hires": 2, "separations": 1,
"headcount_end": 101,
})
assert ok.status_code == 201 and ok.json()["data"]["turnover_rate"] == 1.0
def test_licence_invalid_status_rejected(client, db):
"""证照年检状态白名单校验:非法值 → 400(枚举来自 PRD §4.0)"""
create_test_user(db)
token = get_token_for_user(client)
bad = client.post("/api/cma/ext-ledger/licences", headers=auth_header(token), json={
"licence_name": "营业执照", "licence_no": "X1", "annual_check_status": "有效"})
assert bad.status_code == 400 and "annual_check_status" in bad.json()["detail"]
assert len(_rows(db, LicenceLedger)) == 0
def test_cross_entity_403(client, db):
"""⑥ 多租户:会话账套 entity 1 + 显式 entity_id=2 → 403(文案同 deps.get_entity_id)"""
create_test_user(db)
token = get_token_for_user(client)
resp = client.post("/api/cma/ext-ledger/hr-monthly?entity_id=2", headers=auth_header(token), json={
"period": "2026-08", "headcount_begin": 10, "hires": 0, "separations": 0, "headcount_end": 10})
assert resp.status_code == 403
assert "无权访问企业 entity_id=2" in resp.json()["detail"]
assert len(_rows(db, HrMonthlyLedger)) == 0
def test_unauthenticated_401(client):
"""未认证 → 401(router 级 require_auth)"""
assert client.get("/api/cma/ext-ledger/options").status_code == 401
assert client.post("/api/cma/ext-ledger/derive", json={"period": "2026-08"}).status_code == 401
# ============================================================
# CSV 批量导入 + 查询 + 幂等
# ============================================================
def test_csv_import_tax_idempotent(client, db):
"""CSV 导入两次:第一次 inserted=3,第二次 inserted=0/skipped=3,行数不变"""
create_test_user(db)
token = get_token_for_user(client)
r1 = client.post("/api/cma/ext-ledger/tax-filings/import", headers=auth_header(token),
json={"csv": TAX_CSV})
assert r1.status_code == 200, r1.text
assert (r1.json()["inserted"], r1.json()["skipped"]) == (3, 0)
r2 = client.post("/api/cma/ext-ledger/tax-filings/import", headers=auth_header(token),
json={"csv": TAX_CSV})
assert (r2.json()["inserted"], r2.json()["skipped"]) == (0, 3)
rows = _rows(db, TaxFilingLog, period="2026-08")
assert len(rows) == 3
assert {r.on_time for r in rows} == {1, 0} # 印花税 08-20 > 08-15 → 0
# 按 (entity_id, period) 查询 + 及时率摘要(2/3 = 66.7)
q = client.get("/api/cma/ext-ledger/tax-filings?period=2026-08", headers=auth_header(token))
assert q.status_code == 200
assert q.json()["total"] == 3
assert q.json()["summary"]["tax_timely_rate"] == 66.7
def test_csv_import_hr_reports_bad_rows(client, db):
"""批量导入:等式不成立的行被拒绝并在 errors 回报,合法行照常写入"""
create_test_user(db)
token = get_token_for_user(client)
bad_csv = HR_CSV + "2026-06,50,3,1,51\n"
resp = client.post("/api/cma/ext-ledger/hr-monthly/import", headers=auth_header(token),
json={"csv": bad_csv})
assert resp.status_code == 200
body = resp.json()
assert body["inserted"] == 2 and body["error_count"] == 1
assert "等式不成立" in body["errors"][0]["reason"]
assert body["errors"][0]["line"] == 4
assert len(_rows(db, HrMonthlyLedger)) == 2
q = client.get("/api/cma/ext-ledger/hr-monthly?period=2026-08", headers=auth_header(token))
assert q.json()["total"] == 1 and q.json()["data"][0]["turnover_rate"] == 2.0
def test_csv_import_dry_run_writes_nothing(client, db):
create_test_user(db)
token = get_token_for_user(client)
resp = client.post("/api/cma/ext-ledger/licences/import", headers=auth_header(token),
json={"csv": LIC_CSV, "dry_run": True})
assert resp.status_code == 200 and resp.json()["inserted"] == 2
assert len(_rows(db, LicenceLedger)) == 0
def test_licences_query_valid_only(client, db):
"""③ 已过期证照不计入有效数(查询侧 valid_only 与摘要口径一致)"""
create_test_user(db)
token = get_token_for_user(client)
assert client.post("/api/cma/ext-ledger/licences/import", headers=auth_header(token),
json={"csv": LIC_CSV}).json()["inserted"] == 2
allq = client.get("/api/cma/ext-ledger/licences?as_of=2026-06-30", headers=auth_header(token)).json()
assert allq["total"] == 2
assert allq["summary"]["licence_ok"] == 50.0 # 1/2
assert allq["summary"]["valid"] == 1
only = client.get("/api/cma/ext-ledger/licences?as_of=2026-06-30&valid_only=true",
headers=auth_header(token)).json()
assert only["total"] == 1 and only["data"][0]["licence_no"] == "91610100MA0001"
def test_options_endpoint_lists_enums(client, db):
"""表单下拉选项从系统数据动态取(禁前端硬编码)"""
create_test_user(db)
token = get_token_for_user(client)
body = client.get("/api/cma/ext-ledger/options", headers=auth_header(token)).json()
assert body["annual_check_status"] == ["未到期", "已年检", "待年检", "过期"]
assert set(body["derivable_kpi_codes"]) == {"P_TAX_TIMELY", "L_EMPLOYEE_TURNOVER", "P_LICENCE_OK"}
# ============================================================
# 派生:幂等 / 分母 0 不写 / 季频归集 / 定义缺失跳过
# ============================================================
def _seed_defs(db, entity_id=1):
return {
"P_TAX_TIMELY": create_test_kpi(db, entity_id=entity_id, kpi_code="P_TAX_TIMELY", kpi_name="税务申报及时率"),
"L_EMPLOYEE_TURNOVER": create_test_kpi(db, entity_id=entity_id, kpi_code="L_EMPLOYEE_TURNOVER", kpi_name="员工流失率"),
"P_LICENCE_OK": create_test_kpi(db, entity_id=entity_id, kpi_code="P_LICENCE_OK", kpi_name="经营许可合规率"),
}
def test_derive_idempotent_and_denominator_zero(client, db):
"""④⑤ 派生跑两次:kpi_values 行数不变(第二次全 upsert);期初为 0 → 无定义不写值"""
create_test_user(db)
token = get_token_for_user(client)
_seed_defs(db)
# 台账:税务 2 条(1 按时);人事期初 0(→ 无定义);证照 2 条(1 过期)
client.post("/api/cma/ext-ledger/tax-filings/import", headers=auth_header(token), json={
"csv": "period,tax_type,legal_deadline,filed_date\n"
"2026-08,增值税,2026-08-15,2026-08-12\n"
"2026-08,附加税,2026-08-15,2026-08-20\n"})
client.post("/api/cma/ext-ledger/hr-monthly/import", headers=auth_header(token), json={
"csv": "period,headcount_begin,hires,separations,headcount_end\n2026-08,0,0,0,0\n"})
assert client.post("/api/cma/ext-ledger/licences/import", headers=auth_header(token),
json={"csv": LIC_CSV}).json()["inserted"] == 2
first = client.post("/api/cma/ext-ledger/derive", headers=auth_header(token),
json={"period": "2026-08"}).json()
assert first["items"]["P_TAX_TIMELY"]["actual_value"] == 50.0
assert first["items"]["P_TAX_TIMELY"]["action"] == "insert"
assert first["items"]["L_EMPLOYEE_TURNOVER"]["actual_value"] is None
assert first["items"]["L_EMPLOYEE_TURNOVER"]["action"] == "skipped_denominator_zero"
assert first["items"]["P_LICENCE_OK"]["actual_value"] == 50.0
assert first["items"]["P_LICENCE_OK"]["period"] == "2026-Q3" # 2026-08 → Q3
n1 = db.query(KPIValue).count()
assert n1 == 2 # 无定义指标零写入
second = client.post("/api/cma/ext-ledger/derive", headers=auth_header(token),
json={"period": "2026-08"}).json()
assert second["items"]["P_TAX_TIMELY"]["action"] == "upsert"
assert second["items"]["P_LICENCE_OK"]["action"] == "upsert"
assert db.query(KPIValue).count() == n1 # 幂等:行数不变
tax_row = _rows(db, KPIValue, period="2026-08")[0]
assert (tax_row.actual_value, tax_row.source_type, tax_row.entity_id) == (50.0, "derived", 1)
def test_derive_skips_missing_definition(client, db):
"""不新建/不改动 KPI 定义:定义缺失 → skipped_no_definition(不写值)"""
create_test_user(db)
token = get_token_for_user(client)
create_test_kpi(db, entity_id=1, kpi_code="P_TAX_TIMELY")
client.post("/api/cma/ext-ledger/tax-filings/import", headers=auth_header(token), json={
"csv": "period,tax_type,legal_deadline,filed_date\n2026-08,增值税,2026-08-15,2026-08-12\n"})
body = client.post("/api/cma/ext-ledger/derive", headers=auth_header(token),
json={"period": "2026-08"}).json()
assert body["items"]["P_TAX_TIMELY"]["action"] == "insert"
assert body["items"]["L_EMPLOYEE_TURNOVER"]["action"] == "skipped_no_definition"
assert body["items"]["P_LICENCE_OK"]["action"] == "skipped_no_definition"
assert db.query(KPIValue).count() == 1
assert db.query(KPIDefinition).count() == 1 # 定义数量未变(本模块不新建定义)
def test_derive_dry_run_writes_nothing(client, db):
create_test_user(db)
token = get_token_for_user(client)
_seed_defs(db)
client.post("/api/cma/ext-ledger/tax-filings/import", headers=auth_header(token), json={
"csv": "period,tax_type,legal_deadline,filed_date\n2026-08,增值税,2026-08-15,2026-08-12\n"})
body = client.post("/api/cma/ext-ledger/derive", headers=auth_header(token),
json={"period": "2026-08", "kpi_codes": ["P_TAX_TIMELY"], "dry_run": True}).json()
assert body["items"]["P_TAX_TIMELY"]["action"] == "dry-run"
assert db.query(KPIValue).count() == 0
def test_derive_status_readonly(client, db):
create_test_user(db)
token = get_token_for_user(client)
_seed_defs(db)
body = client.get("/api/cma/ext-ledger/derive/status?period=2026-06",
headers=auth_header(token)).json()
assert body["items"]["P_LICENCE_OK"]["target_period"] == "2026-Q2"
assert body["items"]["P_LICENCE_OK"]["latest_derived"] is None