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
+116 -22
View File
@@ -71,6 +71,12 @@ def collect_for_mapping(db, mapping: KPIValueSource, period: str, write_kpi: boo
period_field = mapping.period_field or "period"
unit = mapping.unit_conversion or 1
# P1③④ 激活(2026-09-14): 科目组合映射 —— 由 kpi_subject_map 推导的多科目加权归集
# filter_rule 形如 {"subject_codes":["5001%"],"measure":"credit","weights":{"5001%":1.0},
# "calc":"sum|ratio","denominator":"5001%","scale":100}
if isinstance(filter_rule, dict) and filter_rule.get("subject_codes"):
return collect_subject_group(db, mapping, period, write_kpi=write_kpi)
model = _source_model(table)
# 构建查询
@@ -138,32 +144,120 @@ def collect_for_mapping(db, mapping: KPIValueSource, period: str, write_kpi: boo
message += f", 类型{plan_type}"
if write_kpi:
# upsert kpi_values: 同 kpi_id+period 已有 auto_collect 记录则更新
existing = db.query(KPIValue).filter(
KPIValue.kpi_id == mapping.kpi_id,
KPIValue.period == period,
KPIValue.source_type == "auto_collect",
).first()
if existing:
existing.actual_value = value
existing.source_batch = _batch_no()
existing.remark = f"自动归集: {table}"
existing.data_status = "pending"
else:
db.add(KPIValue(
entity_id=mapping.entity_id,
kpi_id=mapping.kpi_id,
period=period,
actual_value=value,
source_type="auto_collect",
source_batch=_batch_no(),
data_status="pending",
remark=f"自动归集: {table}",
))
_upsert_auto_value(db, mapping, period, value, f"自动归集: {table}")
return value, message
def _upsert_auto_value(db, mapping, period: str, value: float, remark: str) -> None:
"""upsert kpi_values: 同 kpi_id+period 已有 auto_collect 记录则更新。
人工来源(excel/manual)行不匹配本查询 → 自动归集永不覆盖人工录入值。
"""
existing = db.query(KPIValue).filter(
KPIValue.kpi_id == mapping.kpi_id,
KPIValue.period == period,
KPIValue.source_type == "auto_collect",
).first()
if existing:
existing.actual_value = value
existing.source_batch = _batch_no()
existing.remark = remark
existing.data_status = "pending"
else:
db.add(KPIValue(
entity_id=mapping.entity_id,
kpi_id=mapping.kpi_id,
period=period,
actual_value=value,
source_type="auto_collect",
source_batch=_batch_no(),
data_status="pending",
remark=remark,
))
# ── 科目组合归集(P1③④ 激活 · 2026-09-14)─────────────────────────
# 背景: kpi_subject_map 用新准则科目(6xxx),voucher_details 是智享通凭证明细(5xxx/1xxx/2xxx),
# 编码体系不同 → 由 scripts/kpi_value_source_derive.py 先做「同代码/同名/子目前缀/结转科目」
# 机械桥接,把**实际存在的科目编码或前缀**写进 filter_rule.subject_codes,再由本函数归集。
_CREDIT_NORMAL_PREFIXES = ("2", "4", "60", "61", "63")
def _is_credit_normal(subject_code: str) -> bool:
return str(subject_code).startswith(_CREDIT_NORMAL_PREFIXES)
def _subject_amount(db, entity_id: int, pattern: str, period: str, measure: str):
"""单科目(支持 LIKE 前缀)期间金额 → (金额, 行数)。measure: credit|debit|net|debit_net"""
q = db.query(
func.coalesce(func.sum(VoucherDetail.credit_amount), 0),
func.coalesce(func.sum(VoucherDetail.debit_amount), 0),
func.count(VoucherDetail.id),
).filter(VoucherDetail.entity_id == entity_id)
if "%" in pattern:
q = q.filter(VoucherDetail.subject_code.like(pattern))
else:
q = q.filter(VoucherDetail.subject_code == pattern)
if period:
q = q.filter(VoucherDetail.period == period)
credit, debit, rows = q.first()
credit, debit = float(credit or 0), float(debit or 0)
if measure == "debit":
amount = debit
elif measure == "net":
amount = credit - debit
elif measure == "debit_net":
amount = debit - credit
else: # credit(默认): 贷方发生额——损益类科目月结"结转"腿与本期发生额同额,取贷方即本期发生额
amount = credit
return amount, int(rows or 0)
def collect_subject_group(db, mapping, period: str, write_kpi: bool = True):
"""多科目加权归集(filter_rule.subject_codes 路径),返回 (采集值, 说明)。"""
rule = mapping.filter_rule or {}
codes = list(rule.get("subject_codes") or [])
weights = rule.get("weights") or {}
measure = rule.get("measure", "credit")
calc = rule.get("calc", "sum")
scale = float(rule.get("scale", 1) or 1)
unit = mapping.unit_conversion or 1
total, rows, parts, matched = 0.0, 0, [], 0
for code in codes:
amount, n = _subject_amount(db, mapping.entity_id, code, period, measure)
if n:
matched += 1
rows += n
weight = float(weights.get(code, 1) or 0)
total += weight * amount
parts.append(f"{code}×{weight:g}")
if rows == 0:
return None, f"源表无记录(period={period}, {len(codes)}个科目均无发生额)"
if calc == "ratio":
denom_code = rule.get("denominator") or (codes[0] if codes else None)
if not denom_code:
return None, "比率映射缺少 denominator"
denom, _ = _subject_amount(db, mapping.entity_id, str(denom_code), period, measure)
if not denom:
return None, f"比率分母 {denom_code} 期间无发生额"
value = round(float(total) / float(denom) * scale * unit, 4)
else:
value = round(float(total) * unit, 2)
message = (f"科目组合(period={period}, {measure}, 命中{matched}/{len(codes)}): "
+ " + ".join(parts))
if calc == "ratio":
message += f" ÷ {denom_code} × {scale:g}"
if write_kpi:
_upsert_auto_value(db, mapping, period, value,
f"自动归集: {mapping.source_table} 科目组合")
return value, message
def _batch_no() -> str:
return f"auto-{datetime.now().strftime('%Y%m%d%H%M%S')}"