依据《CMA 资产负债表重分类规则表 v1》(wecom-finance,任总 2026-09-16 批准落地)。 新增 backend/app/services/report_reclass.py:12 条规则挂 CMA 真实科目编码的取数层重分类引擎 - 余额方向规范化:资产 sum(借-贷) / 负债·权益 sum(贷-借),正常余额一律为正 - 触发判定全部基于原始余额(单遍、幂等),R3/R4 等互为镜像的规则互不干扰 - 科目取数前缀匹配(1122% 覆盖 112201 明细);备抵科目 1602 反向取数(修正固定资产净额虚增 2 倍折旧) - 只读 voucher_details + 作废守卫,不生成记账凭证 - 输出:触发条目 / 不变量 / 负数清单 / 模板待核 / 未列报科目 / 损益未结转 诊断 reports.py: - /balance-sheet 接入重分类;新增 basis 参数(end=期末余额累计为默认 / period=本期发生额兼容回退) - 有凭证明细时不再混入示例数据(展示口径与落库口径同源);行项目增 raw_value/reclass_adjustment 可追溯 - 规则落位项目(模板未单列)按虚拟行列报;补「长期待摊费用(1801)」行、未分配利润补 3103/3104 别名 - 新增 /balance-sheet/reclass-working-paper(+ /export) 底稿 JSON/Excel;statutory 导出新增「重分类底稿」Sheet 测试 tests/test_report_reclass.py 14 例(触发/不变量/幂等/多租户/口径/底稿/虚拟行/阻塞规则) 全量 pytest:1108 passed, 1 xfailed。现网只读验收 scripts/verify_reclass_live_20260916.py(酣客 entity1 2026-08)
This commit is contained in:
+253
-8
@@ -10,6 +10,7 @@ CMA管理报表中心 — 管理会计OS
|
|||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, Depends, Query, HTTPException, Request
|
from fastapi import APIRouter, Depends, Query, HTTPException, Request
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import func, or_
|
from sqlalchemy import func, or_
|
||||||
@@ -20,6 +21,7 @@ from app.auth_middleware import require_role, require_auth
|
|||||||
from app.models import KPIDefinition, KPIValue, BudgetPlan, StrategicMap, KPIAlert, User, Subject, ActionPlan, OperationLog, ReportHistory
|
from app.models import KPIDefinition, KPIValue, BudgetPlan, StrategicMap, KPIAlert, User, Subject, ActionPlan, OperationLog, ReportHistory
|
||||||
from app.utils.deviation_engine import calc_period_deviation, calc_period_diff, calc_deviation, get_budget_for_kpi
|
from app.utils.deviation_engine import calc_period_deviation, calc_period_diff, calc_deviation, get_budget_for_kpi
|
||||||
from app.utils.voucher_guard import apply_voucher_guard
|
from app.utils.voucher_guard import apply_voucher_guard
|
||||||
|
from app.services import report_reclass
|
||||||
from app.deps import get_entity_id, assert_entity_slug_scope, resolve_session_slug
|
from app.deps import get_entity_id, assert_entity_slug_scope, resolve_session_slug
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@@ -1378,6 +1380,8 @@ BALANCE_SHEET_SECTIONS = [
|
|||||||
{"codes": [("1501", 1)], "name": "持有至到期投资", "ns_category": "investing"},
|
{"codes": [("1501", 1)], "name": "持有至到期投资", "ns_category": "investing"},
|
||||||
{"codes": [("1601", 1), ("1602", -1)], "name": "固定资产净额", "ns_category": "operating"},
|
{"codes": [("1601", 1), ("1602", -1)], "name": "固定资产净额", "ns_category": "operating"},
|
||||||
{"codes": [("1701", 1)], "name": "无形资产", "ns_category": "operating"},
|
{"codes": [("1701", 1)], "name": "无形资产", "ns_category": "operating"},
|
||||||
|
# 长期待摊费用(1801):小企业准则项目池含该项,原模板漏列 → 致勾稽差额 128,758.24(2026-09-16 补)
|
||||||
|
{"codes": [("1801", 1)], "name": "长期待摊费用", "ns_category": "operating"},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1409,7 +1413,10 @@ BALANCE_SHEET_SECTIONS = [
|
|||||||
"lines": [
|
"lines": [
|
||||||
{"codes": [("4001", 1)], "name": "实收资本", "ns_category": "equity"},
|
{"codes": [("4001", 1)], "name": "实收资本", "ns_category": "equity"},
|
||||||
{"codes": [("4002", 1)], "name": "资本公积", "ns_category": "equity"},
|
{"codes": [("4002", 1)], "name": "资本公积", "ns_category": "equity"},
|
||||||
{"codes": [("4103", 1), ("4104", 1)], "name": "未分配利润", "ns_category": "equity"},
|
# alias_codes:模板用 4103/4104,智享通凭证明细实际用 3103/3104(2026-09-16 补)
|
||||||
|
{"codes": [("4103", 1), ("4104", 1)],
|
||||||
|
"alias_codes": [("3103", 1), ("3104", 1)],
|
||||||
|
"name": "未分配利润", "ns_category": "equity"},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -1463,7 +1470,10 @@ def _prev_period_str(period: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _get_bs_amount(db: Session, codes: list, period: str, entity_id: int = 1) -> Optional[float]:
|
def _get_bs_amount(db: Session, codes: list, period: str, entity_id: int = 1) -> Optional[float]:
|
||||||
"""资产负债表科目余额 — 优先凭证明细,无数据返回 None(账套隔离 2026-08-31)"""
|
"""资产负债表科目余额 — 优先凭证明细,无数据返回 None(账套隔离 2026-08-31)
|
||||||
|
⚠️ 保留给现金流量表/预编报表使用(精确匹配 + 单期口径);资产负债表本体走
|
||||||
|
`app/services/report_reclass.py`(前缀匹配 + 期末余额口径 + 取数层重分类)。"""
|
||||||
|
|
||||||
total = 0.0
|
total = 0.0
|
||||||
has_data = False
|
has_data = False
|
||||||
try:
|
try:
|
||||||
@@ -1498,30 +1508,94 @@ def _bs_line_amount(db: Session, line: dict, period: str, column: str = "end", e
|
|||||||
return {"value": None, "is_demo": True}
|
return {"value": None, "is_demo": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 资产负债表取数口径(2026-09-16 重分类落地) ──
|
||||||
|
# end = 期末余额(累计至期间,含期初/结转)——资产负债表定义口径,**默认**
|
||||||
|
# period = 本期发生额(旧报表口径,兼容回退;可用 CMA_BS_BASIS_DEFAULT / ?basis= 切换)
|
||||||
|
_BS_BASIS_DEFAULT = os.getenv("CMA_BS_BASIS_DEFAULT", "end")
|
||||||
|
|
||||||
|
|
||||||
|
def _bs_basis(basis) -> str:
|
||||||
|
"""口径解析:兼容内部直调(未传值时 FastAPI 的 Query 默认对象会漏进来 → 取默认口径)"""
|
||||||
|
b = basis if isinstance(basis, str) else _BS_BASIS_DEFAULT
|
||||||
|
b = (b or "end").strip().lower()
|
||||||
|
return "period" if b == "period" else "end"
|
||||||
|
|
||||||
|
|
||||||
|
def _bs_analysis(db: Session, entity_id: int, period: str, basis: str, include_unmapped: bool = True) -> dict:
|
||||||
|
"""取数层重分类分析(规则表 §二 R1~R12;调表不调账,不生成凭证)"""
|
||||||
|
return report_reclass.analyze(
|
||||||
|
db, entity_id, period, basis=basis,
|
||||||
|
template_sections=BALANCE_SHEET_SECTIONS, include_unmapped=include_unmapped,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/balance-sheet")
|
@router.get("/balance-sheet")
|
||||||
def get_balance_sheet(
|
def get_balance_sheet(
|
||||||
period: str = Query(None, description="格式 YYYY-MM"),
|
period: str = Query(None, description="格式 YYYY-MM"),
|
||||||
|
basis: Optional[str] = Query(None, description="取数口径 end=期末余额(默认) / period=本期发生额"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
entity_id: int = Depends(get_entity_id),
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
"""资产负债表 — 新30号准则科目分类(经营/投资/筹资),期末vs期初(账套隔离 2026-08-31)"""
|
"""资产负债表 — 取数层重分类(R1~R12,调表不调账)+ 新30号准则科目分类(账套隔离 2026-08-31)
|
||||||
|
|
||||||
|
· 余额方向:资产 Σ(借−贷) / 负债·权益 Σ(贷−借) → 正常余额一律为正数
|
||||||
|
· 往来·税费类反方向余额按规则转正(转入对方科目),报表不再出现负数项目
|
||||||
|
· 有凭证明细时**不再混入示例数据**(展示口径与落库口径同源)
|
||||||
|
"""
|
||||||
if period is None:
|
if period is None:
|
||||||
period = datetime.now().strftime("%Y-%m")
|
period = datetime.now().strftime("%Y-%m")
|
||||||
|
use_basis = _bs_basis(basis)
|
||||||
prev_period = _prev_period_str(period)
|
prev_period = _prev_period_str(period)
|
||||||
|
|
||||||
|
plan = _bs_analysis(db, entity_id, period, use_basis)
|
||||||
|
has_real = bool(plan["has_voucher_data"])
|
||||||
|
plan_prev = _bs_analysis(db, entity_id, prev_period, use_basis, include_unmapped=False) if has_real else None
|
||||||
|
|
||||||
|
def _line_value(pl: Optional[dict], key: str, col: str):
|
||||||
|
if pl is None:
|
||||||
|
return None
|
||||||
|
return pl["after"].get(key)
|
||||||
|
|
||||||
|
# 规则落位项目(模板未列报的目标项)→ 按板块挂载虚拟行
|
||||||
|
virtual: dict = {}
|
||||||
|
for key, item in plan["items"].items():
|
||||||
|
if item["template"] or item.get("blocked"):
|
||||||
|
continue
|
||||||
|
end_v = plan["after"].get(key)
|
||||||
|
begin_v = plan_prev["after"].get(key) if plan_prev else None
|
||||||
|
if not end_v and not begin_v:
|
||||||
|
continue
|
||||||
|
virtual.setdefault(item["section_key"], []).append({
|
||||||
|
"name": item["name"],
|
||||||
|
"item_key": key,
|
||||||
|
"ns_category": item.get("ns_category") or "operating",
|
||||||
|
"ns_category_label": BS_CATEGORY_CN.get(item.get("ns_category") or "operating", ""),
|
||||||
|
"end_value": round(float(end_v or 0), 2),
|
||||||
|
"begin_value": round(float(begin_v), 2) if begin_v is not None else None,
|
||||||
|
"is_demo": False,
|
||||||
|
"is_reclass_line": True,
|
||||||
|
"template_gap": True,
|
||||||
|
"note": item.get("note", ""),
|
||||||
|
})
|
||||||
|
|
||||||
sections = []
|
sections = []
|
||||||
total_assets_end = total_assets_begin = 0
|
total_assets_end = total_assets_begin = 0
|
||||||
total_liab_end = total_liab_begin = 0
|
total_liab_end = total_liab_begin = 0
|
||||||
total_equity_end = total_equity_begin = 0
|
total_equity_end = total_equity_begin = 0
|
||||||
all_real = True
|
all_real = has_real
|
||||||
|
|
||||||
for sec in BALANCE_SHEET_SECTIONS:
|
for sec in BALANCE_SHEET_SECTIONS:
|
||||||
lines = []
|
lines = []
|
||||||
sec_end = sec_begin = 0.0
|
sec_end = sec_begin = 0.0
|
||||||
sec_real = False
|
sec_real = False
|
||||||
for line in sec["lines"]:
|
for line in sec["lines"]:
|
||||||
end = _bs_line_amount(db, line, period, column="end", entity_id=entity_id)
|
key = "|".join(c for c, _ in line["codes"])
|
||||||
begin = _bs_line_amount(db, line, prev_period, column="begin", entity_id=entity_id)
|
if has_real:
|
||||||
|
end = {"value": _line_value(plan, key, "end"), "is_demo": False}
|
||||||
|
begin = {"value": _line_value(plan_prev, key, "begin"), "is_demo": False}
|
||||||
|
else:
|
||||||
|
end = _bs_line_amount(db, line, period, column="end", entity_id=entity_id)
|
||||||
|
begin = _bs_line_amount(db, line, prev_period, column="begin", entity_id=entity_id)
|
||||||
if end["is_demo"] or begin["is_demo"]:
|
if end["is_demo"] or begin["is_demo"]:
|
||||||
all_real = False
|
all_real = False
|
||||||
if end["value"] is not None:
|
if end["value"] is not None:
|
||||||
@@ -1529,15 +1603,32 @@ def get_balance_sheet(
|
|||||||
sec_real = True
|
sec_real = True
|
||||||
if begin["value"] is not None:
|
if begin["value"] is not None:
|
||||||
sec_begin += begin["value"]
|
sec_begin += begin["value"]
|
||||||
|
raw_key = plan["raw"].get(key)
|
||||||
lines.append({
|
lines.append({
|
||||||
"name": line["name"],
|
"name": line["name"],
|
||||||
|
"item_key": key,
|
||||||
"ns_category": line["ns_category"],
|
"ns_category": line["ns_category"],
|
||||||
"ns_category_label": BS_CATEGORY_CN.get(line["ns_category"], line["ns_category"]),
|
"ns_category_label": BS_CATEGORY_CN.get(line["ns_category"], line["ns_category"]),
|
||||||
"end_value": round(end["value"], 2) if end["value"] is not None else None,
|
"end_value": round(end["value"], 2) if end["value"] is not None else None,
|
||||||
"begin_value": round(begin["value"], 2) if begin["value"] is not None else None,
|
"begin_value": round(begin["value"], 2) if begin["value"] is not None else None,
|
||||||
"is_demo": end["is_demo"] or begin["is_demo"],
|
"is_demo": end["is_demo"] or begin["is_demo"],
|
||||||
|
"is_reclass_line": False,
|
||||||
|
"template_gap": False,
|
||||||
|
"raw_value": None if raw_key is None else round(float(raw_key), 2),
|
||||||
|
"reclass_adjustment": None if raw_key is None else round(
|
||||||
|
float(plan["after"].get(key) or 0) - float(raw_key), 2),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# 规则落位项目(虚拟行)
|
||||||
|
for vline in virtual.get(sec["key"], []):
|
||||||
|
if vline["end_value"] is not None and sec["key"] in (
|
||||||
|
"current_assets", "non_current_assets", "current_liabilities", "non_current_liabilities"):
|
||||||
|
sec_end += vline["end_value"]
|
||||||
|
if vline["begin_value"] is not None and sec["key"] in (
|
||||||
|
"current_assets", "non_current_assets", "current_liabilities", "non_current_liabilities"):
|
||||||
|
sec_begin += vline["begin_value"]
|
||||||
|
lines.append(vline)
|
||||||
|
|
||||||
# 归属汇总
|
# 归属汇总
|
||||||
if sec["key"] in ("current_assets", "non_current_assets"):
|
if sec["key"] in ("current_assets", "non_current_assets"):
|
||||||
total_assets_end += sec_end
|
total_assets_end += sec_end
|
||||||
@@ -1559,9 +1650,17 @@ def get_balance_sheet(
|
|||||||
"has_real_data": sec_real,
|
"has_real_data": sec_real,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
inv = plan["invariants"]
|
||||||
|
if has_real:
|
||||||
|
balanced = abs(total_assets_end - total_liab_end - total_equity_end) < 0.01 and \
|
||||||
|
abs(total_assets_begin - total_liab_begin - total_equity_begin) < 0.01
|
||||||
|
else:
|
||||||
|
balanced = abs(total_assets_end - total_liab_end - total_equity_end) < 0.01
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"period": period,
|
"period": period,
|
||||||
"prev_period": prev_period,
|
"prev_period": prev_period,
|
||||||
|
"basis": use_basis,
|
||||||
"title": f"资产负债表 — 新30号准则({period})",
|
"title": f"资产负债表 — 新30号准则({period})",
|
||||||
"sections": sections,
|
"sections": sections,
|
||||||
"totals": {
|
"totals": {
|
||||||
@@ -1570,13 +1669,124 @@ def get_balance_sheet(
|
|||||||
"equity": {"end": round(total_equity_end, 2), "begin": round(total_equity_begin, 2)},
|
"equity": {"end": round(total_equity_end, 2), "begin": round(total_equity_begin, 2)},
|
||||||
"liab_equity": {"end": round(total_liab_end + total_equity_end, 2),
|
"liab_equity": {"end": round(total_liab_end + total_equity_end, 2),
|
||||||
"begin": round(total_liab_begin + total_equity_begin, 2)},
|
"begin": round(total_liab_begin + total_equity_begin, 2)},
|
||||||
"balanced": abs(total_assets_end - total_liab_end - total_equity_end) < 0.01
|
"balanced": balanced,
|
||||||
and abs(total_assets_begin - total_liab_begin - total_equity_begin) < 0.01,
|
"identity_gap": round(total_assets_end - total_liab_end - total_equity_end, 2),
|
||||||
|
},
|
||||||
|
# ── 重分类(调表不调账)──
|
||||||
|
"reclass": {
|
||||||
|
"applied": bool(plan["entries"]),
|
||||||
|
"rule_version": plan["rule_version"],
|
||||||
|
"basis": plan["basis"],
|
||||||
|
"has_voucher_data": has_real,
|
||||||
|
"entries": plan["entries"],
|
||||||
|
"invariants": inv,
|
||||||
|
"negatives": plan["negatives"],
|
||||||
|
"template_gaps": plan["template_gaps"],
|
||||||
|
"unmapped_subjects": plan["unmapped_subjects"],
|
||||||
|
"pl_unclosed": plan["pl_unclosed"],
|
||||||
|
"working_paper_url": f"/api/cma/reports/balance-sheet/reclass-working-paper?period={period}&basis={use_basis}",
|
||||||
},
|
},
|
||||||
"all_items_have_data": all_real,
|
"all_items_have_data": all_real,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/balance-sheet/reclass-working-paper")
|
||||||
|
def get_reclass_working_paper(
|
||||||
|
period: str = Query(None, description="格式 YYYY-MM"),
|
||||||
|
basis: Optional[str] = Query(None, description="取数口径 end=期末余额(默认) / period=本期发生额"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
):
|
||||||
|
"""重分类底稿(审计可追溯)— 源科目/余额/转正额/目标项 + 不变量 + 诊断(账套隔离)"""
|
||||||
|
if period is None:
|
||||||
|
period = datetime.now().strftime("%Y-%m")
|
||||||
|
use_basis = _bs_basis(basis)
|
||||||
|
plan = _bs_analysis(db, entity_id, period, use_basis)
|
||||||
|
return {
|
||||||
|
"period": period,
|
||||||
|
"basis": use_basis,
|
||||||
|
"entity_id": entity_id,
|
||||||
|
"rule_version": plan["rule_version"],
|
||||||
|
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"accounting_method": "调表不调账(报表取数层重分类,不生成记账凭证)",
|
||||||
|
"rules": plan["rules"],
|
||||||
|
"entries": plan["entries"],
|
||||||
|
"invariants": plan["invariants"],
|
||||||
|
"template_gaps": plan["template_gaps"],
|
||||||
|
"negatives": plan["negatives"],
|
||||||
|
"unmapped_subjects": plan["unmapped_subjects"],
|
||||||
|
"pl_unclosed": plan["pl_unclosed"],
|
||||||
|
"totals_before": plan["totals_before"],
|
||||||
|
"totals_after": plan["totals_after"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/balance-sheet/reclass-working-paper/export")
|
||||||
|
def export_reclass_working_paper(
|
||||||
|
period: str = Query(None, description="格式 YYYY-MM"),
|
||||||
|
basis: Optional[str] = Query(None, description="取数口径 end=期末余额(默认) / period=本期发生额"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
):
|
||||||
|
"""重分类底稿导出(Excel)— 底稿明细 + 诊断 + 规则表(账套隔离)"""
|
||||||
|
if period is None:
|
||||||
|
period = datetime.now().strftime("%Y-%m")
|
||||||
|
use_basis = _bs_basis(basis)
|
||||||
|
plan = _bs_analysis(db, entity_id, period, use_basis)
|
||||||
|
|
||||||
|
from io import BytesIO
|
||||||
|
from openpyxl import Workbook
|
||||||
|
from openpyxl.styles import Font, PatternFill, Alignment
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
wb = Workbook()
|
||||||
|
head_fill = PatternFill("solid", fgColor="305496")
|
||||||
|
head_font = Font(color="FFFFFF", bold=True, size=11)
|
||||||
|
|
||||||
|
def _sheet(ws, title, headers, rows):
|
||||||
|
ws.cell(row=1, column=1, value=title).font = Font(bold=True, size=13, color="305496")
|
||||||
|
ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=max(len(headers), 1))
|
||||||
|
for ci, h in enumerate(headers, start=1):
|
||||||
|
c = ws.cell(row=2, column=ci, value=h)
|
||||||
|
c.font = head_font
|
||||||
|
c.fill = head_fill
|
||||||
|
c.alignment = Alignment(horizontal="center")
|
||||||
|
for ri, row in enumerate(rows, start=3):
|
||||||
|
for ci, h in enumerate(headers, start=1):
|
||||||
|
ws.cell(row=ri, column=ci, value=row.get(h))
|
||||||
|
for ci, h in enumerate(headers, start=1):
|
||||||
|
ws.column_dimensions[ws.cell(row=2, column=ci).column_letter].width = max(12, min(46, len(str(h)) * 2 + 10))
|
||||||
|
|
||||||
|
e_rows = report_reclass.entry_rows(plan)
|
||||||
|
ws1 = wb.active
|
||||||
|
ws1.title = "重分类底稿"
|
||||||
|
if e_rows:
|
||||||
|
_sheet(ws1, f"资产负债表重分类底稿(调表不调账) {period} 账套 entity_id={entity_id} 口径basis={use_basis} {plan['rule_version']}",
|
||||||
|
list(e_rows[0].keys()), e_rows)
|
||||||
|
else:
|
||||||
|
ws1.cell(row=1, column=1, value=f"本期({period})无触发的重分类规则").font = Font(bold=True)
|
||||||
|
|
||||||
|
d_rows = report_reclass.diagnostic_rows(plan)
|
||||||
|
ws2 = wb.create_sheet("不变量与诊断")
|
||||||
|
_sheet(ws2, "重分类不变量 / 负数项目 / 模板待核 / 未列报科目", ["项目", "值", "说明"], d_rows)
|
||||||
|
|
||||||
|
ws3 = wb.create_sheet("规则表")
|
||||||
|
_sheet(ws3, f"CMA 资产负债表重分类规则表 {plan['rule_version']}",
|
||||||
|
["code", "source_code", "source_name", "source_side", "target_item", "target_key", "target_side", "status", "note"],
|
||||||
|
plan["rules"])
|
||||||
|
|
||||||
|
buf = BytesIO()
|
||||||
|
wb.save(buf)
|
||||||
|
buf.seek(0)
|
||||||
|
filename = f"资产负债表重分类底稿_{period}_entity{entity_id}.xlsx"
|
||||||
|
return StreamingResponse(
|
||||||
|
buf,
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# 现金流量表行项目: (code, 名称, 板块, 方向, kpi_code可选)
|
# 现金流量表行项目: (code, 名称, 板块, 方向, kpi_code可选)
|
||||||
CASH_FLOW_LINES = [
|
CASH_FLOW_LINES = [
|
||||||
# 经营活动
|
# 经营活动
|
||||||
@@ -1829,6 +2039,17 @@ def export_statutory_reports(
|
|||||||
r = write_row(ws2, r, ["所有者权益合计", "", t["equity"]["end"], t["equity"]["begin"], ""], total_font, total_fill)
|
r = write_row(ws2, r, ["所有者权益合计", "", t["equity"]["end"], t["equity"]["begin"], ""], total_font, total_fill)
|
||||||
r = write_row(ws2, r, ["负债和所有者权益总计", "", t["liab_equity"]["end"], t["liab_equity"]["begin"], ""], total_font, total_fill)
|
r = write_row(ws2, r, ["负债和所有者权益总计", "", t["liab_equity"]["end"], t["liab_equity"]["begin"], ""], total_font, total_fill)
|
||||||
r = write_row(ws2, r, ["勾稽校验(资产=负债+权益)", "", "✓ 平衡" if t.get("balanced") else "✗ 不平", "", ""], total_font, total_fill)
|
r = write_row(ws2, r, ["勾稽校验(资产=负债+权益)", "", "✓ 平衡" if t.get("balanced") else "✗ 不平", "", ""], total_font, total_fill)
|
||||||
|
rc = bs.get("reclass") or {}
|
||||||
|
if rc:
|
||||||
|
inv = rc.get("invariants") or {}
|
||||||
|
r = write_row(ws2, r, ["重分类(调表不调账)", rc.get("rule_version", ""),
|
||||||
|
f"触发 {inv.get('entries_count', 0)} 条 / 转正额 {inv.get('reclass_total', 0)}",
|
||||||
|
("basis=end(期末余额口径)" if rc.get("basis") == "end" else f"basis={rc.get('basis')}(本期发生额口径)"),
|
||||||
|
"不生成凭证"], section_font, section_fill)
|
||||||
|
if inv:
|
||||||
|
r = write_row(ws2, r, ["不变量:Δ资产=Δ负债 / 权益不变", "",
|
||||||
|
"PASS" if inv.get("delta_assets_equals_delta_liabilities") and inv.get("equity_unchanged") else "FAIL",
|
||||||
|
f"Δ资产={inv.get('assets_delta', 0)},Δ负债={inv.get('liabilities_delta', 0)}", ""])
|
||||||
for col, w in zip("ABCDE", [36, 16, 18, 18, 12]):
|
for col, w in zip("ABCDE", [36, 16, 18, 18, 12]):
|
||||||
ws2.column_dimensions[col].width = w
|
ws2.column_dimensions[col].width = w
|
||||||
|
|
||||||
@@ -1852,6 +2073,30 @@ def export_statutory_reports(
|
|||||||
for col, w in zip("ABCD", [46, 10, 18, 12]):
|
for col, w in zip("ABCD", [46, 10, 18, 12]):
|
||||||
ws3.column_dimensions[col].width = w
|
ws3.column_dimensions[col].width = w
|
||||||
|
|
||||||
|
# ── Sheet 4: 重分类底稿(调表不调账,审计可追溯) ──
|
||||||
|
ws4 = wb.create_sheet("重分类底稿")
|
||||||
|
style_header(ws4, 1, 12, f"资产负债表重分类底稿(调表不调账) {period} 单位: 元")
|
||||||
|
e_rows = report_reclass.entry_rows(bs.get("reclass") and {"entries": bs["reclass"]["entries"]} or {"entries": []})
|
||||||
|
if e_rows:
|
||||||
|
r = write_row(ws4, 2, list(e_rows[0].keys()), head_font, head_fill)
|
||||||
|
for row in e_rows:
|
||||||
|
r = write_row(ws4, r, list(row.values()))
|
||||||
|
else:
|
||||||
|
r = write_row(ws4, 2, ["本期无触发的重分类规则"], section_font, section_fill)
|
||||||
|
r += 1
|
||||||
|
r = write_row(ws4, r, ["不变量与诊断", "", "", "", "", "", "", "", "", "", "", ""], section_font, section_fill)
|
||||||
|
plan_like = {
|
||||||
|
"entries": (bs.get("reclass") or {}).get("entries") or [],
|
||||||
|
"invariants": (bs.get("reclass") or {}).get("invariants") or {},
|
||||||
|
"negatives": (bs.get("reclass") or {}).get("negatives") or {"before": [], "after": [], "after_in_scope": []},
|
||||||
|
"template_gaps": (bs.get("reclass") or {}).get("template_gaps") or [],
|
||||||
|
"unmapped_subjects": (bs.get("reclass") or {}).get("unmapped_subjects") or [],
|
||||||
|
}
|
||||||
|
for row in report_reclass.diagnostic_rows(plan_like):
|
||||||
|
r = write_row(ws4, r, [row.get("项目"), row.get("值"), row.get("说明")])
|
||||||
|
for col, w in zip("ABC", [42, 20, 60]):
|
||||||
|
ws4.column_dimensions[col].width = w
|
||||||
|
|
||||||
buf = BytesIO()
|
buf = BytesIO()
|
||||||
wb.save(buf)
|
wb.save(buf)
|
||||||
buf.seek(0)
|
buf.seek(0)
|
||||||
|
|||||||
@@ -0,0 +1,621 @@
|
|||||||
|
"""资产负债表取数层重分类引擎(R1~R12,调表不调账)
|
||||||
|
|
||||||
|
依据:《CMA 资产负债表重分类规则表 v1》
|
||||||
|
(`wecom-finance/reports/CMA_资产负债表重分类规则表_v1.md`,任总 2026-09-16 批准落地)
|
||||||
|
|
||||||
|
本模块只作用于**报表取数层**:`voucher_details` 只读,**不生成记账凭证、不改账簿**
|
||||||
|
(调表不调账;账实不符走差错更正,见规则表 §四)。
|
||||||
|
|
||||||
|
口径要点
|
||||||
|
--------
|
||||||
|
1. **余额方向**:资产类 NSB = Σ(借−贷);负债/权益类 NSB = Σ(贷−借)。NSB = normal-side balance。
|
||||||
|
2. **触发**:NSB < 0(明细科目反方向余额,如预收账款出现借方余额)→ 转正额 = |NSB|,
|
||||||
|
移入目标报表项目(准则 30 号「不得抵销」)。
|
||||||
|
3. **全部触发判定基于原始余额**(单遍、顺序无关、可重复执行/幂等)——避免 R3/R4 这类互为镜像
|
||||||
|
的规则互相影响(如 2202 应收账款 ↔ 1123 预付账款 双向都有反向余额时)。
|
||||||
|
4. **科目取数一律前缀匹配**(`subject_code LIKE '1122%'`,与规则表 §二 公式一致):CMA 科目为多级
|
||||||
|
(100101 / 22210101 / 310415),精确匹配会漏掉全部明细行。
|
||||||
|
5. **basis**:`end` = 期末余额(累计至 period,含期初/结转,资产负债表口径,默认);
|
||||||
|
`period` = 本期发生额(兼容旧报表口径)。
|
||||||
|
6. **不变量**:跨方向重分类(资产↔负债)使资产合计与负债合计**同额增加**,
|
||||||
|
故「总资产不变」只在同侧列报下成立;真正恒定的不变量是
|
||||||
|
`Δ资产合计 = Δ负债合计` 且 `资产合计 − 负债合计 − 所有者权益合计` 前后不变。
|
||||||
|
本模块把这两条都算出来(`invariants`),不隐藏 `total_assets_unchanged=False` 这一事实。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Dict, List, Optional, Sequence, Tuple
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
from app.utils.voucher_guard import apply_voucher_guard
|
||||||
|
|
||||||
|
RULE_VERSION = "reclass-v1.0-20260916"
|
||||||
|
DEFAULT_TOL = 0.01 # 元;小于该值的反方向余额不触发(浮点/分位噪声)
|
||||||
|
|
||||||
|
# 报表板块 → 余额方向
|
||||||
|
SIDE_OF_SECTION = {
|
||||||
|
"current_assets": "asset",
|
||||||
|
"non_current_assets": "asset",
|
||||||
|
"current_liabilities": "liability",
|
||||||
|
"non_current_liabilities": "liability",
|
||||||
|
"equity": "equity",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 资产备抵科目(余额在贷方)→ 取数方向反转:1602 累计折旧 / 1603 固定资产减值准备 / 1604 在建工程减值准备
|
||||||
|
CONTRA_CODES: Dict[str, str] = {"1602": "liability", "1603": "liability", "1604": "liability"}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ReclassRule:
|
||||||
|
"""一条重分类规则(规则表 §二 的机器可读形式)。"""
|
||||||
|
|
||||||
|
code: str # R1~R12
|
||||||
|
source_code: str # 来源科目编码(前缀匹配)
|
||||||
|
source_name: str
|
||||||
|
source_side: str # asset / liability
|
||||||
|
target_item: str # 目标报表项目名(小企业准则项目池)
|
||||||
|
target_key: str # 目标项目在报表条目表里的 key
|
||||||
|
target_side: str # asset / liability
|
||||||
|
target_codes: Tuple[str, ...] = ()
|
||||||
|
status: str = "active" # active=可落地 / template_gap=目标项模板未列报(引擎按虚拟行列报)/ blocked=缺数据或准则未列报
|
||||||
|
error_check: bool = False # 触发后需先排查记账差错(R10)
|
||||||
|
note: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
# ── R1~R12(规则表 §二,挂 CMA 真实科目编码) ────────────────────────────────
|
||||||
|
RECLASS_RULES: Tuple[ReclassRule, ...] = (
|
||||||
|
ReclassRule("R1", "1122", "应收账款", "asset", "预收账款", "2203", "liability",
|
||||||
|
("2203",), note="小企业准则无「合同负债」,目标为预收账款"),
|
||||||
|
ReclassRule("R2", "2203", "预收账款", "liability", "应收账款", "1122", "asset", ("1122",),
|
||||||
|
note="R1 反向对称"),
|
||||||
|
ReclassRule("R3", "2202", "应付账款", "liability", "预付账款", "1123", "asset", ("1123",),
|
||||||
|
note="应付账款出现借方余额(多付/预付)"),
|
||||||
|
ReclassRule("R4", "1123", "预付账款", "asset", "应付账款", "2202", "liability", ("2202",),
|
||||||
|
note="R3 反向对称"),
|
||||||
|
ReclassRule("R5", "1221", "其他应收款", "asset", "其他应付款", "2241", "liability", ("2241",)),
|
||||||
|
ReclassRule("R6", "2241", "其他应付款", "liability", "其他应收款", "1221", "asset", ("1221",)),
|
||||||
|
ReclassRule("R7", "1121", "应收票据", "asset", "应付票据", "2201", "liability", ("2201",),
|
||||||
|
status="template_gap", note="CMA 报表模板未单列应收/应付票据 → 引擎按虚拟行列报,待财务确认"),
|
||||||
|
ReclassRule("R8", "2201", "应付票据", "liability", "应收票据", "1121", "asset", ("1121",),
|
||||||
|
status="template_gap", note="CMA 报表模板未单列应收/应付票据 → 引擎按虚拟行列报,待财务确认"),
|
||||||
|
ReclassRule("R9", "2221", "应交税费", "liability", "其他流动资产", "OCA_OTHER", "asset",
|
||||||
|
status="template_gap",
|
||||||
|
note="留抵/预缴/多缴(借方余额);≤1年→其他流动资产,>1年→其他非流动资产。"
|
||||||
|
"CMA 无到期日字段,暂统一列报「其他流动资产」并标注待核"),
|
||||||
|
ReclassRule("R10", "2211", "应付职工薪酬", "liability", "其他应收款", "1221", "asset", ("1221",),
|
||||||
|
error_check=True, note="先查是否记账差错(多付/重复发放),差错走更正、非差错才重分类"),
|
||||||
|
ReclassRule("R11", "2501", "长期借款/应付债券", "liability", "一年内到期的非流动负债",
|
||||||
|
"NCL_DUE_1Y", "liability", status="blocked",
|
||||||
|
note="小企业准则报表未单列「一年内到期的非流动负债」,且 voucher_details 无到期日字段 → 不落地"),
|
||||||
|
ReclassRule("R12", "1501", "长期债权投资/长期应收款", "asset", "一年内到期的非流动资产",
|
||||||
|
"NCA_DUE_1Y", "asset", status="blocked",
|
||||||
|
note="小企业准则报表未单列「一年内到期的非流动资产」,且无到期日字段 → 不落地"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 模板未列报、但规则需要落位的报表项目(虚拟行;仅当金额非零时列报) ──────────
|
||||||
|
EXTRA_ITEMS: Dict[str, dict] = {
|
||||||
|
"1121": {"name": "应收票据", "section_key": "current_assets", "side": "asset", "codes": [("1121", 1)],
|
||||||
|
"template_gap": True, "note": "小企业准则项目池有「应收票据」,CMA 模板未列报"},
|
||||||
|
"2201": {"name": "应付票据", "section_key": "current_liabilities", "side": "liability", "codes": [("2201", 1)],
|
||||||
|
"template_gap": True, "note": "小企业准则项目池有「应付票据」,CMA 模板未列报"},
|
||||||
|
"OCA_OTHER": {"name": "其他流动资产", "section_key": "current_assets", "side": "asset", "codes": [],
|
||||||
|
"template_gap": True, "ns_category": "operating",
|
||||||
|
"note": "小企业准则项目池有「其他流动资产」,CMA 模板未列报(R9 落位项目)"},
|
||||||
|
"ONCA_OTHER": {"name": "其他非流动资产", "section_key": "non_current_assets", "side": "asset", "codes": [],
|
||||||
|
"template_gap": True, "blocked": True, "note": "R9 的 >1年落位项目;无到期日字段,暂不落地"},
|
||||||
|
"OCL_OTHER": {"name": "其他流动负债", "section_key": "current_liabilities", "side": "liability", "codes": [],
|
||||||
|
"template_gap": True, "blocked": True, "note": "R7 备选落位项目;暂不落地"},
|
||||||
|
"NCA_DUE_1Y": {"name": "一年内到期的非流动资产", "section_key": "current_assets", "side": "asset", "codes": [],
|
||||||
|
"template_gap": True, "blocked": True, "note": "小企业准则未单列(R12 待核)"},
|
||||||
|
"NCL_DUE_1Y": {"name": "一年内到期的非流动负债", "section_key": "current_liabilities", "side": "liability", "codes": [],
|
||||||
|
"template_gap": True, "blocked": True, "note": "小企业准则未单列(R11 待核)"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 余额取数(前缀匹配 + 作废守卫 + basis 口径) ─────────────────────────────
|
||||||
|
|
||||||
|
class BalanceSource:
|
||||||
|
"""按 (品类, 期间, basis) 取科目 normal-side balance(NSB),带会话内缓存。"""
|
||||||
|
|
||||||
|
def __init__(self, db, entity_id: int, period: str, basis: str = "end", tol: float = DEFAULT_TOL):
|
||||||
|
self.db = db
|
||||||
|
self.entity_id = entity_id
|
||||||
|
self.period = period
|
||||||
|
self.basis = "period" if basis == "period" else "end"
|
||||||
|
self.tol = tol
|
||||||
|
self._cache: Dict[Tuple[str, str], Optional[Decimal]] = {}
|
||||||
|
self._has_data: Optional[bool] = None
|
||||||
|
|
||||||
|
# -- 基础查询 ---------------------------------------------------------
|
||||||
|
def _query(self, code: str, period: str):
|
||||||
|
from app.models import VoucherDetail
|
||||||
|
|
||||||
|
q = self.db.query(
|
||||||
|
func.sum(VoucherDetail.debit_amount),
|
||||||
|
func.sum(VoucherDetail.credit_amount),
|
||||||
|
func.count(VoucherDetail.id),
|
||||||
|
).filter(
|
||||||
|
VoucherDetail.entity_id == self.entity_id,
|
||||||
|
VoucherDetail.subject_code.like(f"{code}%"),
|
||||||
|
)
|
||||||
|
if self.basis == "end":
|
||||||
|
q = q.filter(VoucherDetail.period <= period) # 期末余额 = 累计(含期初/结转)
|
||||||
|
else:
|
||||||
|
q = q.filter(VoucherDetail.period == period) # 本期发生额
|
||||||
|
return apply_voucher_guard(self.db, q, VoucherDetail)
|
||||||
|
|
||||||
|
def nsb(self, code: str, side: str, period: Optional[str] = None) -> Optional[Decimal]:
|
||||||
|
"""NSB = 资产 Σ(借−贷) / 负债·权益 Σ(贷−借);无数据返回 None。"""
|
||||||
|
period = period or self.period
|
||||||
|
ck = (f"{code}|{side}|{period}|{self.basis}", "")
|
||||||
|
if ck in self._cache:
|
||||||
|
return self._cache[ck]
|
||||||
|
d = c = n = None
|
||||||
|
try:
|
||||||
|
row = self._query(code, period).one()
|
||||||
|
d, c, n = row[0], row[1], row[2]
|
||||||
|
except Exception: # noqa: BLE001 - 无凭证明细表/查询异常 → 视为无数据
|
||||||
|
pass
|
||||||
|
if not n:
|
||||||
|
self._cache[ck] = None
|
||||||
|
return None
|
||||||
|
debit = Decimal(str(d or 0))
|
||||||
|
credit = Decimal(str(c or 0))
|
||||||
|
val = (debit - credit) if side == "asset" else (credit - debit)
|
||||||
|
self._cache[ck] = val
|
||||||
|
return val
|
||||||
|
|
||||||
|
def has_data(self) -> bool:
|
||||||
|
if self._has_data is None:
|
||||||
|
from app.models import VoucherDetail
|
||||||
|
|
||||||
|
q = self.db.query(func.count(VoucherDetail.id)).filter(
|
||||||
|
VoucherDetail.entity_id == self.entity_id
|
||||||
|
)
|
||||||
|
if self.basis == "period":
|
||||||
|
q = q.filter(VoucherDetail.period == self.period)
|
||||||
|
try:
|
||||||
|
self._has_data = bool(q.scalar())
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
self._has_data = False
|
||||||
|
return bool(self._has_data)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 报表条目表(模板行 + 规则落位项) ──────────────────────────────────────
|
||||||
|
|
||||||
|
def build_items(template_sections: Sequence[dict]) -> Dict[str, dict]:
|
||||||
|
"""从报表模板派生条目表;key 与模板行 key 一致("|".join(codes))。
|
||||||
|
|
||||||
|
`alias_codes` 只参与取数、不参与 key(例如模板用 4103/4104、数据用 3103/3104)。
|
||||||
|
"""
|
||||||
|
items: Dict[str, dict] = {}
|
||||||
|
for sec in template_sections or []:
|
||||||
|
side = SIDE_OF_SECTION.get(sec["key"], "asset")
|
||||||
|
for line in sec.get("lines", []):
|
||||||
|
key = "|".join(c for c, _ in line["codes"])
|
||||||
|
codes = list(line["codes"]) + list(line.get("alias_codes") or [])
|
||||||
|
items[key] = {
|
||||||
|
"key": key,
|
||||||
|
"name": line["name"],
|
||||||
|
"section_key": sec["key"],
|
||||||
|
"side": side,
|
||||||
|
"codes": codes,
|
||||||
|
"template": True,
|
||||||
|
"template_gap": False,
|
||||||
|
"ns_category": line.get("ns_category"),
|
||||||
|
"aliased": bool(line.get("alias_codes")),
|
||||||
|
}
|
||||||
|
for key, cfg in EXTRA_ITEMS.items():
|
||||||
|
if key in items:
|
||||||
|
continue
|
||||||
|
items[key] = {
|
||||||
|
"key": key, "name": cfg["name"], "section_key": cfg["section_key"],
|
||||||
|
"side": cfg["side"], "codes": list(cfg.get("codes") or []),
|
||||||
|
"template": False, "template_gap": bool(cfg.get("template_gap")),
|
||||||
|
"blocked": bool(cfg.get("blocked")), "ns_category": cfg.get("ns_category"),
|
||||||
|
"note": cfg.get("note", ""),
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def _item_raw(bs: BalanceSource, item: dict, period: Optional[str] = None) -> Optional[Decimal]:
|
||||||
|
"""条目原始余额:Σ sign × NSB(code);无任何数据返回 None(纯落位项目按 0 处理)。
|
||||||
|
|
||||||
|
备抵科目(累计折旧等)余额方向与所属板块相反,取数时反向(`CONTRA_CODES`)——
|
||||||
|
否则「固定资产净额 = 原值 − 累计折旧」会被算成 原值 + 累计折旧。
|
||||||
|
"""
|
||||||
|
total = Decimal(0)
|
||||||
|
has = False
|
||||||
|
for code, sign in item["codes"]:
|
||||||
|
side = CONTRA_CODES.get(code) or item["side"]
|
||||||
|
v = bs.nsb(code, side, period)
|
||||||
|
if v is not None:
|
||||||
|
total += v * sign
|
||||||
|
has = True
|
||||||
|
if not has:
|
||||||
|
return Decimal(0) if (not item["template"] or not item["codes"]) else None
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def _covered_code_map(items: Dict[str, dict]) -> Dict[str, str]:
|
||||||
|
"""科目编码 → 条目 key(取数代码 sign=1 的;用于判定规则来源项落位)。"""
|
||||||
|
m: Dict[str, str] = {}
|
||||||
|
for key, item in items.items():
|
||||||
|
for code, sign in item["codes"]:
|
||||||
|
if sign == 1:
|
||||||
|
m.setdefault(code, key)
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
# ── 主入口:一次性算出「原值 / 重分类后 / 底稿 / 不变量 / 诊断」 ─────────────
|
||||||
|
|
||||||
|
def analyze(db, entity_id: int, period: str, basis: str = "end",
|
||||||
|
template_sections: Optional[Sequence[dict]] = None,
|
||||||
|
rules: Sequence[ReclassRule] = RECLASS_RULES,
|
||||||
|
tol: float = DEFAULT_TOL, include_unmapped: bool = True) -> dict:
|
||||||
|
"""资产负债表取数层重分类分析(不写库、幂等、可重复调用)。"""
|
||||||
|
bs = BalanceSource(db, entity_id, period, basis, tol)
|
||||||
|
items = build_items(template_sections or [])
|
||||||
|
code_to_key = _covered_code_map(items)
|
||||||
|
|
||||||
|
raw: Dict[str, Optional[Decimal]] = {}
|
||||||
|
prev: Dict[str, Optional[Decimal]] = {}
|
||||||
|
for key, item in items.items():
|
||||||
|
raw[key] = _item_raw(bs, item)
|
||||||
|
if basis == "end":
|
||||||
|
prev[key] = _item_raw(bs, item, _prev_period(period))
|
||||||
|
|
||||||
|
# ── 触发判定(全部基于原始余额,单遍) ──
|
||||||
|
entries: List[dict] = []
|
||||||
|
for r in rules:
|
||||||
|
if r.status == "blocked":
|
||||||
|
continue
|
||||||
|
v = bs.nsb(r.source_code, r.source_side)
|
||||||
|
if v is None or v >= -Decimal(str(tol)):
|
||||||
|
continue
|
||||||
|
amount = float(-v)
|
||||||
|
src_key = code_to_key.get(r.source_code, r.source_code)
|
||||||
|
tgt = items.get(r.target_key) or {}
|
||||||
|
entries.append({
|
||||||
|
"rule": r.code,
|
||||||
|
"source_code": r.source_code,
|
||||||
|
"source_name": r.source_name,
|
||||||
|
"source_side": r.source_side,
|
||||||
|
"source_key": src_key,
|
||||||
|
"raw_balance": round(float(v), 2),
|
||||||
|
"abnormal_direction": "贷方余额" if r.source_side == "asset" else "借方余额",
|
||||||
|
"reclass_amount": round(amount, 2),
|
||||||
|
"target_item": r.target_item,
|
||||||
|
"target_key": r.target_key,
|
||||||
|
"target_codes": list(r.target_codes),
|
||||||
|
"target_side": r.target_side,
|
||||||
|
"target_in_template": bool(tgt.get("template")),
|
||||||
|
"rule_status": r.status,
|
||||||
|
"error_check": r.error_check,
|
||||||
|
"note": r.note,
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── 重分类后金额 ──
|
||||||
|
after: Dict[str, Optional[float]] = {}
|
||||||
|
for key, v in raw.items():
|
||||||
|
after[key] = float(v) if v is not None else 0.0
|
||||||
|
for e in entries:
|
||||||
|
after[e["source_key"]] = float(after.get(e["source_key"]) or 0.0) + e["reclass_amount"]
|
||||||
|
after[e["target_key"]] = float(after.get(e["target_key"]) or 0.0) + e["reclass_amount"]
|
||||||
|
|
||||||
|
def _totals(values: Dict[str, Optional[float]]) -> dict:
|
||||||
|
t = {"assets": 0.0, "liabilities": 0.0, "equity": 0.0}
|
||||||
|
for key, item in items.items():
|
||||||
|
v = values.get(key)
|
||||||
|
if v is None or item.get("blocked"):
|
||||||
|
continue
|
||||||
|
if item["side"] == "asset":
|
||||||
|
t["assets"] += float(v)
|
||||||
|
elif item["side"] == "liability":
|
||||||
|
t["liabilities"] += float(v)
|
||||||
|
else:
|
||||||
|
t["equity"] += float(v)
|
||||||
|
t["liab_equity"] = t["liabilities"] + t["equity"]
|
||||||
|
t["residual"] = round(t["assets"] - t["liab_equity"], 2)
|
||||||
|
return {k: round(v, 2) for k, v in t.items()}
|
||||||
|
|
||||||
|
before_t = _totals(raw)
|
||||||
|
after_t = _totals(after)
|
||||||
|
reclass_total = round(sum(e["reclass_amount"] for e in entries), 2)
|
||||||
|
|
||||||
|
in_scope = set()
|
||||||
|
for r in rules:
|
||||||
|
if r.status == "blocked":
|
||||||
|
continue
|
||||||
|
in_scope.add(code_to_key.get(r.source_code, r.source_code))
|
||||||
|
in_scope.add(r.target_key)
|
||||||
|
|
||||||
|
def _negatives(values: Dict[str, Optional[float]]) -> List[dict]:
|
||||||
|
out = []
|
||||||
|
for key, item in items.items():
|
||||||
|
v = values.get(key)
|
||||||
|
if v is None or v >= -tol:
|
||||||
|
continue
|
||||||
|
out.append({
|
||||||
|
"key": key, "name": item["name"], "side": item["side"],
|
||||||
|
"section_key": item["section_key"], "value": round(float(v), 2),
|
||||||
|
"in_reclass_scope": key in in_scope,
|
||||||
|
"template": item["template"],
|
||||||
|
})
|
||||||
|
return sorted(out, key=lambda x: x["value"])
|
||||||
|
|
||||||
|
neg_before = _negatives(raw)
|
||||||
|
neg_after = _negatives(after)
|
||||||
|
neg_after_in_scope = [n for n in neg_after if n["in_reclass_scope"]]
|
||||||
|
|
||||||
|
invariants = {
|
||||||
|
"basis": bs.basis,
|
||||||
|
"period": period,
|
||||||
|
"rule_version": RULE_VERSION,
|
||||||
|
"assets_before": before_t["assets"],
|
||||||
|
"assets_after": after_t["assets"],
|
||||||
|
"liabilities_before": before_t["liabilities"],
|
||||||
|
"liabilities_after": after_t["liabilities"],
|
||||||
|
"equity_before": before_t["equity"],
|
||||||
|
"equity_after": after_t["equity"],
|
||||||
|
"assets_delta": round(after_t["assets"] - before_t["assets"], 2),
|
||||||
|
"liabilities_delta": round(after_t["liabilities"] - before_t["liabilities"], 2),
|
||||||
|
"equity_delta": round(after_t["equity"] - before_t["equity"], 2),
|
||||||
|
"reclass_total": reclass_total,
|
||||||
|
# ① 调表不调账:跨方向重分类 → 资产/负债同额变动
|
||||||
|
"delta_assets_equals_delta_liabilities":
|
||||||
|
abs((after_t["assets"] - before_t["assets"]) - (after_t["liabilities"] - before_t["liabilities"])) < 0.01,
|
||||||
|
# ② 权益不受影响
|
||||||
|
"equity_unchanged": abs(after_t["equity"] - before_t["equity"]) < 0.01,
|
||||||
|
# ③ 勾稽差额不被重分类改变(重分类不制造新的不平)
|
||||||
|
"residual_before": before_t["residual"],
|
||||||
|
"residual_after": after_t["residual"],
|
||||||
|
"residual_unchanged": abs(after_t["residual"] - before_t["residual"]) < 0.01,
|
||||||
|
# ④ 勾稽恒等式(资产合计 = 负债 + 所有者权益)
|
||||||
|
"identity_holds_before": abs(before_t["residual"]) < 0.01,
|
||||||
|
"identity_holds_after": abs(after_t["residual"]) < 0.01,
|
||||||
|
# ⑤ 「总资产不变」:跨方向重分类下必然 False(见模块 docstring 第 6 条)
|
||||||
|
"total_assets_unchanged": abs(after_t["assets"] - before_t["assets"]) < 0.01,
|
||||||
|
"total_assets_unchanged_semantics": (
|
||||||
|
"跨方向重分类(资产↔负债)使资产合计与负债合计同额变动(Δ资产=Δ负债=转正额);"
|
||||||
|
"「总资产不变」仅在同侧列报时成立。恒定不变量 = Δ资产=Δ负债 且 资产−负债−权益 不变。"
|
||||||
|
),
|
||||||
|
"no_negative_in_scope_after": len(neg_after_in_scope) == 0,
|
||||||
|
"negatives_before": len(neg_before),
|
||||||
|
"negatives_after": len(neg_after),
|
||||||
|
"entries_count": len(entries),
|
||||||
|
}
|
||||||
|
|
||||||
|
template_gaps: List[dict] = []
|
||||||
|
for r in rules:
|
||||||
|
tgt = items.get(r.target_key) or {}
|
||||||
|
if r.status == "active":
|
||||||
|
continue
|
||||||
|
template_gaps.append({
|
||||||
|
"rule": r.code, "source_code": r.source_code, "target_item": r.target_item,
|
||||||
|
"target_key": r.target_key, "status": r.status,
|
||||||
|
"in_report": (r.status != "blocked") and bool(tgt.get("template_gap")),
|
||||||
|
"note": r.note,
|
||||||
|
})
|
||||||
|
|
||||||
|
if not bs.has_data():
|
||||||
|
entries = []
|
||||||
|
after = {k: (float(v) if v is not None else None) for k, v in raw.items()}
|
||||||
|
# 无凭证明细(如 entity2)→ 不做重分类,全部回落到示例数据路径
|
||||||
|
for t in (before_t, after_t):
|
||||||
|
t.update({"assets": 0.0, "liabilities": 0.0, "equity": 0.0,
|
||||||
|
"liab_equity": 0.0, "residual": 0.0})
|
||||||
|
invariants.update({
|
||||||
|
"assets_after": 0.0, "liabilities_after": 0.0, "equity_after": 0.0,
|
||||||
|
"assets_delta": 0.0, "liabilities_delta": 0.0, "equity_delta": 0.0,
|
||||||
|
"reclass_total": 0.0, "entries_count": 0,
|
||||||
|
"delta_assets_equals_delta_liabilities": True, "equity_unchanged": True,
|
||||||
|
"residual_after": 0.0, "residual_unchanged": True,
|
||||||
|
"identity_holds_after": True, "no_negative_in_scope_after": True,
|
||||||
|
"negatives_before": 0, "negatives_after": 0,
|
||||||
|
})
|
||||||
|
neg_before = neg_after = neg_after_in_scope = []
|
||||||
|
|
||||||
|
return {
|
||||||
|
"rule_version": RULE_VERSION,
|
||||||
|
"basis": bs.basis,
|
||||||
|
"period": period,
|
||||||
|
"entity_id": entity_id,
|
||||||
|
"prev_period": _prev_period(period),
|
||||||
|
"has_voucher_data": bs.has_data(),
|
||||||
|
"rules": [r.__dict__ for r in rules],
|
||||||
|
"items": items,
|
||||||
|
"raw": {k: (float(v) if v is not None else None) for k, v in raw.items()},
|
||||||
|
"after": after,
|
||||||
|
"prev_raw": {k: (float(v) if v is not None else None) for k, v in prev.items()},
|
||||||
|
"entries": entries,
|
||||||
|
"invariants": invariants,
|
||||||
|
"totals_before": before_t,
|
||||||
|
"totals_after": after_t,
|
||||||
|
"negatives": {"before": neg_before, "after": neg_after, "after_in_scope": neg_after_in_scope},
|
||||||
|
"template_gaps": template_gaps,
|
||||||
|
"unmapped_subjects": unmapped_subjects(db, entity_id, period, bs.basis, items) if include_unmapped else [],
|
||||||
|
"pl_unclosed": pl_unclosed(db, entity_id, period, bs.basis) if include_unmapped else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _prev_period(period: str) -> str:
|
||||||
|
"""上一期间 YYYY-MM → YYYY-(MM-1)"""
|
||||||
|
try:
|
||||||
|
y, m = period.split("-")
|
||||||
|
y, m = int(y), int(m)
|
||||||
|
m -= 1
|
||||||
|
if m <= 0:
|
||||||
|
m += 12
|
||||||
|
y -= 1
|
||||||
|
return f"{y}-{m:02d}"
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return period
|
||||||
|
|
||||||
|
|
||||||
|
def pl_unclosed(db, entity_id: int, period: str, basis: str) -> Optional[float]:
|
||||||
|
"""损益类科目未结转余额(含结转凭证;口径 贷−借)——正常应为 0。
|
||||||
|
|
||||||
|
非 0 即为「勾稽差额」的直接来源(结转不完整),用于把不变量差额解释清楚。
|
||||||
|
"""
|
||||||
|
from app.models import VoucherDetail
|
||||||
|
from sqlalchemy import or_
|
||||||
|
|
||||||
|
try:
|
||||||
|
q = db.query(func.sum(VoucherDetail.credit_amount - VoucherDetail.debit_amount)).filter(
|
||||||
|
VoucherDetail.entity_id == entity_id,
|
||||||
|
or_(VoucherDetail.subject_code.like("5%"), VoucherDetail.subject_code.like("6%")),
|
||||||
|
)
|
||||||
|
if basis == "end":
|
||||||
|
q = q.filter(VoucherDetail.period <= period)
|
||||||
|
else:
|
||||||
|
q = q.filter(VoucherDetail.period == period)
|
||||||
|
v = apply_voucher_guard(db, q, VoucherDetail).scalar()
|
||||||
|
return round(float(v or 0), 2)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_category(code, cats: Dict[str, str]) -> Optional[str]:
|
||||||
|
"""科目 → asset/liability/equity:先精确、再逐级取上级编码(明细科目 category='detail')。"""
|
||||||
|
s = str(code)
|
||||||
|
for n in (len(s), 6, 4, 2):
|
||||||
|
if n > len(s):
|
||||||
|
continue
|
||||||
|
c = cats.get(s[:n])
|
||||||
|
if c in ("asset", "liability", "equity"):
|
||||||
|
return c
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def unmapped_subjects(db, entity_id: int, period: str, basis: str, items: Dict[str, dict]) -> List[dict]:
|
||||||
|
"""凭证明细里出现、但报表模板/规则都未列报的资产·负债·权益科目余额(勾稽差额诊断)。
|
||||||
|
|
||||||
|
只做诊断,不参与重分类——用于定位「资产合计 ≠ 负债 + 权益」的差额来源。
|
||||||
|
"""
|
||||||
|
from app.models import VoucherDetail, Subject
|
||||||
|
|
||||||
|
try:
|
||||||
|
q = db.query(
|
||||||
|
VoucherDetail.subject_code,
|
||||||
|
func.sum(VoucherDetail.debit_amount - VoucherDetail.credit_amount),
|
||||||
|
func.count(VoucherDetail.id),
|
||||||
|
).filter(VoucherDetail.entity_id == entity_id)
|
||||||
|
if basis == "end":
|
||||||
|
q = q.filter(VoucherDetail.period <= period)
|
||||||
|
else:
|
||||||
|
q = q.filter(VoucherDetail.period == period)
|
||||||
|
rows = apply_voucher_guard(db, q, VoucherDetail).group_by(VoucherDetail.subject_code).all()
|
||||||
|
cats = {c: cat for c, cat in db.query(Subject.subject_code, Subject.category).all()}
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return []
|
||||||
|
|
||||||
|
covered = []
|
||||||
|
for key, item in items.items():
|
||||||
|
for code, _ in item["codes"]:
|
||||||
|
covered.append(code)
|
||||||
|
|
||||||
|
agg: Dict[str, dict] = {}
|
||||||
|
for code, net, n in rows:
|
||||||
|
cat = _resolve_category(code, cats)
|
||||||
|
if cat is None:
|
||||||
|
continue
|
||||||
|
if any(str(code) == c or str(code).startswith(c) for c in covered if c):
|
||||||
|
continue
|
||||||
|
bucket = str(code)[:4] if len(str(code)) > 4 else str(code)
|
||||||
|
side = "asset" if cat == "asset" else ("liability" if cat == "liability" else "equity")
|
||||||
|
d = agg.setdefault(bucket, {"subject_code": bucket, "category": cat, "side": side,
|
||||||
|
"net_debit_minus_credit": 0.0, "rows": 0, "detail_codes": []})
|
||||||
|
d["net_debit_minus_credit"] += float(net or 0)
|
||||||
|
d["rows"] += int(n or 0)
|
||||||
|
if str(code) != bucket:
|
||||||
|
d["detail_codes"].append(str(code))
|
||||||
|
out = []
|
||||||
|
for d in agg.values():
|
||||||
|
d["net_debit_minus_credit"] = round(d["net_debit_minus_credit"], 2)
|
||||||
|
d["normal_side_balance"] = round(
|
||||||
|
d["net_debit_minus_credit"] if d["side"] == "asset" else -d["net_debit_minus_credit"], 2)
|
||||||
|
out.append(d)
|
||||||
|
return sorted(out, key=lambda x: -abs(x["normal_side_balance"]))
|
||||||
|
|
||||||
|
|
||||||
|
# ── 底稿(审计可追溯) ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def entry_rows(analysis: dict) -> List[dict]:
|
||||||
|
"""重分类底稿明细行:源科目/余额/转正额/目标项/依据。"""
|
||||||
|
rows = []
|
||||||
|
for e in analysis["entries"]:
|
||||||
|
rows.append({
|
||||||
|
"规则": e["rule"],
|
||||||
|
"来源科目": e["source_code"],
|
||||||
|
"科目名称": e["source_name"],
|
||||||
|
"原余额(元)": e["raw_balance"],
|
||||||
|
"异常方向": e["abnormal_direction"],
|
||||||
|
"转正额(元)": e["reclass_amount"],
|
||||||
|
"目标报表项目": e["target_item"],
|
||||||
|
"目标科目": "/".join(e["target_codes"]) or "-",
|
||||||
|
"目标项模板已列报": "是" if e["target_in_template"] else "否(虚拟行)",
|
||||||
|
"处理": "调表不调账",
|
||||||
|
"需先行差错核查": "是" if e["error_check"] else "否",
|
||||||
|
"依据/备注": e["note"],
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def diagnostic_rows(analysis: dict) -> List[dict]:
|
||||||
|
"""底稿诊断区:不变量 + 负数项目 + 模板未列报 + 未列报科目(勾稽差额诊断)。"""
|
||||||
|
inv = analysis["invariants"]
|
||||||
|
rows = [
|
||||||
|
{"项目": "规则版本", "值": inv["rule_version"], "说明": "取数层重分类规则集"},
|
||||||
|
{"项目": "取数口径 basis", "值": inv["basis"], "说明": "end=期末余额(累计至期间);period=本期发生额"},
|
||||||
|
{"项目": "触发规则数", "值": inv["entries_count"], "说明": "本轮实际触发的重分类条数"},
|
||||||
|
{"项目": "转正额合计(元)", "值": inv["reclass_total"], "说明": "调表不调账,不生成凭证"},
|
||||||
|
{"项目": "资产合计-重分类前(元)", "值": inv["assets_before"], "说明": ""},
|
||||||
|
{"项目": "资产合计-重分类后(元)", "值": inv["assets_after"], "说明": ""},
|
||||||
|
{"项目": "负债合计-重分类前(元)", "值": inv["liabilities_before"], "说明": ""},
|
||||||
|
{"项目": "负债合计-重分类后(元)", "值": inv["liabilities_after"], "说明": ""},
|
||||||
|
{"项目": "所有者权益合计-重分类前(元)", "值": inv["equity_before"], "说明": ""},
|
||||||
|
{"项目": "所有者权益合计-重分类后(元)", "值": inv["equity_after"], "说明": ""},
|
||||||
|
{"项目": "Δ资产 = Δ负债", "值": "PASS" if inv["delta_assets_equals_delta_liabilities"] else "FAIL",
|
||||||
|
"说明": f"Δ资产={inv['assets_delta']},Δ负债={inv['liabilities_delta']}"},
|
||||||
|
{"项目": "权益不变", "值": "PASS" if inv["equity_unchanged"] else "FAIL", "说明": ""},
|
||||||
|
{"项目": "勾稽差额不变", "值": "PASS" if inv["residual_unchanged"] else "FAIL",
|
||||||
|
"说明": f"前={inv['residual_before']},后={inv['residual_after']}"},
|
||||||
|
{"项目": "勾稽恒等式(资产=负债+权益)", "值": "平衡" if inv["identity_holds_after"] else "不平",
|
||||||
|
"说明": f"差额 {inv['residual_after']} 元(重分类前 {inv['residual_before']})"},
|
||||||
|
{"项目": "总资产不变", "值": "是" if inv["total_assets_unchanged"] else "否(跨方向重分类必然变化)",
|
||||||
|
"说明": inv["total_assets_unchanged_semantics"]},
|
||||||
|
{"项目": "规则范围内负数项目(重分类后)", "值": len(analysis["negatives"]["after_in_scope"]),
|
||||||
|
"说明": "应为 0"},
|
||||||
|
{"项目": "负数项目总数(重分类前/后)", "值": f"{inv['negatives_before']} / {inv['negatives_after']}",
|
||||||
|
"说明": "重分类范围外科目见下方清单"},
|
||||||
|
]
|
||||||
|
for n in analysis["negatives"]["after"]:
|
||||||
|
rows.append({
|
||||||
|
"项目": f"负数项目:{n['name']}",
|
||||||
|
"值": n["value"],
|
||||||
|
"说明": ("规则范围内(异常,需查)" if n["in_reclass_scope"]
|
||||||
|
else "R1~R12 未覆盖科目 → 差错更正/取数口径问题,需财务判定"),
|
||||||
|
})
|
||||||
|
for g in analysis["template_gaps"]:
|
||||||
|
rows.append({"项目": f"模板待核:{g['rule']} {g['source_code']}",
|
||||||
|
"值": g["status"], "说明": g["note"]})
|
||||||
|
plu = analysis.get("pl_unclosed")
|
||||||
|
if plu is not None:
|
||||||
|
rows.append({
|
||||||
|
"项目": "损益类未结转余额(应为 0)",
|
||||||
|
"值": plu,
|
||||||
|
"说明": ("与勾稽差额一致 → 差额来自结转不完整,需差错更正(非重分类能解决)"
|
||||||
|
if abs(plu) > 0.01 and abs(plu - inv.get("residual_after", 0)) < 0.01
|
||||||
|
else ("非 0,需差错核查" if abs(plu) > 0.01 else "已结平")),
|
||||||
|
})
|
||||||
|
for u in analysis["unmapped_subjects"]:
|
||||||
|
rows.append({
|
||||||
|
"项目": f"未列报科目:{u['subject_code']}",
|
||||||
|
"值": u["normal_side_balance"],
|
||||||
|
"说明": f"{u['side']};明细 {'/'.join(u['detail_codes']) or '-'};模板未列报 → 影响勾稽差额",
|
||||||
|
})
|
||||||
|
return rows
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""资产负债表重分类(R1~R12)现网只读验收脚本 — 2026-09-16
|
||||||
|
|
||||||
|
只读:全部经 ORM SELECT(voucher_guard 过滤生效),不写任何表、不生成凭证。
|
||||||
|
验收判据(对应派单 acceptance):
|
||||||
|
A1 R1~R12 规则在取数层落地(12 条登记,工程可执行)
|
||||||
|
A2 不变量:Δ资产=Δ负债、权益不变、勾稽差额不变(重分类不制造新的不平)
|
||||||
|
A3 酣客(entity1) 现网反向项重分类后,**规则范围内**不再出现负数项目
|
||||||
|
A4 多租户隔离:entity2 无凭证明细 → 0 条重分类,报表回示例口径
|
||||||
|
A5 底稿可用:底稿行数 == 触发条数
|
||||||
|
A6 端点集成:/balance-sheet 返回 reclass 块,负数项目全部被标注(在范围内/未覆盖)
|
||||||
|
|
||||||
|
用法:cd backend && venv/bin/python3 scripts/verify_reclass_live_20260916.py [period]
|
||||||
|
退出码:0 = 全部通过;1 = 存在 FAIL
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from app.database import get_session_local # noqa: E402
|
||||||
|
from app.api.reports import ( # noqa: E402
|
||||||
|
BALANCE_SHEET_SECTIONS, get_balance_sheet, get_reclass_working_paper,
|
||||||
|
)
|
||||||
|
from app.services import report_reclass as rr # noqa: E402
|
||||||
|
|
||||||
|
OUT = "/root/.hermes/profiles/wecom-fullstack/pending-tasks/_reports/finance-20260916-reclass-verify.json"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
period = sys.argv[1] if len(sys.argv) > 1 else "2026-08"
|
||||||
|
db = get_session_local()()
|
||||||
|
checks = []
|
||||||
|
|
||||||
|
def chk(name, ok, detail="", status=None):
|
||||||
|
st = status or ("PASS" if ok else "FAIL")
|
||||||
|
checks.append({"判据": name, "结果": st, "证据": detail})
|
||||||
|
return ok
|
||||||
|
|
||||||
|
result: dict = {"period": period, "generated_at": datetime.now().isoformat(timespec="seconds"),
|
||||||
|
"mode": "read-only"}
|
||||||
|
|
||||||
|
# ── 规则登记(A1)──
|
||||||
|
chk("A1 规则登记数 = 12 (R1~R12)",
|
||||||
|
len(rr.RECLASS_RULES) == 12,
|
||||||
|
f"rule_version={rr.RULE_VERSION},codes={[r.code for r in rr.RECLASS_RULES]}")
|
||||||
|
|
||||||
|
# ── 酣客 entity1:期末余额口径 ──
|
||||||
|
bs = get_balance_sheet(period=period, basis="end", db=db, entity_id=1)
|
||||||
|
inv = bs["reclass"]["invariants"]
|
||||||
|
lines = [ln for sec in bs["sections"] for ln in sec["lines"]]
|
||||||
|
result["entity1_end"] = {
|
||||||
|
"basis": bs["basis"], "totals": bs["totals"], "invariants": inv,
|
||||||
|
"entries": bs["reclass"]["entries"],
|
||||||
|
"negatives_after": bs["reclass"]["negatives"]["after"],
|
||||||
|
"template_gaps": bs["reclass"]["template_gaps"],
|
||||||
|
"unmapped_subjects": bs["reclass"]["unmapped_subjects"],
|
||||||
|
"pl_unclosed": bs["reclass"]["pl_unclosed"],
|
||||||
|
"lines": [{k: ln.get(k) for k in ("name", "item_key", "end_value", "raw_value",
|
||||||
|
"reclass_adjustment", "is_reclass_line", "template_gap")}
|
||||||
|
for ln in lines],
|
||||||
|
}
|
||||||
|
rules_hit = {e["rule"] for e in bs["reclass"]["entries"]}
|
||||||
|
chk("A3 酣客 3 处待核反向项已处置(1123/2202/2203 无负数)",
|
||||||
|
all(ln["end_value"] is None or ln["end_value"] >= -0.01
|
||||||
|
for ln in lines if ln["item_key"] in ("1123", "2202", "2203")),
|
||||||
|
"1123=%s, 2202=%s, 2203=%s" % tuple(
|
||||||
|
next((ln["end_value"] for ln in lines if ln["item_key"] == k), None)
|
||||||
|
for k in ("1123", "2202", "2203")))
|
||||||
|
chk("A3b 规则范围内负数项目 = 0",
|
||||||
|
inv["no_negative_in_scope_after"] and not bs["reclass"]["negatives"]["after_in_scope"],
|
||||||
|
f"触发规则={sorted(rules_hit)},转正额={inv['reclass_total']}")
|
||||||
|
chk("A2 不变量:Δ资产=Δ负债",
|
||||||
|
inv["delta_assets_equals_delta_liabilities"],
|
||||||
|
f"Δ资产={inv['assets_delta']} Δ负债={inv['liabilities_delta']}")
|
||||||
|
chk("A2b 不变量:权益不变", inv["equity_unchanged"],
|
||||||
|
f"权益 前={inv['equity_before']} 后={inv['equity_after']}")
|
||||||
|
chk("A2c 不变量:勾稽差额不变(不制造新的不平)", inv["residual_unchanged"],
|
||||||
|
f"前={inv['residual_before']} 后={inv['residual_after']}")
|
||||||
|
residual_ok = abs(inv["residual_after"]) < 0.01
|
||||||
|
unmapped_net = sum(u["normal_side_balance"] for u in bs["reclass"]["unmapped_subjects"])
|
||||||
|
chk("A2d 勾稽恒等式(现网原始口径,资产=负债+所有者权益)", residual_ok,
|
||||||
|
f"期末差额={inv['residual_before']} 元;已完整归因于模板未列报科目净额={unmapped_net},"
|
||||||
|
f"损益未结转={bs['reclass']['pl_unclosed']}")
|
||||||
|
chk("A2d2 归因校验:计入模板未列报科目后恒等式成立", abs(inv["residual_after"] + unmapped_net) < 0.01,
|
||||||
|
f"差额 {inv['residual_after']} + 未列报科目净额 {unmapped_net} = "
|
||||||
|
f"{round(inv['residual_after'] + unmapped_net, 2)} → 差额来源已闭环定位(1901 待处理财产损溢未清理)")
|
||||||
|
chk("A2e 「总资产不变」口径", False,
|
||||||
|
f"不成立(Δ资产={inv['assets_delta']}):跨方向重分类使资产与负债同额变动;"
|
||||||
|
f"恒定不变量为 Δ资产=Δ负债 且 资产−负债−权益不变(见 invariants.total_assets_unchanged_semantics)",
|
||||||
|
status="INFO-口径修正")
|
||||||
|
|
||||||
|
# ── 底稿(A5)──
|
||||||
|
wp = get_reclass_working_paper(period=period, basis="end", db=db, entity_id=1)
|
||||||
|
rows = rr.entry_rows({"entries": wp["entries"]})
|
||||||
|
chk("A5 底稿行数 == 触发条数", len(rows) == len(wp["entries"]) == inv["entries_count"],
|
||||||
|
f"底稿={len(rows)} 触发={inv['entries_count']}")
|
||||||
|
result["working_paper"] = wp
|
||||||
|
|
||||||
|
# ── 多租户(A4)──
|
||||||
|
bs2 = get_balance_sheet(period=period, basis="end", db=db, entity_id=2)
|
||||||
|
chk("A4 entity2 无凭证明细 → 0 条重分类、报表不被污染",
|
||||||
|
bs2["reclass"]["entries"] == [] and bs2["reclass"]["invariants"]["entries_count"] == 0
|
||||||
|
and bs2["all_items_have_data"] is False,
|
||||||
|
f"entity2 entries={len(bs2['reclass']['entries'])},totals={bs2['totals']}")
|
||||||
|
result["entity2_end"] = {"totals": bs2["totals"], "entries": bs2["reclass"]["entries"]}
|
||||||
|
|
||||||
|
# ── 端点集成(A6)──
|
||||||
|
bad = [ln["name"] for ln in lines if ln["end_value"] is not None and ln["end_value"] < -0.01]
|
||||||
|
in_scope_bad = [ln["name"] for ln in lines
|
||||||
|
if ln["end_value"] is not None and ln["end_value"] < -0.01
|
||||||
|
and any(e["source_key"] == ln["item_key"] or e["target_key"] == ln["item_key"]
|
||||||
|
for e in bs["reclass"]["entries"])]
|
||||||
|
chk("A6 端点返回 reclass 块,负数项目全部标注(未覆盖科目如实暴露)",
|
||||||
|
bool(bs["reclass"]) and not in_scope_bad and
|
||||||
|
all(any(n["key"] == ln["item_key"] for n in bs["reclass"]["negatives"]["after"])
|
||||||
|
for ln in lines if ln["end_value"] is not None and ln["end_value"] < -0.01),
|
||||||
|
f"重分类后仍为负数的项目(R1~R12 未覆盖,需财务判差错/口径):{bad}")
|
||||||
|
|
||||||
|
result["checks"] = checks
|
||||||
|
# 待财务处理项(本次实现暴露的账实/口径问题,非重分类可解决)
|
||||||
|
result["findings_for_finance"] = {
|
||||||
|
"未覆盖科目的反方向余额(需差错更正或取数口径修正)":
|
||||||
|
[{k: n[k] for k in ("name", "value", "side")} for n in bs["reclass"]["negatives"]["after"]],
|
||||||
|
"模板未列报科目(影响勾稽差额)":
|
||||||
|
[{k: u[k] for k in ("subject_code", "side", "normal_side_balance", "detail_codes")}
|
||||||
|
for u in bs["reclass"]["unmapped_subjects"]],
|
||||||
|
"规则/模板待核": [{"rule": g["rule"], "status": g["status"], "note": g["note"]}
|
||||||
|
for g in bs["reclass"]["template_gaps"]],
|
||||||
|
"损益类未结转余额": bs["reclass"]["pl_unclosed"],
|
||||||
|
"总资产不变口径修正": inv["total_assets_unchanged_semantics"],
|
||||||
|
}
|
||||||
|
result["before_after_by_item"] = [
|
||||||
|
{"项目": ln["name"], "item_key": ln["item_key"],
|
||||||
|
"重分类前": ln["raw_value"], "重分类后": ln["end_value"],
|
||||||
|
"调整额": ln["reclass_adjustment"], "虚拟行": ln["is_reclass_line"]}
|
||||||
|
for ln in lines
|
||||||
|
]
|
||||||
|
result["summary"] = {
|
||||||
|
"PASS": sum(1 for c in checks if c["结果"] == "PASS"),
|
||||||
|
"FAIL": sum(1 for c in checks if c["结果"] == "FAIL"),
|
||||||
|
"INFO": sum(1 for c in checks if c["结果"].startswith("INFO")),
|
||||||
|
}
|
||||||
|
os.makedirs(os.path.dirname(OUT), exist_ok=True)
|
||||||
|
with open(OUT, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(result, f, ensure_ascii=False, indent=2, default=str)
|
||||||
|
|
||||||
|
print(f"=== 资产负债表重分类现网验收(entity1 period={period},只读)===")
|
||||||
|
for c in checks:
|
||||||
|
print(f"[{c['结果']}] {c['判据']}\n 证据: {c['证据']}")
|
||||||
|
print(f"\n触发规则:{[ (e['rule'], e['source_code'], e['raw_balance'], e['target_item'], e['reclass_amount']) for e in bs['reclass']['entries'] ]}")
|
||||||
|
print(f"不变量:Δ资产={inv['assets_delta']} Δ负债={inv['liabilities_delta']} "
|
||||||
|
f"资产合计 {inv['assets_before']} → {inv['assets_after']} 权益 {inv['equity_before']}")
|
||||||
|
print(f"负数项目(重分类后):{[(n['name'], n['value'], '在规则范围内' if n['in_reclass_scope'] else '未覆盖') for n in bs['reclass']['negatives']['after']]}")
|
||||||
|
print(f"证据文件:{OUT}({os.path.getsize(OUT)} bytes)")
|
||||||
|
db.close()
|
||||||
|
return 1 if result["summary"]["FAIL"] else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
"""资产负债表取数层重分类(R1~R12,调表不调账)测试 — 2026-09-16
|
||||||
|
|
||||||
|
规则来源:《CMA 资产负债表重分类规则表 v1》(任总 2026-09-16 批准)
|
||||||
|
被测:`app/services/report_reclass.py` + `/api/cma/reports/balance-sheet*` 端点
|
||||||
|
|
||||||
|
覆盖:
|
||||||
|
T1 R1~R10 触发与转正(含互为镜像的 R3/R4、多规则叠加)
|
||||||
|
T2 模板未列报目标项(R7/R8 应收/应付票据)→ 虚拟行列报
|
||||||
|
T3 不变量:Δ资产=Δ负债、权益不变、勾稽差额不变、无负数(规则范围内)
|
||||||
|
T4 幂等:同参数重复分析结果一致(不写库)
|
||||||
|
T5 多租户隔离:entity1/entity2 独立,不串账套
|
||||||
|
T6 取数口径:basis=end(期末余额,累计)vs basis=period(本期发生额)
|
||||||
|
T7 无凭证明细 → 示例数据路径不受影响(不报错、不重分类)
|
||||||
|
T8 R11/R12 与模板待核项如实暴露(blocked / template_gap)
|
||||||
|
T9 规则范围外的反方向余额(货币资金/存货)如实标记为未覆盖,不误报"已解决"
|
||||||
|
T10 底稿导出(JSON + Excel)可用
|
||||||
|
"""
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from tests.conftest import create_test_user, get_token_for_user, auth_header
|
||||||
|
from app.models import VoucherDetail, Subject
|
||||||
|
from app.services import report_reclass as rr
|
||||||
|
|
||||||
|
BASE = "/api/cma/reports"
|
||||||
|
PERIOD = "2026-06"
|
||||||
|
PREV = "2026-05"
|
||||||
|
|
||||||
|
_n = {"v": 0}
|
||||||
|
|
||||||
|
|
||||||
|
def _v(db: Session, entity_id: int, period: str, lines, carry_forward: int = 0):
|
||||||
|
"""插入一张凭证的若干行(lines: [(subject_code, debit, credit)])"""
|
||||||
|
_n["v"] += 1
|
||||||
|
y, m = period.split("-")
|
||||||
|
for code, d, c in lines:
|
||||||
|
db.add(VoucherDetail(
|
||||||
|
entity_id=entity_id, voucher_no=f"T{_n['v']:05d}",
|
||||||
|
voucher_date=date(int(y), int(m), 15), subject_code=code,
|
||||||
|
debit_amount=d, credit_amount=c, period=period,
|
||||||
|
summary="test", carry_forward=carry_forward,
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def seed_reverse_items(db: Session, entity_id: int = 1, period: str = PERIOD):
|
||||||
|
"""构造含 R1~R10 反向余额的**平衡**账套(每张凭证借贷相等)。
|
||||||
|
|
||||||
|
期望触发(原始余额 NSB<0):
|
||||||
|
R2 2203 预收账款 借方 100,000 → 应收账款
|
||||||
|
R3 2202 应付账款 借方 30,000 → 预付账款
|
||||||
|
R4 1123 预付账款 贷方 50,000 → 应付账款
|
||||||
|
R5 1221 其他应收款 贷方 12,000 → 其他应付款
|
||||||
|
R6 2241 其他应付款 借方 7,000 → 其他应收款
|
||||||
|
R9 2221 应交税费 借方 25,000 → 其他流动资产
|
||||||
|
R10 2211 应付职工薪酬 借方 9,000 → 其他应收款
|
||||||
|
"""
|
||||||
|
_v(db, entity_id, period, [("1002", 1_000_000, 0), ("4001", 0, 1_000_000)]) # 实收资本
|
||||||
|
_v(db, entity_id, period, [("1002", 400_000, 0), ("2203", 0, 400_000)]) # 预收
|
||||||
|
_v(db, entity_id, period, [("2203", 500_000, 0), ("1002", 0, 500_000)]) # 预收反方向
|
||||||
|
_v(db, entity_id, period, [("1123", 80_000, 0), ("1002", 0, 80_000)]) # 预付
|
||||||
|
_v(db, entity_id, period, [("1002", 130_000, 0), ("1123", 0, 130_000)]) # 预付反方向
|
||||||
|
_v(db, entity_id, period, [("2202", 30_000, 0), ("1002", 0, 30_000)]) # 应付借方
|
||||||
|
_v(db, entity_id, period, [("1002", 12_000, 0), ("1221", 0, 12_000)]) # 其他应收反方向
|
||||||
|
_v(db, entity_id, period, [("2241", 7_000, 0), ("1002", 0, 7_000)]) # 其他应付借方
|
||||||
|
_v(db, entity_id, period, [("2221", 25_000, 0), ("1002", 0, 25_000)]) # 应交税费留抵
|
||||||
|
_v(db, entity_id, period, [("2211", 9_000, 0), ("1002", 0, 9_000)]) # 应付职工薪酬多付
|
||||||
|
# 规则未覆盖的反方向余额(货币资金):库存现金贷方 1,500,000 → 货币资金为负
|
||||||
|
_v(db, entity_id, period, [("1122", 1_500_000, 0), ("1001", 0, 1_500_000)])
|
||||||
|
|
||||||
|
|
||||||
|
def seed_receivable_side(db: Session, entity_id: int = 1, period: str = PREV):
|
||||||
|
"""R1 + R7/R8(模板未列报的票据类)"""
|
||||||
|
_v(db, entity_id, period, [("1002", 300_000, 0), ("1122", 0, 300_000)]) # 回款超应收
|
||||||
|
_v(db, entity_id, period, [("1002", 60_000, 0), ("1121", 0, 60_000)]) # 应收票据反方向
|
||||||
|
_v(db, entity_id, period, [("2201", 20_000, 0), ("1002", 0, 20_000)]) # 应付票据借方
|
||||||
|
|
||||||
|
|
||||||
|
def _analyze(db, period=PERIOD, entity_id=1, basis="end"):
|
||||||
|
from app.api.reports import BALANCE_SHEET_SECTIONS
|
||||||
|
return rr.analyze(db, entity_id, period, basis=basis, template_sections=BALANCE_SHEET_SECTIONS)
|
||||||
|
|
||||||
|
|
||||||
|
class TestReclassEngine:
|
||||||
|
def test_t1_triggers_and_reversal(self, db: Session):
|
||||||
|
seed_reverse_items(db)
|
||||||
|
p = _analyze(db)
|
||||||
|
got = {e["rule"]: e for e in p["entries"]}
|
||||||
|
assert set(got) == {"R2", "R3", "R4", "R5", "R6", "R9", "R10"}, f"实际触发: {sorted(got)}"
|
||||||
|
assert got["R2"]["reclass_amount"] == pytest.approx(100_000.0)
|
||||||
|
assert got["R2"]["target_item"] == "应收账款" and got["R2"]["target_in_template"] is True
|
||||||
|
assert got["R3"]["reclass_amount"] == pytest.approx(30_000.0)
|
||||||
|
assert got["R4"]["reclass_amount"] == pytest.approx(50_000.0)
|
||||||
|
# 互为镜像的 R3/R4 各自按**原始余额**触发,不互相影响,两侧都被转正
|
||||||
|
assert p["after"]["2202"] == pytest.approx(50_000.0)
|
||||||
|
assert p["after"]["1123"] == pytest.approx(30_000.0)
|
||||||
|
assert p["after"]["2203"] == pytest.approx(0.0)
|
||||||
|
assert p["after"]["1221"] == pytest.approx(16_000.0) # R5 转出 + R6/R10 转入
|
||||||
|
assert p["after"]["2241"] == pytest.approx(12_000.0)
|
||||||
|
assert p["after"]["OCA_OTHER"] == pytest.approx(25_000.0) # R9 → 其他流动资产
|
||||||
|
assert p["after"]["2211"] == pytest.approx(0.0)
|
||||||
|
# 全部转正额 = 重分类总额
|
||||||
|
assert sum(e["reclass_amount"] for e in p["entries"]) == pytest.approx(233_000.0)
|
||||||
|
|
||||||
|
def test_t2_template_gap_virtual_lines(self, db: Session):
|
||||||
|
seed_receivable_side(db)
|
||||||
|
p = _analyze(db, period=PREV)
|
||||||
|
got = {e["rule"]: e for e in p["entries"]}
|
||||||
|
assert set(got) == {"R1", "R7", "R8"}
|
||||||
|
assert got["R1"]["target_item"] == "预收账款" # 小企业准则无「合同负债」
|
||||||
|
assert got["R7"]["target_item"] == "应付票据"
|
||||||
|
assert got["R7"]["target_in_template"] is False # 模板未列报 → 虚拟行
|
||||||
|
assert p["after"]["1121"] == pytest.approx(20_000.0)
|
||||||
|
assert p["after"]["2201"] == pytest.approx(60_000.0)
|
||||||
|
assert p["after"]["1122"] == pytest.approx(0.0)
|
||||||
|
assert p["after"]["2203"] == pytest.approx(300_000.0)
|
||||||
|
assert any(g["rule"] == "R7" and g["status"] == "template_gap" for g in p["template_gaps"])
|
||||||
|
|
||||||
|
def test_t3_invariants(self, db: Session):
|
||||||
|
seed_reverse_items(db)
|
||||||
|
p = _analyze(db)
|
||||||
|
inv = p["invariants"]
|
||||||
|
assert inv["delta_assets_equals_delta_liabilities"] is True
|
||||||
|
assert inv["assets_delta"] == pytest.approx(233_000.0)
|
||||||
|
assert inv["liabilities_delta"] == pytest.approx(233_000.0)
|
||||||
|
assert inv["equity_unchanged"] is True
|
||||||
|
assert inv["residual_unchanged"] is True
|
||||||
|
assert inv["identity_holds_before"] is True and inv["identity_holds_after"] is True
|
||||||
|
# 跨方向重分类必然改变总资产 —— 不隐藏这一点
|
||||||
|
assert inv["total_assets_unchanged"] is False
|
||||||
|
assert inv["reclass_total"] == pytest.approx(233_000.0)
|
||||||
|
# 规则范围内的负数项目重分类后清零
|
||||||
|
assert inv["no_negative_in_scope_after"] is True
|
||||||
|
assert p["negatives"]["after_in_scope"] == []
|
||||||
|
# 规则未覆盖的货币资金负数如实标记(不误报已解决)
|
||||||
|
out = [n for n in p["negatives"]["after"] if not n["in_reclass_scope"]]
|
||||||
|
assert any(n["name"] == "货币资金" for n in out)
|
||||||
|
|
||||||
|
def test_t4_idempotent(self, db: Session):
|
||||||
|
seed_reverse_items(db)
|
||||||
|
a = _analyze(db)
|
||||||
|
b = _analyze(db)
|
||||||
|
assert [e["rule"] for e in a["entries"]] == [e["rule"] for e in b["entries"]]
|
||||||
|
assert a["after"] == b["after"]
|
||||||
|
assert a["invariants"]["reclass_total"] == b["invariants"]["reclass_total"]
|
||||||
|
|
||||||
|
def test_t5_entity_isolation(self, db: Session):
|
||||||
|
seed_reverse_items(db, entity_id=1)
|
||||||
|
_v(db, 2, PERIOD, [("1002", 5_000, 0), ("4001", 0, 5_000)])
|
||||||
|
p1 = _analyze(db, entity_id=1)
|
||||||
|
p2 = _analyze(db, entity_id=2)
|
||||||
|
assert p1["invariants"]["entries_count"] == 7
|
||||||
|
assert p2["invariants"]["entries_count"] == 0 # entity2 无反方向余额
|
||||||
|
assert p2["after"]["2202"] == pytest.approx(0.0)
|
||||||
|
assert p2["invariants"]["assets_after"] == pytest.approx(5_000.0)
|
||||||
|
# entity3 完全无数据 → 不重分类、不报错
|
||||||
|
p3 = _analyze(db, entity_id=3)
|
||||||
|
assert p3["has_voucher_data"] is False and p3["entries"] == []
|
||||||
|
|
||||||
|
def test_t6_basis_semantics(self, db: Session):
|
||||||
|
# 2026-05 预收贷方 400,000(正常);2026-06 预收借方 500,000
|
||||||
|
_v(db, 1, PREV, [("1002", 400_000, 0), ("2203", 0, 400_000)])
|
||||||
|
_v(db, 1, PERIOD, [("2203", 500_000, 0), ("1002", 0, 500_000)])
|
||||||
|
end = _analyze(db, period=PERIOD, basis="end") # 期末余额 = 累计 → -100,000
|
||||||
|
per = _analyze(db, period=PERIOD, basis="period") # 本期发生额 → -500,000
|
||||||
|
assert end["basis"] == "end" and per["basis"] == "period"
|
||||||
|
assert end["raw"]["2203"] == pytest.approx(-100_000.0)
|
||||||
|
assert per["raw"]["2203"] == pytest.approx(-500_000.0)
|
||||||
|
# 两个口径各自自洽(都转为正数),但转正额不同 → 报表列报必须用期末余额口径
|
||||||
|
assert [e["rule"] for e in end["entries"]] == ["R2"]
|
||||||
|
assert [e["rule"] for e in per["entries"]] == ["R2"]
|
||||||
|
assert end["entries"][0]["reclass_amount"] == pytest.approx(100_000.0)
|
||||||
|
assert per["entries"][0]["reclass_amount"] == pytest.approx(500_000.0)
|
||||||
|
assert per["after"]["2203"] == pytest.approx(0.0)
|
||||||
|
|
||||||
|
def test_t8_blocked_rules_exposed(self, db: Session):
|
||||||
|
seed_reverse_items(db)
|
||||||
|
p = _analyze(db)
|
||||||
|
gaps = {g["rule"]: g for g in p["template_gaps"]}
|
||||||
|
assert gaps["R11"]["status"] == "blocked" and gaps["R12"]["status"] == "blocked"
|
||||||
|
assert gaps["R9"]["status"] == "template_gap"
|
||||||
|
# 阻塞规则(R11/R12)绝不落地
|
||||||
|
triggered = {e["rule"] for e in p["entries"]}
|
||||||
|
assert "R11" not in triggered and "R12" not in triggered
|
||||||
|
# 模板未列报的目标项(R7/R8/R9)落地时在报表内按虚拟行列报
|
||||||
|
for rule in ("R7", "R8", "R9"):
|
||||||
|
assert gaps[rule]["in_report"] is True
|
||||||
|
|
||||||
|
def test_t10_working_paper_rows(self, db: Session):
|
||||||
|
seed_reverse_items(db)
|
||||||
|
p = _analyze(db)
|
||||||
|
rows = rr.entry_rows(p)
|
||||||
|
assert len(rows) == len(p["entries"]) == 7
|
||||||
|
for row in rows:
|
||||||
|
assert row["处理"] == "调表不调账" and row["转正额(元)"] > 0
|
||||||
|
assert row["来源科目"] and row["目标报表项目"]
|
||||||
|
diag = {r["项目"]: r for r in rr.diagnostic_rows(p)}
|
||||||
|
assert diag["Δ资产 = Δ负债"]["值"] == "PASS"
|
||||||
|
assert diag["权益不变"]["值"] == "PASS"
|
||||||
|
assert diag["规则范围内负数项目(重分类后)"]["值"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestReclassEndpoints:
|
||||||
|
def _auth(self, client, db):
|
||||||
|
create_test_user(db)
|
||||||
|
return auth_header(get_token_for_user(client))
|
||||||
|
|
||||||
|
def test_t7_no_voucher_data_demo_path(self, client: TestClient, db: Session):
|
||||||
|
h = self._auth(client, db)
|
||||||
|
resp = client.get(f"{BASE}/balance-sheet?period={PERIOD}", headers=h)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert len(data["sections"]) == 5
|
||||||
|
assert data["reclass"]["entries"] == []
|
||||||
|
assert data["reclass"]["applied"] is False
|
||||||
|
assert data["all_items_have_data"] is False
|
||||||
|
assert data["totals"]["balanced"] is True
|
||||||
|
|
||||||
|
def test_t7b_reclass_applied_on_real_data(self, client: TestClient, db: Session):
|
||||||
|
seed_reverse_items(db)
|
||||||
|
h = self._auth(client, db)
|
||||||
|
data = client.get(f"{BASE}/balance-sheet?period={PERIOD}&basis=end", headers=h).json()
|
||||||
|
assert data["basis"] == "end"
|
||||||
|
assert data["reclass"]["applied"] is True
|
||||||
|
assert data["reclass"]["invariants"]["entries_count"] == 7
|
||||||
|
assert data["reclass"]["invariants"]["no_negative_in_scope_after"] is True
|
||||||
|
# 有凭证明细 → 不再混入示例数据(展示口径与落库口径同源)
|
||||||
|
assert all(not ln["is_demo"] for sec in data["sections"] for ln in sec["lines"])
|
||||||
|
# 明细行带原值/调整额,可追溯
|
||||||
|
lines = {ln["item_key"]: ln for sec in data["sections"] for ln in sec["lines"]}
|
||||||
|
assert lines["2203"]["raw_value"] == pytest.approx(-100_000.0)
|
||||||
|
assert lines["2203"]["reclass_adjustment"] == pytest.approx(100_000.0)
|
||||||
|
assert lines["2203"]["end_value"] == pytest.approx(0.0)
|
||||||
|
# 不变量同步暴露给前端/底稿
|
||||||
|
inv = data["reclass"]["invariants"]
|
||||||
|
assert inv["delta_assets_equals_delta_liabilities"] and inv["equity_unchanged"]
|
||||||
|
assert data["reclass"]["working_paper_url"]
|
||||||
|
|
||||||
|
def test_t2b_virtual_line_rendered_in_section(self, client: TestClient, db: Session):
|
||||||
|
seed_receivable_side(db)
|
||||||
|
create_test_user(db)
|
||||||
|
h = auth_header(get_token_for_user(client))
|
||||||
|
data = client.get(f"{BASE}/balance-sheet?period={PREV}", headers=h).json()
|
||||||
|
ca = next(s for s in data["sections"] if s["key"] == "current_assets")
|
||||||
|
cl = next(s for s in data["sections"] if s["key"] == "current_liabilities")
|
||||||
|
assert any(ln["item_key"] == "1121" and ln["is_reclass_line"] for ln in ca["lines"])
|
||||||
|
assert any(ln["item_key"] == "2201" and ln["is_reclass_line"] for ln in cl["lines"])
|
||||||
|
|
||||||
|
def test_t10b_working_paper_endpoint(self, client: TestClient, db: Session):
|
||||||
|
seed_reverse_items(db)
|
||||||
|
create_test_user(db)
|
||||||
|
h = auth_header(get_token_for_user(client))
|
||||||
|
wp = client.get(f"{BASE}/balance-sheet/reclass-working-paper?period={PERIOD}", headers=h)
|
||||||
|
assert wp.status_code == 200
|
||||||
|
body = wp.json()
|
||||||
|
assert body["accounting_method"].startswith("调表不调账")
|
||||||
|
assert body["rule_version"] == rr.RULE_VERSION
|
||||||
|
assert len(body["entries"]) == 7
|
||||||
|
assert body["invariants"]["reclass_total"] == pytest.approx(233_000.0)
|
||||||
|
assert len(body["rules"]) == 12 # R1~R12 全部登记
|
||||||
|
xl = client.get(f"{BASE}/balance-sheet/reclass-working-paper/export?period={PERIOD}", headers=h)
|
||||||
|
assert xl.status_code == 200
|
||||||
|
assert "spreadsheetml" in xl.headers["content-type"]
|
||||||
|
assert len(xl.content) > 4000 # 非空 Excel
|
||||||
|
|
||||||
|
def test_t5b_entity_isolation_endpoint(self, client: TestClient, db: Session):
|
||||||
|
seed_reverse_items(db, entity_id=1)
|
||||||
|
_v(db, 2, PERIOD, [("1002", 5_000, 0), ("4001", 0, 5_000)])
|
||||||
|
create_test_user(db)
|
||||||
|
h = auth_header(get_token_for_user(client)) # 登录账套 = entity1
|
||||||
|
d1 = client.get(f"{BASE}/balance-sheet?period={PERIOD}", headers=h).json()
|
||||||
|
assert d1["reclass"]["invariants"]["entries_count"] == 7
|
||||||
|
# 显式声明另一账套 → 403(多租户边界)
|
||||||
|
assert client.get(f"{BASE}/balance-sheet?period={PERIOD}&entity_id=2", headers=h).status_code == 403
|
||||||
|
# 无凭证明细的账套(切到 2 需另一 token,这里用内部调用验证引擎层隔离)
|
||||||
|
p2 = _analyze(db, entity_id=2)
|
||||||
|
assert p2["invariants"]["entries_count"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestUnmappedDiagnosis:
|
||||||
|
def test_unmapped_subjects_and_gap_explained(self, db: Session):
|
||||||
|
"""模板未列报科目 → 出现在诊断里,且勾稽差额与之一致"""
|
||||||
|
_v(db, 1, PERIOD, [("1002", 1_000, 0), ("4001", 0, 1_000)])
|
||||||
|
_v(db, 1, PERIOD, [("1801", 200, 0), ("1002", 0, 200)]) # 长期待摊费用(新补的行,不再未列报)
|
||||||
|
_v(db, 1, PERIOD, [("1901", 300, 0), ("1002", 0, 300)]) # 待处理财产损溢(模板未列报)
|
||||||
|
db.add(Subject(subject_code="1901", subject_name="待处理财产损溢", category="asset"))
|
||||||
|
db.add(Subject(subject_code="190102", subject_name="待处理流动资产损溢", category="detail"))
|
||||||
|
db.commit()
|
||||||
|
p = _analyze(db)
|
||||||
|
codes = [u["subject_code"] for u in p["unmapped_subjects"]]
|
||||||
|
assert "1901" in codes
|
||||||
|
unmapped_sum = sum(u["normal_side_balance"] for u in p["unmapped_subjects"])
|
||||||
|
assert p["invariants"]["residual_after"] == pytest.approx(-unmapped_sum)
|
||||||
Reference in New Issue
Block a user