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:
Hermes CI Fix
2026-09-16 01:17:09 +08:00
parent db3c42f766
commit ae6f71dbc0
7 changed files with 833 additions and 29 deletions
@@ -0,0 +1,150 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""账套建账期初余额导入器(M1)—— 幂等 / 默认 dry-run / 显式 --commit 才写库
来源清单:`wecom-finance/reports/CMA_酣客建账期初余额_反解清单_v1.csv`(13 行,借贷各 15,110,544.36)
写入表:`report_opening_balances`(只作用于报表取数;**不生成凭证、不写 voucher_details、不改 kpi_values**)
用法:
cd /root/cma-management/backend
venv/bin/python3 scripts/import_report_opening_balances.py # dry-run(默认,零写入)
venv/bin/python3 scripts/import_report_opening_balances.py --commit # 落库(需放行)
venv/bin/python3 scripts/import_report_opening_balances.py --commit --entity-id 1 --effective-date 2021-03
幂等:按 (entity_id, subject_code, effective_date) 判存 —— 新增/等值跳过/差异更新,重复执行结果一致。
放行前置:本仓 push 会触发部署重启(生产变更),须任总放行;表由 `init_db()`(应用启动 create_all)建出。
"""
from __future__ import annotations
import argparse
import csv
import io
import os
import sys
from decimal import Decimal
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
DEFAULT_CSV = "/root/.hermes/profiles/wecom-finance/reports/CMA_酣客建账期初余额_反解清单_v1.csv"
DEFAULT_SOURCE = "reverse-calc-20260916"
DEFAULT_STATUS = "derived" # 待源系统《科目余额表 2021.03》确认后升级 confirmed
def read_csv(path: str):
rows = []
with io.open(path, encoding="utf-8-sig") as f:
for r in csv.DictReader(f):
rows.append({
"entity_id": int((r.get("entity_id") or "1").strip()),
"subject_code": (r.get("subject_code") or "").strip(),
"subject_name": (r.get("subject_name") or "").strip(),
"balance_side": (r.get("balance_side") or "").strip().lower(),
"amount": Decimal(str((r.get("opening_amount") or "0").strip() or 0)),
"evidence": (r.get("evidence") or "").strip()[:255],
"status_note": (r.get("status") or "").strip(),
})
return rows
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--csv", default=DEFAULT_CSV)
ap.add_argument("--entity-id", type=int, default=None, help="覆盖 CSV 内的 entity_id")
ap.add_argument("--effective-date", default="2021-03", help="建账期 YYYY-MM(期初生效期间)")
ap.add_argument("--source", default=DEFAULT_SOURCE)
ap.add_argument("--status", default=DEFAULT_STATUS, choices=["derived", "confirmed"])
ap.add_argument("--commit", action="store_true", help="真正写库(缺省=dry-run,零写入)")
args = ap.parse_args()
rows = read_csv(args.csv)
if args.entity_id is not None:
for r in rows:
r["entity_id"] = args.entity_id
debit = sum((r["amount"] for r in rows if r["balance_side"] == "debit"), Decimal(0))
credit = sum((r["amount"] for r in rows if r["balance_side"] == "credit"), Decimal(0))
print(f"[清单] {args.csv}")
print(f"[清单] {len(rows)} 行 | 借 {debit:,.2f} / 贷 {credit:,.2f} / 差 {debit - credit:,.2f} "
f"(自平={'是' if abs(debit - credit) < Decimal('0.01') else '否'})")
for r in rows:
print(f" e{r['entity_id']} {r['subject_code']:<6}{r['subject_name']:<10}"
f"{r['balance_side']:<7}{r['amount']:>16,.2f} {r['status_note']}")
if abs(debit - credit) >= Decimal("0.01"):
print("[FAIL] 期初借贷不平衡 —— 拒绝导入")
return 2
from app.database import get_session_local
try:
from app.models import ReportOpeningBalance
except Exception as exc: # noqa: BLE001
print(f"[FAIL] 无法加载模型(表未建?先应用启动 create_all):{exc}")
return 3
db = get_session_local()()
try:
# 预检:表是否存在/现有行数(dry-run 也读,只读不写)
try:
existing = db.query(ReportOpeningBalance).all()
table_ready = True
except Exception as exc: # noqa: BLE001 - 表未建(部署前 create_all 才会建)
existing = []
table_ready = False
print(f"[预检] report_opening_balances 不可读({type(exc).__name__})→ 视为 0 行;"
f"表由部署后应用启动 `init_db()`/create_all 建出")
idx = {(r.entity_id, r.subject_code, r.effective_date): r for r in existing}
print(f"[预检] report_opening_balances 现有 {len(existing)} 行(表就绪={table_ready});"
f"将按 (entity_id, subject_code, effective_date={args.effective_date}) 幂等导入")
ins = upd = skip = 0
plan = []
for r in rows:
key = (r["entity_id"], r["subject_code"], args.effective_date)
cur = idx.get(key)
if cur is None:
ins += 1
plan.append(("INSERT", r))
elif (str(cur.balance_side) == r["balance_side"]
and Decimal(str(cur.amount)) == r["amount"]
and str(cur.status) == args.status):
skip += 1
plan.append(("SKIP", r))
else:
upd += 1
plan.append(("UPDATE", r))
if args.commit:
if cur is None:
db.add(ReportOpeningBalance(
entity_id=r["entity_id"], subject_code=r["subject_code"],
subject_name=r["subject_name"], balance_side=r["balance_side"],
amount=r["amount"], effective_date=args.effective_date,
source=args.source, evidence=r["evidence"] or args.source,
status=args.status,
))
else:
cur.subject_name = r["subject_name"]
cur.balance_side = r["balance_side"]
cur.amount = r["amount"]
cur.source = args.source
cur.evidence = r["evidence"] or args.source
cur.status = args.status
print(f"[计划] 新增 {ins} / 更新 {upd} / 跳过(等值){skip}"
f" | 模式={'COMMIT(写库)' if args.commit else 'DRY-RUN(零写入)'}")
if not args.commit:
print("[dry-run] 未写库;确认无误后加 --commit 执行(须任总放行后)")
return 0
db.commit()
after = db.query(ReportOpeningBalance).filter(
ReportOpeningBalance.entity_id.in_({r["entity_id"] for r in rows})
).all()
d2 = sum((Decimal(str(x.amount)) for x in after if x.balance_side == "debit"), Decimal(0))
c2 = sum((Decimal(str(x.amount)) for x in after if x.balance_side == "credit"), Decimal(0))
print(f"[回查] 落库后 {len(after)} 行 | 借 {d2:,.2f} / 贷 {c2:,.2f} / 差 {d2 - c2:,.2f}")
ok = len(after) == len(rows) and abs(d2 - debit) < Decimal("0.01") and abs(c2 - credit) < Decimal("0.01")
print("[PASS] 落库回查一致" if ok else "[FAIL] 落库回查不一致")
return 0 if ok else 4
finally:
db.close()
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,262 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""建账期初层(M1/M2)现网只读验收 —— 2026-09-16
判据(任务书 `finance-20260916-opening-balance-layer.md` 验收标准):
① 13 科目期末余额 = 期初 + 累计,与源系统《科目余额表》2026.08 / 2026.07 **逐科目 0 差异**(容差 ≤0.01)
② 恒等式:资产 = 负债 + 权益,勾稽差额 0.00(含 1901 按「其他流动资产」列报)
③ M7:权益合计 10,932,589.50(含实收资本 3001 = 2,000,000.00)
④ 期初补齐后 R1~R12 触发数 = 0(R2/R4 伪触发消失)
⑤ M2 两态:期初未补 → direction_check_unreliable=True;期初注入 → 自动 False
**零写入**:全程只读 SELECT;期初行不落库(用 monkeypatch 注入内存期初,不建表、不 INSERT、不 commit)。
用法:cd /root/cma-management/backend && venv/bin/python3 scripts/verify_opening_balance_live_20260916.py [period]
"""
from __future__ import annotations
import csv
import io
import json
import os
import sys
from decimal import Decimal
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import func # noqa: E402
from app.database import get_session_local # noqa: E402
from app.models import VoucherDetail # noqa: E402
from app.services import report_reclass as rr # noqa: E402
from app.utils.voucher_guard import apply_voucher_guard # noqa: E402
PERIOD = sys.argv[1] if len(sys.argv) > 1 else "2026-08"
PREV = "2026-07"
ENTITY = 1
SRC = "/root/WB_WS/_data/zxt-hanke/%s/kmyeb.csv"
OPENING_CSV = "/root/.hermes/profiles/wecom-finance/reports/CMA_酣客建账期初余额_反解清单_v1.csv"
OUT_JSON = "/root/.hermes/profiles/wecom-fullstack/pending-tasks/_reports/finance-20260916-opening-balance-verify.json"
TOL = Decimal("0.01")
# 任务书给出的 13 科目期末余额(借−贷,负=贷方)
EXPECT = {
"1001": Decimal("6331.30"), "1002": Decimal("46729.79"), "1122": Decimal("1449489.54"),
"1123": Decimal("2066724.00"), "1405": Decimal("5462698.72"), "1601": Decimal("9333477.39"),
"1602": Decimal("-516957.04"), "1801": Decimal("0.00"), "2203": Decimal("-836505.86"),
"2221": Decimal("-4295885.13"), "3001": Decimal("-2000000.00"), "3103": Decimal("-14918101.66"),
"3104": Decimal("5985512.16"),
}
SRC_TOTALS = {"assets": Decimal("18203980.49"), "liabilities": Decimal("7271390.99"),
"equity": Decimal("10932589.50")}
FINDINGS = []
def chk(status: str, name: str, detail: str):
FINDINGS.append({"status": status, "judge": name, "detail": detail})
print(f"[{status}] {name} — {detail}")
def load_opening():
rows = []
with io.open(OPENING_CSV, encoding="utf-8-sig") as f:
for r in csv.DictReader(f):
rows.append({
"subject_code": (r["subject_code"] or "").strip(),
"subject_name": (r["subject_name"] or "").strip(),
"balance_side": (r["balance_side"] or "").strip().lower(),
"amount": Decimal(str(r["opening_amount"]).strip()),
"effective_date": "2021-03",
"status": "derived", "source": "reverse-calc-20260916",
})
return rows
OPENING = load_opening()
def src_rows(period: str):
"""源系统《科目余额表》4 位科目行 → {code: (name, 期末借−期末贷)}"""
path = SRC % period.replace("-", ".")
out = {}
if not os.path.exists(path):
return out
with io.open(path, encoding="utf-8-sig") as f:
for r in csv.DictReader(f):
code = (r["科目编码"] or "").strip()
if len(code) != 4:
continue
d = Decimal(str(r["期末借"] or "0"))
c = Decimal(str(r["期末贷"] or "0"))
out[code] = ((r["科目名称"] or "").strip(), d - c)
return out
def src_totals(rows):
t = {"assets": Decimal(0), "liabilities": Decimal(0), "equity": Decimal(0)}
for code, (_n, net) in rows.items():
if code.startswith("1"):
t["assets"] += net
elif code.startswith("2"):
t["liabilities"] += -net
elif code.startswith("3"):
t["equity"] += -net
return t
def cma_cum(db, code: str, period: str) -> Decimal:
"""CMA voucher_details 累计(借−贷,作废守卫生效)"""
q = db.query(func.sum(VoucherDetail.debit_amount - VoucherDetail.credit_amount)).filter(
VoucherDetail.entity_id == ENTITY,
VoucherDetail.subject_code.like(f"{code}%"),
VoucherDetail.period <= period,
)
v = apply_voucher_guard(db, q, VoucherDetail).scalar()
return Decimal(str(v or 0))
def opening_signed(code: str) -> Decimal:
tot = Decimal(0)
for r in OPENING:
if r["subject_code"].startswith(code):
tot += r["amount"] if r["balance_side"] == "debit" else -r["amount"]
return tot
def main():
db = get_session_local()()
evidence = {"period": PERIOD, "prev_period": PREV, "entity_id": ENTITY, "mode": "read-only",
"opening_rows": len(OPENING), "subjects": [], "checks": []}
try:
# ── ① 13 科目:期初 + 累计 vs 源系统期末(2026.08 / 2026.07 双期) ──
for period in (PERIOD, PREV):
sr = src_rows(period)
bad = 0
print(f"\n=== [①] {period} 13 科目 期初 + 累计 vs 源系统《科目余额表》期末(借−贷)===")
print(f"{'科目':<6}{'名称':<12}{'期初':>16}{'CMA累计':>16}{'期初+累计':>16}"
f"{'源系统期末':>16}{'差异':>12} 判定")
for code in list(EXPECT) if period == PERIOD else list(EXPECT):
name, snet = sr.get(code, ("", None))
cum = cma_cum(db, code, period)
op = opening_signed(code)
total = cum + op
diff = (total - snet) if snet is not None else None
ok = diff is not None and abs(diff) <= TOL
if period == PERIOD:
evidence["subjects"].append({
"subject_code": code, "subject_name": name, "period": period,
"opening": float(op), "cma_cumulative": float(cum),
"opening_plus_cumulative": float(total),
"source_end_balance": float(snet) if snet is not None else None,
"diff": float(diff) if diff is not None else None, "pass": bool(ok),
})
if not ok:
bad += 1
print(f"{code:<6}{name:<12}{float(op):>16,.2f}{float(cum):>16,.2f}{float(total):>16,.2f}"
f"{(float(snet) if snet is not None else float('nan')):>16,.2f}"
f"{(float(diff) if diff is not None else float('nan')):>12,.2f} {'OK' if ok else 'FAIL'}")
chk("PASS" if bad == 0 else "FAIL",
f"{period} 13 科目逐科目 0 差异",
f"不一致 {bad} 项(容差 {TOL})")
# ── 任务书硬判据:13 科目期末余额逐科目对表 ──
bad_exp = 0
for code, want in EXPECT.items():
cum = cma_cum(db, code, PERIOD) + opening_signed(code)
if abs(cum - want) > TOL:
bad_exp += 1
print(f" [差异] {code}: {cum} != {want}")
chk("PASS" if bad_exp == 0 else "FAIL", "任务书 13 科目期末余额硬判据",
f"不一致 {bad_exp} 项")
# ── 源系统合计(4 位科目自算,独立于财务给出值) ──
st = src_totals(src_rows(PERIOD))
ok_src = all(abs(st[k] - SRC_TOTALS[k]) <= TOL for k in SRC_TOTALS)
chk("PASS" if ok_src else "FAIL", "源系统合计复算可比",
f"资产 {st['assets']:,.2f} / 负债 {st['liabilities']:,.2f} / 权益 {st['equity']:,.2f}")
evidence["source_totals_selfcalc"] = {k: float(v) for k, v in st.items()}
# ── ②③④ 报表层:期初注入(内存,不落库)后跑引擎 ──
from app.api.reports import BALANCE_SHEET_SECTIONS
def analyze_with(opening_rows):
orig = rr.load_opening_rows
rr.load_opening_rows = lambda db_, entity_id: list(opening_rows)
try:
return rr.analyze(db, ENTITY, PERIOD, basis="end",
template_sections=BALANCE_SHEET_SECTIONS)
finally:
rr.load_opening_rows = orig
p_no = analyze_with([]) # 态①:期初未补(现网实况)
p_yes = analyze_with(OPENING) # 态②:期初补齐(F1 反解值)
evidence["state_no_opening"] = {
"direction_check_unreliable": p_no["direction_check_unreliable"],
"entries_count": p_no["invariants"]["entries_count"],
"entries": [(e["rule"], e["reclass_amount"], e["source_code"]) for e in p_no["entries"]],
"totals_after": p_no["totals_after"],
}
evidence["state_with_opening"] = {
"direction_check_unreliable": p_yes["direction_check_unreliable"],
"opening": p_yes["opening"],
"entries_count": p_yes["invariants"]["entries_count"],
"entries": [(e["rule"], e["reclass_amount"], e["source_code"]) for e in p_yes["entries"]],
"totals_after": p_yes["totals_after"],
"invariants": {k: p_yes["invariants"][k] for k in (
"assets_after", "liabilities_after", "equity_after", "residual_after",
"identity_holds_after", "opening_imported", "opening_balanced")},
}
print(f"\n=== [②③④] 报表层(basis=end, {PERIOD})===")
print(f" 期初未补:可信度不可信={p_no['direction_check_unreliable']} 触发={p_no['invariants']['entries_count']} "
f"资产={p_no['totals_after']['assets']:,.2f} 负债={p_no['totals_after']['liabilities']:,.2f} "
f"权益={p_no['totals_after']['equity']:,.2f} 差额={p_no['totals_after']['residual']:,.2f}")
ta = p_yes["totals_after"]
print(f" 期初补齐:可信度不可信={p_yes['direction_check_unreliable']} 触发={p_yes['invariants']['entries_count']} "
f"资产={ta['assets']:,.2f} 负债={ta['liabilities']:,.2f} 权益={ta['equity']:,.2f} "
f"差额={ta['residual']:,.2f}")
print(f" 期初层:{p_yes['opening']}")
ok_tot = (abs(Decimal(str(ta["assets"])) - SRC_TOTALS["assets"]) <= TOL
and abs(Decimal(str(ta["liabilities"])) - SRC_TOTALS["liabilities"]) <= TOL
and abs(Decimal(str(ta["equity"])) - SRC_TOTALS["equity"]) <= TOL)
chk("PASS" if ok_tot else "FAIL", "② 三合计与源系统一致",
f"资产 {ta['assets']:,.2f}(源 18,203,980.49) / 负债 {ta['liabilities']:,.2f}(源 7,271,390.99) "
f"/ 权益 {ta['equity']:,.2f}(源 10,932,589.50)")
chk("PASS" if abs(Decimal(str(ta["residual"]))) <= TOL else "FAIL", "② 勾稽差额 0.00",
f"residual_after={ta['residual']}")
chk("PASS" if abs(Decimal(str(ta["equity"])) - SRC_TOTALS["equity"]) <= TOL else "FAIL",
"③ M7 权益合计(含 3001 实收资本 2,000,000)",
f"权益合计 {ta['equity']:,.2f}(实收资本行 raw={p_yes['raw'].get('4001')})")
chk("PASS" if p_yes["invariants"]["entries_count"] == 0 else "FAIL", "④ R1~R12 触发数 = 0(R2/R4 伪触发消失)",
f"期初补齐后触发 {p_yes['invariants']['entries_count']} 条 / 期初未补时 {p_no['invariants']['entries_count']} 条(诊断)")
chk("PASS" if (p_no["direction_check_unreliable"] and not p_yes["direction_check_unreliable"]) else "FAIL",
"⑤ M2 两态(期初未补=不可信 → 补齐=可信)",
f"态①{ p_no['direction_check_unreliable'] } → 态②{ p_yes['direction_check_unreliable'] }")
# ── 零持久化自证 ──
try:
from app.models import ReportOpeningBalance
n_rows = db.query(ReportOpeningBalance).count()
has_table = True
except Exception as exc: # noqa: BLE001
n_rows, has_table = None, False
print(f" [INFO] report_opening_balances 未建({type(exc).__name__})")
chk("PASS", "零持久化(期初未写入现网库)",
f"本次仅内存注入;表存在={has_table} 行数={n_rows}(未落库)")
evidence["checks"] = FINDINGS
evidence["pass"] = all(f["status"] == "PASS" for f in FINDINGS)
os.makedirs(os.path.dirname(OUT_JSON), exist_ok=True)
with io.open(OUT_JSON, "w", encoding="utf-8") as f:
json.dump(evidence, f, ensure_ascii=False, indent=2)
print(f"\n证据 JSON: {OUT_JSON} ({os.path.getsize(OUT_JSON)} B)")
n_fail = sum(1 for f in FINDINGS if f["status"] == "FAIL")
print(f"总判定:PASS {len(FINDINGS) - n_fail} / FAIL {n_fail}")
return 1 if n_fail else 0
finally:
db.close()
if __name__ == "__main__":
sys.exit(main())