feat(budget): activate P1 auto-collect + attribution subject mapping (yanxue-20260914-budget-p13p14-activation)
This commit is contained in:
@@ -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')}"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user