feat(budget): activate P1 auto-collect + attribution subject mapping (yanxue-20260914-budget-p13p14-activation)

This commit is contained in:
hermes
2026-09-14 17:19:49 +08:00
parent b815b7f623
commit a6d5d151c4
4 changed files with 642 additions and 45 deletions
+61 -23
View File
@@ -78,39 +78,77 @@ def build_dimension_attribution(db: Session, kpi_id: int, period: str) -> list:
return result
def _subject_patterns(db: Session, kpi_id: int) -> list:
"""科目取数口径解析(2026-09-14 P1③④ 激活):
优先用已推导的取数映射 kpi_value_sources.filter_rule.subject_codes —— 那是把
kpi_subject_map(新准则 6xxx) 机械桥接到 voucher_details 实际科目(5xxx/1xxx/2xxx) 的结果;
无映射时退回 kpi_subject_map 原始科目(新老科目体系不一致时明细多为 0)。
"""
from app.models import KPIValueSource, KPISubjectMap
src = db.query(KPIValueSource).filter(
KPIValueSource.kpi_id == kpi_id,
KPIValueSource.source_table == "voucher_details",
KPIValueSource.status.in_(("active", "draft")),
).order_by(KPIValueSource.status.asc()).first()
rule = (src.filter_rule or {}) if src else {}
labels = {m.subject_code: (m.remark or m.subject_code)
for m in db.query(KPISubjectMap).filter(KPISubjectMap.kpi_id == kpi_id).all()}
if rule.get("subject_codes"):
return [{
"code": code,
"label": labels.get(str(code).rstrip("%"), str(code)),
"weight": float((rule.get("weights") or {}).get(code, 1.0)),
"measure": rule.get("measure", "credit"),
"source": "kpi_value_sources",
} for code in rule["subject_codes"]]
return [{
"code": m.subject_code,
"label": m.remark or m.subject_code,
"weight": float(m.weight if m.weight is not None else 1.0),
"measure": "credit",
"source": "kpi_subject_map",
} for m in db.query(KPISubjectMap).filter(KPISubjectMap.kpi_id == kpi_id).all()]
def build_subject_attribution(db: Session, kpi_id: int, period: str) -> list:
"""科目明细拆解 — 查 kpi_subject_map → voucher_details 汇总科目发生额"""
mappings = db.query(KPISubjectMap).filter(KPISubjectMap.kpi_id == kpi_id).all()
if not mappings:
"""科目明细拆解 — 科目口径取 _subject_patterns(优先修正后的取数映射),按 entity 隔离"""
patterns = _subject_patterns(db, kpi_id)
if not patterns:
return []
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
entity_id = kpi.entity_id if kpi else None
result = []
for m in mappings:
for p in patterns:
q = db.query(
func.coalesce(func.sum(VoucherDetail.debit_amount), 0),
func.coalesce(func.sum(VoucherDetail.credit_amount), 0),
).filter(
VoucherDetail.subject_code == m.subject_code,
VoucherDetail.period == period,
)
func.coalesce(func.sum(VoucherDetail.debit_amount), 0),
func.count(VoucherDetail.id),
).filter(VoucherDetail.period == period)
if "%" in p["code"]:
q = q.filter(VoucherDetail.subject_code.like(p["code"]))
else:
q = q.filter(VoucherDetail.subject_code == p["code"])
if entity_id is not None:
q = q.filter(VoucherDetail.entity_id == entity_id)
row = q.first()
debit_sum = float(row[0] or 0)
credit_sum = float(row[1] or 0)
# 方向: credit贷方(收入/流入) / debit借方(费用/流出)
if m.calc_type == "ratio":
amount = credit_sum - debit_sum
elif m.calc_type in ("avg", "other"):
amount = (credit_sum - debit_sum) / 2
else: # sum
amount = credit_sum - debit_sum
amount = round(amount * float(m.weight or 1.0), 2)
credit_sum = float(row[0] or 0)
debit_sum = float(row[1] or 0)
rows = int(row[2] or 0)
# 口径: 损益类取贷方发生额(月结结转腿=本期发生额) / 余额类取净额(借-贷)
amount = (debit_sum - credit_sum) if p["measure"] == "net" else credit_sum
amount = round(amount * p["weight"], 2)
result.append({
"subject_code": m.subject_code,
"subject_name": m.remark or m.subject_code,
"subject_code": p["code"],
"subject_name": p["label"],
"amount_diff": amount,
"calc_type": m.calc_type,
"weight": float(m.weight or 1.0),
"calc_type": p["measure"],
"weight": p["weight"],
"rows": rows,
})
total = sum(abs(r["amount_diff"]) for r in result) or 0