Files
cma-management/backend/scripts/import_report_opening_balances.py
T
Hermes CI Fix ae6f71dbc0 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 门控)
2026-09-16 01:17:09 +08:00

151 lines
7.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())