feat(cma-report): 建账期初层 + 方向判定可信度 + 权益行科目集(M1/M2/M7,规则表 v1.1)
- M1 时点报表口径:余额 = 期初(report_opening_balances) + 累计发生额;期初只作用于取数层
(不生成凭证/不写 voucher_details/不改 kpi_values);basis=period 不套期初;受 effective_date 约束
- M2 期初未补 → direction_check_unreliable=true:报表/底稿带"期初未补,方向判定不可信"水印,
重分类触发只进底稿诊断(diagnostic_only),不落地对外行项目
- M7 权益行科目集 = {3001,3002,3101,3103,3104,3105}(含 4001/4002/4101/4103/4104 别名):补实收资本 3001
- 1901 待处理财产损溢过渡列报「其他流动资产」以保恒等式(M6 归因文案)
- 只读验收脚本 + 幂等导入器(dry-run 默认,--commit 门控)
This commit is contained in:
+46
-10
@@ -1410,12 +1410,19 @@ BALANCE_SHEET_SECTIONS = [
|
||||
"key": "equity",
|
||||
"name": "所有者权益",
|
||||
"category_label": "所有者权益",
|
||||
# M7(规则表 v1.1 §三):权益行科目集 = {3001,3002,3101,3103,3104,3105}
|
||||
# (含 4001/4002/4101/4103/4104 别名)。账套实际记账用小企业准则 3xxx 科目,
|
||||
# 别名只参与取数、不参与行 key(避免打破按 key 的预算映射)。缺 3001 会漏实收资本 2,000,000。
|
||||
"lines": [
|
||||
{"codes": [("4001", 1)], "name": "实收资本", "ns_category": "equity"},
|
||||
{"codes": [("4002", 1)], "name": "资本公积", "ns_category": "equity"},
|
||||
{"codes": [("4001", 1)], "alias_codes": [("3001", 1)],
|
||||
"name": "实收资本", "ns_category": "equity"},
|
||||
{"codes": [("4002", 1)], "alias_codes": [("3002", 1)],
|
||||
"name": "资本公积", "ns_category": "equity"},
|
||||
{"codes": [("4101", 1)], "alias_codes": [("3101", 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)],
|
||||
"alias_codes": [("3103", 1), ("3104", 1), ("3105", 1)],
|
||||
"name": "未分配利润", "ns_category": "equity"},
|
||||
],
|
||||
},
|
||||
@@ -1549,20 +1556,26 @@ def get_balance_sheet(
|
||||
|
||||
plan = _bs_analysis(db, entity_id, period, use_basis)
|
||||
has_real = bool(plan["has_voucher_data"])
|
||||
# M2:期初未补 → 方向判定不可信 → 重分类结论只进底稿诊断,不落地到对外行项目
|
||||
diag_only = bool(plan.get("direction_check_unreliable"))
|
||||
plan_prev = _bs_analysis(db, entity_id, prev_period, use_basis, include_unmapped=False) if has_real else None
|
||||
|
||||
def _present_key(pl: Optional[dict]) -> str:
|
||||
"""M2:期初未补(方向判定不可信)时,对外行项目用**原始余额**(不呈现"重分类转正")"""
|
||||
return "raw" if (pl or {}).get("direction_check_unreliable") else "after"
|
||||
|
||||
def _line_value(pl: Optional[dict], key: str, col: str):
|
||||
if pl is None:
|
||||
return None
|
||||
return pl["after"].get(key)
|
||||
return pl[_present_key(pl)].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
|
||||
end_v = plan[_present_key(plan)].get(key)
|
||||
begin_v = plan_prev[_present_key(plan_prev)].get(key) if plan_prev else None
|
||||
if not end_v and not begin_v:
|
||||
continue
|
||||
virtual.setdefault(item["section_key"], []).append({
|
||||
@@ -1604,6 +1617,8 @@ def get_balance_sheet(
|
||||
if begin["value"] is not None:
|
||||
sec_begin += begin["value"]
|
||||
raw_key = plan["raw"].get(key)
|
||||
diag_adj = None if raw_key is None else round(
|
||||
float(plan["after"].get(key) or 0) - float(raw_key), 2)
|
||||
lines.append({
|
||||
"name": line["name"],
|
||||
"item_key": key,
|
||||
@@ -1615,8 +1630,11 @@ def get_balance_sheet(
|
||||
"is_reclass_line": False,
|
||||
"template_gap": False,
|
||||
"raw_value": None if raw_key is None else round(float(raw_key), 2),
|
||||
# 对外调整额按呈现口径计算:期初未补(方向判定不可信)时不落地 → 0
|
||||
"reclass_adjustment": None if raw_key is None else round(
|
||||
float(plan["after"].get(key) or 0) - float(raw_key), 2),
|
||||
float(_line_value(plan, key, "end") or 0) - float(raw_key), 2),
|
||||
# 诊断用调整额(无论是否落地都给出,供底稿/审计追溯)
|
||||
"diagnostic_reclass_adjustment": diag_adj,
|
||||
})
|
||||
|
||||
# 规则落位项目(虚拟行)
|
||||
@@ -1674,7 +1692,12 @@ def get_balance_sheet(
|
||||
},
|
||||
# ── 重分类(调表不调账)──
|
||||
"reclass": {
|
||||
"applied": bool(plan["entries"]),
|
||||
# M2:期初未补 → 方向判定不可信 → 重分类结论只作底稿诊断,不落地到对外行项目
|
||||
"applied": bool(plan["entries"]) and not diag_only,
|
||||
"diagnostic_only": diag_only,
|
||||
"direction_check_unreliable": diag_only,
|
||||
"caliber_warning": plan.get("caliber_warning") or "",
|
||||
"opening": plan.get("opening"),
|
||||
"rule_version": plan["rule_version"],
|
||||
"basis": plan["basis"],
|
||||
"has_voucher_data": has_real,
|
||||
@@ -1682,6 +1705,7 @@ def get_balance_sheet(
|
||||
"invariants": inv,
|
||||
"negatives": plan["negatives"],
|
||||
"template_gaps": plan["template_gaps"],
|
||||
"residual_reported": plan.get("residual_reported") or [],
|
||||
"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}",
|
||||
@@ -1709,10 +1733,16 @@ def get_reclass_working_paper(
|
||||
"rule_version": plan["rule_version"],
|
||||
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"accounting_method": "调表不调账(报表取数层重分类,不生成记账凭证)",
|
||||
# ── M1/M2:期初层 + 方向判定可信度(期初未补 → 只作诊断)──
|
||||
"direction_check_unreliable": plan.get("direction_check_unreliable", False),
|
||||
"caliber_warning": plan.get("caliber_warning") or "",
|
||||
"opening": plan.get("opening"),
|
||||
"diagnostic_only": bool(plan.get("direction_check_unreliable")),
|
||||
"rules": plan["rules"],
|
||||
"entries": plan["entries"],
|
||||
"invariants": plan["invariants"],
|
||||
"template_gaps": plan["template_gaps"],
|
||||
"residual_reported": plan.get("residual_reported") or [],
|
||||
"negatives": plan["negatives"],
|
||||
"unmapped_subjects": plan["unmapped_subjects"],
|
||||
"pl_unclosed": plan["pl_unclosed"],
|
||||
@@ -1759,13 +1789,15 @@ def export_reclass_working_paper(
|
||||
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)
|
||||
warn = (" ⚠️ 期初未补,方向判定不可信(重分类结论仅诊断,不落地)"
|
||||
if plan.get("direction_check_unreliable") else "")
|
||||
ws1 = wb.active
|
||||
ws1.title = "重分类底稿"
|
||||
if e_rows:
|
||||
_sheet(ws1, f"资产负债表重分类底稿(调表不调账) {period} 账套 entity_id={entity_id} 口径basis={use_basis} {plan['rule_version']}",
|
||||
_sheet(ws1, f"资产负债表重分类底稿(调表不调账) {period} 账套 entity_id={entity_id} 口径basis={use_basis} {plan['rule_version']}{warn}",
|
||||
list(e_rows[0].keys()), e_rows)
|
||||
else:
|
||||
ws1.cell(row=1, column=1, value=f"本期({period})无触发的重分类规则").font = Font(bold=True)
|
||||
ws1.cell(row=1, column=1, value=f"本期({period})无触发的重分类规则{warn}").font = Font(bold=True)
|
||||
|
||||
d_rows = report_reclass.diagnostic_rows(plan)
|
||||
ws2 = wb.create_sheet("不变量与诊断")
|
||||
@@ -2091,6 +2123,10 @@ def export_statutory_reports(
|
||||
"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 [],
|
||||
# M1/M2:期初层 + 方向判定可信度(底稿 Sheet 同源暴露)
|
||||
"residual_reported": (bs.get("reclass") or {}).get("residual_reported") or [],
|
||||
"direction_check_unreliable": (bs.get("reclass") or {}).get("direction_check_unreliable", False),
|
||||
"caliber_warning": (bs.get("reclass") or {}).get("caliber_warning", ""),
|
||||
}
|
||||
for row in report_reclass.diagnostic_rows(plan_like):
|
||||
r = write_row(ws4, r, [row.get("项目"), row.get("值"), row.get("说明")])
|
||||
|
||||
@@ -8,6 +8,7 @@ from app.models.knowledge import KnowledgeEvent, KnowledgeSummary
|
||||
from app.models.driver_budget import DriverFactorTemplate, DriverFactorBudget
|
||||
from app.models.product_sales import ProductSales
|
||||
from app.models.ext_input_ledger import TaxFilingLog, HrMonthlyLedger, LicenceLedger
|
||||
from app.models.report_opening import ReportOpeningBalance
|
||||
from app.models.treasury import (
|
||||
TreasuryAccount, TreasuryBalance, TreasuryControlRule,
|
||||
TreasuryPayment, TreasuryControlHit, TreasuryLimit,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""报表科目期初余额(账套建账期初层)— 2026-09-16
|
||||
|
||||
时点报表口径铁律(《CMA 资产负债表重分类规则表 v1.1》§零):
|
||||
|
||||
余额 = 期初余额 + Σ(借−贷)(资产) / 期初余额 + Σ(贷−借)(负债·权益)
|
||||
|
||||
CMA 原有取数只有 `voucher_details` 累计发生额(账套最早期间 2021-03),**不含建账期初**,
|
||||
使全部往来/税费类科目出现伪反向项(实测:1001 差 346,139.02、1405 差 6,133,427.20……逐月恒定)。
|
||||
|
||||
本表只作用于**报表取数层**:
|
||||
· 不生成记账凭证、不写 `voucher_details`、不改 `kpi_values`;
|
||||
· 期初数据来源:反解清单(status=derived,待源系统《科目余额表 2021.03》确认 → confirmed)。
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, Numeric, DateTime, UniqueConstraint, func
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class ReportOpeningBalance(Base):
|
||||
"""账套建账期初余额(一账套一科目一期初行)"""
|
||||
|
||||
__tablename__ = "report_opening_balances"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("entity_id", "subject_code", "effective_date",
|
||||
name="uq_report_opening_entity_subject_effective"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, nullable=False, index=True, comment="账套(实体)ID")
|
||||
subject_code = Column(String(20), nullable=False, index=True, comment="科目编码(前缀匹配)")
|
||||
subject_name = Column(String(100), comment="科目名称")
|
||||
balance_side = Column(String(10), nullable=False, comment="余额方向 debit/credit")
|
||||
amount = Column(Numeric(18, 2), nullable=False, default=0, comment="期初余额(正数)")
|
||||
effective_date = Column(String(7), nullable=False, default="2021-03",
|
||||
comment="生效期间 YYYY-MM(建账期);早于该期的期间不套用")
|
||||
source = Column(String(50), comment="来源标识,如 reverse-calc-20260916")
|
||||
evidence = Column(String(255), comment="取证说明")
|
||||
status = Column(String(20), nullable=False, default="derived",
|
||||
comment="derived=反解待确认 / confirmed=源系统确认")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
@@ -1,14 +1,18 @@
|
||||
"""资产负债表取数层重分类引擎(R1~R12,调表不调账)
|
||||
|
||||
依据:《CMA 资产负债表重分类规则表 v1》
|
||||
(`wecom-finance/reports/CMA_资产负债表重分类规则表_v1.md`,任总 2026-09-16 批准落地)
|
||||
依据:《CMA 资产负债表重分类规则表 v1.1》
|
||||
(`wecom-finance/reports/CMA_资产负债表重分类规则表_v1.1.md`,2026-09-16;v1.0 已被替代)
|
||||
|
||||
本模块只作用于**报表取数层**:`voucher_details` 只读,**不生成记账凭证、不改账簿**
|
||||
(调表不调账;账实不符走差错更正,见规则表 §四)。
|
||||
|
||||
口径要点
|
||||
--------
|
||||
1. **余额方向**:资产类 NSB = Σ(借−贷);负债/权益类 NSB = Σ(贷−借)。NSB = normal-side balance。
|
||||
0. **建账期初层(v1.1 §零 前置铁律)**:时点报表 余额 = 期初余额 + Σ(借−贷)(资产)/
|
||||
期初余额 + Σ(贷−借)(负债·权益),期初取 `report_opening_balances`(只作用于报表取数,
|
||||
不生成凭证、不写 voucher_details、不改 kpi_values)。期初缺失时**往来类方向判定不可信**
|
||||
(`direction_check_unreliable=True`,报表层不得呈现"重分类转正"结论,只进底稿诊断)。
|
||||
1. **余额方向**:资产类 NSB = 期初借 + Σ(借−贷);负债/权益类 NSB = 期初贷 + Σ(贷−借)。
|
||||
2. **触发**:NSB < 0(明细科目反方向余额,如预收账款出现借方余额)→ 转正额 = |NSB|,
|
||||
移入目标报表项目(准则 30 号「不得抵销」)。
|
||||
3. **全部触发判定基于原始余额**(单遍、顺序无关、可重复执行/幂等)——避免 R3/R4 这类互为镜像
|
||||
@@ -32,7 +36,7 @@ from sqlalchemy import func
|
||||
|
||||
from app.utils.voucher_guard import apply_voucher_guard
|
||||
|
||||
RULE_VERSION = "reclass-v1.0-20260916"
|
||||
RULE_VERSION = "reclass-v1.1-20260916"
|
||||
DEFAULT_TOL = 0.01 # 元;小于该值的反方向余额不触发(浮点/分位噪声)
|
||||
|
||||
# 报表板块 → 余额方向
|
||||
@@ -47,6 +51,66 @@ SIDE_OF_SECTION = {
|
||||
# 资产备抵科目(余额在贷方)→ 取数方向反转:1602 累计折旧 / 1603 固定资产减值准备 / 1604 在建工程减值准备
|
||||
CONTRA_CODES: Dict[str, str] = {"1602": "liability", "1603": "liability", "1604": "liability"}
|
||||
|
||||
# 账套建账期初层默认生效期间(早于该期的期间不套用期初;实际以表内 effective_date 为准)
|
||||
DEFAULT_OPENING_EFFECTIVE_DATE = "2021-03"
|
||||
|
||||
|
||||
def load_opening_rows(db, entity_id: int) -> List[dict]:
|
||||
"""读取账套建账期初余额(`report_opening_balances`)。
|
||||
|
||||
期初层**只作用于报表取数**(余额 = 期初 + 累计发生额):不生成凭证、不写 `voucher_details`。
|
||||
表未建/无数据/查询异常 → 返回 [](此时 `direction_check_unreliable=True`,报表层不得呈现转正结论)。
|
||||
"""
|
||||
try:
|
||||
from app.models import ReportOpeningBalance
|
||||
|
||||
rows = db.query(ReportOpeningBalance).filter(
|
||||
ReportOpeningBalance.entity_id == entity_id
|
||||
).all()
|
||||
except Exception: # noqa: BLE001 - 表未建/异常 → 视为期初未导入
|
||||
return []
|
||||
|
||||
out: List[dict] = []
|
||||
for r in rows:
|
||||
out.append({
|
||||
"subject_code": str(r.subject_code or "").strip(),
|
||||
"subject_name": r.subject_name or "",
|
||||
"balance_side": str(r.balance_side or "").strip().lower(),
|
||||
"amount": Decimal(str(r.amount or 0)),
|
||||
"effective_date": str(r.effective_date or "").strip(),
|
||||
"status": str(r.status or "").strip(),
|
||||
"source": str(r.source or "").strip(),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _opening_summary(rows: List[dict]) -> dict:
|
||||
"""期初层摘要(进底稿/接口,供财务核对借贷是否自平)。"""
|
||||
debit = Decimal(0)
|
||||
credit = Decimal(0)
|
||||
dates, statuses, sources = set(), set(), set()
|
||||
for r in rows:
|
||||
if r["balance_side"] == "debit":
|
||||
debit += r["amount"]
|
||||
else:
|
||||
credit += r["amount"]
|
||||
if r["effective_date"]:
|
||||
dates.add(r["effective_date"])
|
||||
if r["status"]:
|
||||
statuses.add(r["status"])
|
||||
if r["source"]:
|
||||
sources.add(r["source"])
|
||||
return {
|
||||
"imported": bool(rows),
|
||||
"rows": len(rows),
|
||||
"debit_total": float(round(debit, 2)),
|
||||
"credit_total": float(round(credit, 2)),
|
||||
"balanced": abs(debit - credit) < Decimal("0.01"),
|
||||
"effective_dates": sorted(dates),
|
||||
"statuses": sorted(statuses),
|
||||
"sources": sorted(sources),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReclassRule:
|
||||
@@ -101,9 +165,11 @@ EXTRA_ITEMS: Dict[str, dict] = {
|
||||
"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": [],
|
||||
"OCA_OTHER": {"name": "其他流动资产", "section_key": "current_assets", "side": "asset",
|
||||
"codes": [("1901", 1)],
|
||||
"template_gap": True, "ns_category": "operating",
|
||||
"note": "小企业准则项目池有「其他流动资产」,CMA 模板未列报(R9 落位项目)"},
|
||||
"note": "小企业准则项目池有「其他流动资产」,CMA 模板未列报(R9 落位项目);"
|
||||
"并含 1901 待处理财产损溢的**过渡列报**(准则要求期末清理为零,待差错更正,M6)"},
|
||||
"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": [],
|
||||
@@ -118,9 +184,15 @@ EXTRA_ITEMS: Dict[str, dict] = {
|
||||
# ── 余额取数(前缀匹配 + 作废守卫 + basis 口径) ─────────────────────────────
|
||||
|
||||
class BalanceSource:
|
||||
"""按 (品类, 期间, basis) 取科目 normal-side balance(NSB),带会话内缓存。"""
|
||||
"""按 (品类, 期间, basis) 取科目 normal-side balance(NSB),带会话内缓存。
|
||||
|
||||
def __init__(self, db, entity_id: int, period: str, basis: str = "end", tol: float = DEFAULT_TOL):
|
||||
余额口径(v1.1 §零)= **期初余额(建账期初层)+ 累计发生额**:
|
||||
· basis=end :资产 NSB = 期初借 + Σ(借−贷)(累计至 period);负债/权益 = 期初贷 + Σ(贷−借)
|
||||
· basis=period :只取本期发生额(**不含期初**,期初是时点余额概念)
|
||||
"""
|
||||
|
||||
def __init__(self, db, entity_id: int, period: str, basis: str = "end", tol: float = DEFAULT_TOL,
|
||||
opening_rows: Optional[List[dict]] = None):
|
||||
self.db = db
|
||||
self.entity_id = entity_id
|
||||
self.period = period
|
||||
@@ -128,6 +200,43 @@ class BalanceSource:
|
||||
self.tol = tol
|
||||
self._cache: Dict[Tuple[str, str], Optional[Decimal]] = {}
|
||||
self._has_data: Optional[bool] = None
|
||||
self._opening_rows: Optional[List[dict]] = opening_rows
|
||||
|
||||
# -- 建账期初层(M1) ---------------------------------------------------
|
||||
def opening_rows(self) -> List[dict]:
|
||||
if self._opening_rows is None:
|
||||
self._opening_rows = load_opening_rows(self.db, self.entity_id)
|
||||
return self._opening_rows
|
||||
|
||||
def has_opening(self) -> bool:
|
||||
"""该账套是否已登记建账期初(未登记 → 方向判定不可信,M2)"""
|
||||
return bool(self.opening_rows())
|
||||
|
||||
def opening_nsb(self, code: str, side: str, period: Optional[str] = None) -> Optional[Decimal]:
|
||||
"""期初层对 NSB 的贡献;无该科目期初行 → None(与凭证层相加时按 0 处理)。
|
||||
|
||||
· 只对 basis=end 生效(时点余额口径);
|
||||
· 科目前缀匹配(期初行可挂在明细科目,如 `100101`);
|
||||
· `effective_date > period` 的行不套用(建账期之后的期间才用得上期初)。
|
||||
"""
|
||||
if self.basis != "end":
|
||||
return None
|
||||
period = period or self.period
|
||||
total: Optional[Decimal] = None
|
||||
for row in self.opening_rows():
|
||||
sc = row["subject_code"]
|
||||
if not sc or not sc.startswith(str(code)):
|
||||
continue
|
||||
eff = row["effective_date"]
|
||||
if eff and period and eff > period:
|
||||
continue
|
||||
amt = row["amount"]
|
||||
same_side = (
|
||||
(row["balance_side"] == "debit" and side == "asset")
|
||||
or (row["balance_side"] == "credit" and side in ("liability", "equity"))
|
||||
)
|
||||
total = (total if total is not None else Decimal(0)) + (amt if same_side else -amt)
|
||||
return total
|
||||
|
||||
# -- 基础查询 ---------------------------------------------------------
|
||||
def _query(self, code: str, period: str):
|
||||
@@ -148,7 +257,7 @@ class BalanceSource:
|
||||
return apply_voucher_guard(self.db, q, VoucherDetail)
|
||||
|
||||
def nsb(self, code: str, side: str, period: Optional[str] = None) -> Optional[Decimal]:
|
||||
"""NSB = 资产 Σ(借−贷) / 负债·权益 Σ(贷−借);无数据返回 None。"""
|
||||
"""NSB = 期初 + 累计(资产 Σ(借−贷) / 负债·权益 Σ(贷−借));无凭证且无期初返回 None。"""
|
||||
period = period or self.period
|
||||
ck = (f"{code}|{side}|{period}|{self.basis}", "")
|
||||
if ck in self._cache:
|
||||
@@ -159,12 +268,16 @@ class BalanceSource:
|
||||
d, c, n = row[0], row[1], row[2]
|
||||
except Exception: # noqa: BLE001 - 无凭证明细表/查询异常 → 视为无数据
|
||||
pass
|
||||
opening = self.opening_nsb(code, side, period)
|
||||
if not n:
|
||||
self._cache[ck] = None
|
||||
return None
|
||||
# 无凭证明细:只有登记了期初才有余额,否则返回 None(回落示例口径)
|
||||
self._cache[ck] = opening
|
||||
return opening
|
||||
debit = Decimal(str(d or 0))
|
||||
credit = Decimal(str(c or 0))
|
||||
val = (debit - credit) if side == "asset" else (credit - debit)
|
||||
if opening is not None:
|
||||
val += opening
|
||||
self._cache[ck] = val
|
||||
return val
|
||||
|
||||
@@ -261,6 +374,10 @@ def analyze(db, entity_id: int, period: str, basis: str = "end",
|
||||
items = build_items(template_sections or [])
|
||||
code_to_key = _covered_code_map(items)
|
||||
|
||||
# ── 建账期初层(M1)+ 方向判定可信度(M2) ──
|
||||
opening_info = _opening_summary(bs.opening_rows())
|
||||
direction_unreliable = not opening_info["imported"]
|
||||
|
||||
raw: Dict[str, Optional[Decimal]] = {}
|
||||
prev: Dict[str, Optional[Decimal]] = {}
|
||||
for key, item in items.items():
|
||||
@@ -295,6 +412,7 @@ def analyze(db, entity_id: int, period: str, basis: str = "end",
|
||||
"target_in_template": bool(tgt.get("template")),
|
||||
"rule_status": r.status,
|
||||
"error_check": r.error_check,
|
||||
"diagnostic_only": direction_unreliable,
|
||||
"note": r.note,
|
||||
})
|
||||
|
||||
@@ -355,6 +473,13 @@ def analyze(db, entity_id: int, period: str, basis: str = "end",
|
||||
"basis": bs.basis,
|
||||
"period": period,
|
||||
"rule_version": RULE_VERSION,
|
||||
# ── 建账期初层 / 方向判定可信度(v1.1 §零,M1/M2)──
|
||||
"direction_check_unreliable": direction_unreliable,
|
||||
"opening_imported": opening_info["imported"],
|
||||
"opening_rows": opening_info["rows"],
|
||||
"opening_debit_total": opening_info["debit_total"],
|
||||
"opening_credit_total": opening_info["credit_total"],
|
||||
"opening_balanced": opening_info["balanced"],
|
||||
"assets_before": before_t["assets"],
|
||||
"assets_after": after_t["assets"],
|
||||
"liabilities_before": before_t["liabilities"],
|
||||
@@ -426,6 +551,15 @@ def analyze(db, entity_id: int, period: str, basis: str = "end",
|
||||
"entity_id": entity_id,
|
||||
"prev_period": _prev_period(period),
|
||||
"has_voucher_data": bs.has_data(),
|
||||
# ── 期初层与可信度(M1/M2)──
|
||||
"direction_check_unreliable": direction_unreliable,
|
||||
"caliber_warning": (
|
||||
"期初未补,方向判定不可信:本账套无建账期初余额(report_opening_balances),"
|
||||
"余额口径缺少期初层 → 负数项目/反方向项判定不得作为对外列报依据;"
|
||||
"重分类触发结论仅供底稿诊断,不进对外行项目(规则表 v1.1 §零-4)。"
|
||||
if direction_unreliable else ""
|
||||
),
|
||||
"opening": opening_info,
|
||||
"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()},
|
||||
@@ -437,6 +571,15 @@ def analyze(db, entity_id: int, period: str, basis: str = "end",
|
||||
"totals_after": after_t,
|
||||
"negatives": {"before": neg_before, "after": neg_after, "after_in_scope": neg_after_in_scope},
|
||||
"template_gaps": template_gaps,
|
||||
# 规则池未列报、但按准则项目池**过渡列报**的科目(M6:1901 待处理财产损溢 → 其他流动资产)
|
||||
"residual_reported": [
|
||||
{"item_key": key, "name": item["name"],
|
||||
"subject_codes": [c for c, s in item["codes"] if s == 1],
|
||||
"value": (float(raw[key]) if raw.get(key) is not None else None),
|
||||
"note": item.get("note", "")}
|
||||
for key, item in items.items()
|
||||
if "1901" in [c for c, s in item["codes"] if s == 1]
|
||||
],
|
||||
"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,
|
||||
}
|
||||
@@ -559,6 +702,7 @@ def entry_rows(analysis: dict) -> List[dict]:
|
||||
"目标科目": "/".join(e["target_codes"]) or "-",
|
||||
"目标项模板已列报": "是" if e["target_in_template"] else "否(虚拟行)",
|
||||
"处理": "调表不调账",
|
||||
"诊断模式": "是(期初未补,方向判定不可信→不落地)" if e.get("diagnostic_only") else "否",
|
||||
"需先行差错核查": "是" if e["error_check"] else "否",
|
||||
"依据/备注": e["note"],
|
||||
})
|
||||
@@ -570,6 +714,17 @@ def diagnostic_rows(analysis: dict) -> List[dict]:
|
||||
inv = analysis["invariants"]
|
||||
rows = [
|
||||
{"项目": "规则版本", "值": inv["rule_version"], "说明": "取数层重分类规则集"},
|
||||
{"项目": "建账期初层(时点报表口径)",
|
||||
"值": (f"已导入 {inv.get('opening_rows', 0)} 行"
|
||||
if inv.get("opening_imported") else "未导入"),
|
||||
"说明": ("余额 = 期初 + 累计发生额;期初只作用于报表取数,不生成凭证、不写 voucher_details。"
|
||||
f"期初借贷合计 {inv.get('opening_debit_total', 0)} / {inv.get('opening_credit_total', 0)}"
|
||||
f"(自平={'是' if inv.get('opening_balanced') else '否'})"
|
||||
if inv.get("opening_imported") else
|
||||
"本账套无建账期初余额 → 余额口径缺期初层(v1.1 §零)")},
|
||||
{"项目": "方向判定可信度",
|
||||
"值": "不可信(期初未补)" if analysis.get("direction_check_unreliable") else "可信",
|
||||
"说明": analysis.get("caliber_warning") or "期初已补,方向判定以「期初 + 累计」余额为准"},
|
||||
{"项目": "取数口径 basis", "值": inv["basis"], "说明": "end=期末余额(累计至期间);period=本期发生额"},
|
||||
{"项目": "触发规则数", "值": inv["entries_count"], "说明": "本轮实际触发的重分类条数"},
|
||||
{"项目": "转正额合计(元)", "值": inv["reclass_total"], "说明": "调表不调账,不生成凭证"},
|
||||
@@ -589,7 +744,8 @@ def diagnostic_rows(analysis: dict) -> List[dict]:
|
||||
{"项目": "总资产不变", "值": "是" if inv["total_assets_unchanged"] else "否(跨方向重分类必然变化)",
|
||||
"说明": inv["total_assets_unchanged_semantics"]},
|
||||
{"项目": "规则范围内负数项目(重分类后)", "值": len(analysis["negatives"]["after_in_scope"]),
|
||||
"说明": "应为 0"},
|
||||
"说明": ("应为 0" if not analysis.get("direction_check_unreliable")
|
||||
else "诊断用(期初未补,方向判定不可信,不得作为对外列报依据)")},
|
||||
{"项目": "负数项目总数(重分类前/后)", "值": f"{inv['negatives_before']} / {inv['negatives_after']}",
|
||||
"说明": "重分类范围外科目见下方清单"},
|
||||
]
|
||||
@@ -612,6 +768,16 @@ def diagnostic_rows(analysis: dict) -> List[dict]:
|
||||
if abs(plu) > 0.01 and abs(plu - inv.get("residual_after", 0)) < 0.01
|
||||
else ("非 0,需差错核查" if abs(plu) > 0.01 else "已结平")),
|
||||
})
|
||||
# 1901 待处理财产损溢 → 过渡列报「其他流动资产」以保恒等式(v1.1 §三 补注,M6)
|
||||
for rl in (analysis.get("residual_reported") or []):
|
||||
if not rl.get("value"):
|
||||
continue
|
||||
rows.append({
|
||||
"项目": f"过渡列报:{'/'.join(rl.get('subject_codes') or [])} 待处理财产损溢",
|
||||
"值": round(float(rl["value"]), 2),
|
||||
"说明": "准则项目池无「待处理财产损溢」正式行 → 过渡期按「其他流动资产」列报以保恒等式;"
|
||||
"准则要求该科目期末清理为零,未清理余额即勾稽差额来源,属财务差错更正事项(M6)",
|
||||
})
|
||||
for u in analysis["unmapped_subjects"]:
|
||||
rows.append({
|
||||
"项目": f"未列报科目:{u['subject_code']}",
|
||||
|
||||
Reference in New Issue
Block a user