"""KPI实际值自动归集采集器 — 管理会计OS (P1-④ 2026-08-28) 按 kpi_value_sources 取数映射,从源头表(科目余额/进销存/现金流水)自动汇总写入 kpi_values。 - 源头: voucher_details(网银凭证明细) / product_inventory(库存汇总) / product_inventory_detail(库存明细) / cash_plans(收付款计划) - 严格按 entity_id + period 过滤,避免跨账套/跨期串数 - 幂等: 同 kpi_id+period 已有 auto_collect 记录则更新;人工 excel/manual 写入不覆盖 - 调度: 系统 crontab 每日 06:30 (参考 auto_verify_cron.py 模式) 用法: /usr/bin/python3 scripts/kpi_value_collector.py # 全量采集当月 /usr/bin/python3 scripts/kpi_value_collector.py 2026-08 # 指定期间 /usr/bin/python3 scripts/kpi_value_collector.py 2026-08 5 # 指定期间+KPI """ import sys import logging from datetime import datetime from typing import Optional, Tuple logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", ) logger = logging.getLogger("cma.kpi_collector") # 保证从 backend 目录直接运行时能 import app if __name__ == "__main__": import os sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from sqlalchemy import func from app.database import get_session_local from app.models import ( KPIValueSource, KPIValueCollectLog, KPIValue, KPIDefinition, VoucherDetail, CashPlan, ) # 源头表 → ORM模型映射(动态 import 避免循环依赖) def _source_model(table: str): if table == "voucher_details": return VoucherDetail if table == "cash_plans": return CashPlan # product_inventory / product_inventory_detail 无ORM模型 → SQLAlchemy Table 反射 from sqlalchemy import Table, MetaData from app.database import get_engine md = MetaData() return Table(table, md, autoload_with=get_engine()) def _field_expression(model, field: str, aggregate: str = "sum"): """聚合表达式: sum/avg/count/max/min""" col = getattr(model, field) if aggregate == "count": return func.count(col) if aggregate == "avg": return func.avg(col) if aggregate == "max": return func.max(col) if aggregate == "min": return func.min(col) return func.sum(col) def collect_for_mapping(db, mapping: KPIValueSource, period: str, write_kpi: bool = True) -> Tuple[Optional[float], str]: """执行单条取数映射,返回 (采集值, 说明)。write_kpi=False 时为试跑模式(不写库)。""" table = mapping.source_table field = mapping.source_field aggregate = mapping.aggregate or "sum" filter_rule = mapping.filter_rule or {} 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) # 构建查询 col = getattr(model, field, None) if col is None: return None, f"字段 {field} 不存在于表 {table}" q = db.query(_field_expression(model, field, aggregate)) # entity_id 过滤(所有源头表都有) q = q.filter(model.entity_id == mapping.entity_id) # 期间过滤 if period_field == "voucher_date": # voucher_date 是 DATE 类型 → 按 %Y-%m 前缀匹配 q = q.filter(func.date_format(model.voucher_date, "%Y-%m") == period) else: pf = getattr(model, period_field, None) if pf is None: return None, f"期间字段 {period_field} 不存在于表 {table}" q = q.filter(pf == period) # 过滤规则: subject_code / direction / plan_type / carry_forward subject_code = filter_rule.get("subject_code") if subject_code and hasattr(model, "subject_code"): q = q.filter(model.subject_code == subject_code) direction = filter_rule.get("direction") if direction: # direction 覆盖: credit→只算贷方, debit→只算借方 if direction == "credit" and hasattr(model, "credit_amount"): q = db.query(_field_expression(model, "credit_amount", aggregate)) q = q.filter(model.entity_id == mapping.entity_id) if period_field == "voucher_date": q = q.filter(func.date_format(model.voucher_date, "%Y-%m") == period) else: q = q.filter(getattr(model, period_field) == period) field = "credit_amount" elif direction == "debit" and hasattr(model, "debit_amount"): q = db.query(_field_expression(model, "debit_amount", aggregate)) q = q.filter(model.entity_id == mapping.entity_id) if period_field == "voucher_date": q = q.filter(func.date_format(model.voucher_date, "%Y-%m") == period) else: q = q.filter(getattr(model, period_field) == period) field = "debit_amount" plan_type = filter_rule.get("plan_type") if plan_type and hasattr(model, "plan_type"): q = q.filter(model.plan_type == plan_type) if filter_rule.get("exclude_carry_forward") and hasattr(model, "carry_forward"): q = q.filter(model.carry_forward == 0) value = q.scalar() if value is None: # 源表无记录 → 不写 0,交由 run_collector 记 skipped,避免污染 KPI 实际值 return None, "源表无记录" value = float(value) value = round(value * unit, 2) message = f"表{table}.{field} {aggregate}(period={period}) × {unit}" if subject_code: message += f", 科目{subject_code}" if direction: message += f", 方向{direction}" if plan_type: message += f", 类型{plan_type}" if write_kpi: _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')}" def run_collector(db, entity_id: Optional[int] = None, period: Optional[str] = None, kpi_id: Optional[int] = None) -> dict: """运行采集器:遍历 active 映射 → 汇总 → upsert kpi_values → 写采集日志""" if period is None: period = datetime.now().strftime("%Y-%m") query = db.query(KPIValueSource).filter(KPIValueSource.status == "active") if entity_id is not None: query = query.filter(KPIValueSource.entity_id == entity_id) if kpi_id is not None: query = query.filter(KPIValueSource.kpi_id == kpi_id) mappings = query.all() if not mappings: return {"success": True, "collected": 0, "failed": 0, "message": "无激活取数映射"} collected, failed = 0, 0 errors = [] for m in mappings: try: value, message = collect_for_mapping(db, m, period, write_kpi=True) if value is None: # 源表无记录 → 跳过,不落 pending=0 的 KPIValue db.add(KPIValueCollectLog( entity_id=m.entity_id, kpi_id=m.kpi_id, period=period, source_table=m.source_table, collected_value=None, status="skipped", message=message, )) continue db.add(KPIValueCollectLog( entity_id=m.entity_id, kpi_id=m.kpi_id, period=period, source_table=m.source_table, collected_value=value, status="success", message=message, )) collected += 1 except Exception as e: failed += 1 errors.append({"kpi_id": m.kpi_id, "source_table": m.source_table, "error": str(e)}) db.add(KPIValueCollectLog( entity_id=m.entity_id, kpi_id=m.kpi_id, period=period, source_table=m.source_table, collected_value=None, status="failed", message=str(e)[:500], )) logger.error("采集失败 kpi=%s table=%s: %s", m.kpi_id, m.source_table, e) db.commit() logger.info("采集完成: 成功%s 失败%s (period=%s)", collected, failed, period) return { "success": failed == 0, "collected": collected, "failed": failed, "period": period, "errors": errors[:20], } if __name__ == "__main__": period_arg = sys.argv[1] if len(sys.argv) > 1 else None kpi_arg = int(sys.argv[2]) if len(sys.argv) > 2 and sys.argv[2].isdigit() else None db = get_session_local()() try: r = run_collector(db, period=period_arg, kpi_id=kpi_arg) print(f"实际值自动归集完成: 成功{r['collected']} 失败{r['failed']} (period={r.get('period')})") for e in r.get("errors", []): print(f" 失败: kpi={e['kpi_id']} table={e['source_table']} -> {e['error']}") finally: db.close()