"""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 os import sys import logging from datetime import datetime from typing import Optional, Tuple # 复核闸门开关(R2/R3,财务裁定 2026-09-14;KPI_REVIEW_GATE=off 可关闭,仅供测试) _REVIEW_GATE_ENABLED = os.environ.get("KPI_REVIEW_GATE", "on").strip().lower() not in ("0", "off", "false", "no") 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 MetaData, Table, and_, exists, func, not_, or_ from sqlalchemy.orm import aliased from app.database import get_session_local from app.models import ( KPIValueSource, KPIValueCollectLog, KPIValue, KPIDefinition, VoucherDetail, CashPlan, ) # 作废/撤单标记守卫(全库唯一实现,见 app/utils/voucher_guard.py) from app.utils.voucher_guard import ( # noqa: F401 (别名供本模块历史调用点使用) VOID_MARK_TABLE, apply_voucher_guard as _apply_voucher_guard, exclude_void as _exclude_void, reset_cache as _reset_void_mark_cache, void_mark_table as _void_mark_table, void_mark_table_exists as _void_mark_table_exists, ) # 源头表 → 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) # ── 作废/撤单标记过滤(2026-09-14 接入 · 同日晚收口至单一实现)────────────────── # 口径**不在本模块**:统一由 app/utils/voucher_guard.py 提供(全库唯一实现), # 本模块只做转发,避免「采集器已过滤、报表/归因仍含虚增」的口径漂移。 # 判定键 = (entity_id, voucher_no, voucher_date) 且 revoked_at IS NULL —— 与 DB 视图 # `cma.v_voucher_details_active` 的定义逐字一致。 # 纪律: 禁硬编码凭证号;标记表是唯一数据源 —— 今后任何撤单只需写标记表,代码零改动。 # 兼容: 标记表不存在(测试库/旧库)→ 跳过过滤,不阻断采集(告警一次)。 _VOID_MARK_TABLE = VOID_MARK_TABLE # 兼容别名(历史引用) _reset_cache = _reset_void_mark_cache # 兼容别名(单测/外部复位) 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 # 现金流两族KPI 激活(2026-09-14): 现金流量表直接法·对手方分类口径 # filter_rule 形如 {"calc":"cashflow_direct","bucket":"operating","cash_subjects":["1001%","1002%","1012%"], # "financing_counterpart":[...],"investing_counterpart":[...],"round":4, # "minus_capex":{"counterpart":[...],"side":"debit"}} if isinstance(filter_rule, dict) and filter_rule.get("calc") == "cashflow_direct": return collect_cashflow_direct(db, mapping, period, write_kpi=write_kpi) # 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) # 作废/撤单标记过滤(消费 voucher_details 的统一守卫) q = _apply_voucher_guard(db, q, model) # 过滤规则: 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) q = _apply_voucher_guard(db, q, model) 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) q = _apply_voucher_guard(db, q, model) 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)行不匹配本查询 → 自动归集永不覆盖人工录入值。 复核闸门(财务裁定 2026-09-14,规则 R2/R3):落 pending 后**同事务**判定是否转正 / 归并 / 进例外;判定异常一律 fail-safe 保持 pending(N 条转正条件未证成就不转正)。 """ 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" kv = existing else: kv = 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, ) db.add(kv) db.flush() # 取 id(幂等去重与 remark 留痕需要) _review_gate(db, mapping, period, kv) def _review_gate(db, mapping, period: str, kv) -> None: """R2/R3 复核闸门:同事务判定并落状态。任何异常都不阻断采集(保持 pending)。""" if not _REVIEW_GATE_ENABLED: return try: try: from scripts.kpi_review_gate import review_auto_value except ImportError: from kpi_review_gate import review_auto_value except ImportError as e: logger.warning("复核闸门模块不可用(保持 pending): %s", e) return try: review_auto_value(db, mapping, kv, period) except Exception as e: logger.warning("复核闸门判定异常 → 保持 pending (kpi=%s period=%s): %s", mapping.kpi_id, period, e) # ── 科目组合归集(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) q = _exclude_void(db, q, VoucherDetail) # 作废/撤单标记过滤 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 # ── 现金流量表直接法·对手方分类归集(2026-09-14 激活)────────────────── # 任务: yanxue-budget-cashflow-kpi-activate-20260914(财务Bot 口径说明 §二/§三/§五) # 口径: 以**凭证为单元**取含现金类科目(1001%/1002%/1012%)的凭证 → 对手方**行级**分类 # (筹资类/投资类/其余=经营);经营净额 = Σ(经营类对手方行净额 × -1) # 恒等式: 经营净额 + 筹资净额 + 投资净额 = 现金总净额 # 关键: 必须行级拆分,不能按凭证级分类 —— 反例 2026-06 凭证 31226 = 还借款 2,550,000 # + 手续费用 18,700 同单,整单归筹资会把 18,700 手续费误算作经营(高估 18,700)。 # 纪律: 不硬编码凭证号/常量;对手方集合、bucket、舍位全部来自 filter_rule。 _DEFAULT_CASH_SUBJECTS = ("1001%", "1002%", "1012%") _CASHFLOW_BUCKETS = ("operating", "financing", "investing", "net_cash") def _cashflow_voucher_subquery(db, entity_id: int, period: Optional[str], cash_subjects): """含现金类科目的凭证键集合(entity_id + voucher_date + voucher_no + period)。""" q = db.query( VoucherDetail.entity_id.label("entity_id"), VoucherDetail.voucher_date.label("voucher_date"), VoucherDetail.voucher_no.label("voucher_no"), VoucherDetail.period.label("period"), ).filter(VoucherDetail.entity_id == entity_id) if period: q = q.filter(VoucherDetail.period == period) q = q.filter(or_(*[VoucherDetail.subject_code.like(p) for p in cash_subjects])) q = _exclude_void(db, q, VoucherDetail) # 作废/撤单标记过滤(凭证键集合) voucher_cnt = q.distinct().count() return q.distinct().subquery(), voucher_cnt def _cashflow_side_amount(db, entity_id: int, period: Optional[str], cash_subjects, include_prefixes=None, exclude_prefixes=None, side: str = "cashflow"): """现金凭证内「对手方行级」金额 → (金额元, 行数, 凭证数)。 side="cashflow" : Σ(贷方 − 借方),即现金流口径(流入为正) side="debit_net" : Σ(借方 − 贷方),即资本支出/借方净额口径(付现为正) include_prefixes / exclude_prefixes: 4 位科目前缀,命中即纳入 / 排除(均为 LIKE 前缀,非硬编码)。 """ sub, voucher_cnt = _cashflow_voucher_subquery(db, entity_id, period, cash_subjects) c = aliased(VoucherDetail) if side == "debit_net": expr = func.coalesce(func.sum(c.debit_amount - c.credit_amount), 0) else: expr = func.coalesce(func.sum(c.credit_amount - c.debit_amount), 0) q = db.query(expr, func.count(c.id)).select_from(c).join( sub, and_(sub.c.entity_id == c.entity_id, sub.c.voucher_date == c.voucher_date, sub.c.voucher_no == c.voucher_no, sub.c.period == c.period), ).filter(c.entity_id == entity_id) if period: q = q.filter(c.period == period) # 只保留对手方行(剔除现金类科目自身) q = q.filter(not_(or_(*[c.subject_code.like(p) for p in cash_subjects]))) if include_prefixes: q = q.filter(or_(*[c.subject_code.like(str(p) + "%") for p in include_prefixes])) if exclude_prefixes: q = q.filter(not_(or_(*[c.subject_code.like(str(p) + "%") for p in exclude_prefixes]))) row = q.first() amount = float(row[0] or 0) if row else 0.0 rows = int(row[1] or 0) if row else 0 return amount, rows, voucher_cnt def collect_cashflow_direct(db, mapping, period: str, write_kpi: bool = True): """现金流量表直接法归集(filter_rule.calc = cashflow_direct),返回 (采集值, 说明)。 bucket: operating(经营/默认) | financing(筹资) | investing(投资) | net_cash(现金总净额) minus_capex: 付现资本支出扣减项 {"counterpart":[4位前缀...],"side":"debit"} """ rule = mapping.filter_rule or {} cash_subjects = [str(p) for p in (rule.get("cash_subjects") or _DEFAULT_CASH_SUBJECTS)] financing = [str(p) for p in (rule.get("financing_counterpart") or [])] investing = [str(p) for p in (rule.get("investing_counterpart") or [])] bucket = str(rule.get("bucket") or "operating") dp = int(rule.get("round", 4) or 4) unit = mapping.unit_conversion or 1 if bucket not in _CASHFLOW_BUCKETS: return None, f"未知 bucket={bucket}(可选 {'/'.join(_CASHFLOW_BUCKETS)})" if bucket == "net_cash": amount, rows, vouchers = _cashflow_side_amount(db, mapping.entity_id, period, cash_subjects) elif bucket == "financing": amount, rows, vouchers = _cashflow_side_amount(db, mapping.entity_id, period, cash_subjects, include_prefixes=financing) elif bucket == "investing": amount, rows, vouchers = _cashflow_side_amount(db, mapping.entity_id, period, cash_subjects, include_prefixes=investing) else: amount, rows, vouchers = _cashflow_side_amount(db, mapping.entity_id, period, cash_subjects, exclude_prefixes=financing + investing) if vouchers == 0 or rows == 0: return None, f"源表无记录(现金流量直接法 period={period}, bucket={bucket})" value_yuan = amount parts = [f"{bucket} {amount:.2f}元"] capex_rule = rule.get("minus_capex") or {} if capex_rule: capex_prefixes = [str(p) for p in (capex_rule.get("counterpart") or [])] capex_side = "debit_net" if str(capex_rule.get("side") or "debit").startswith("debit") else "cashflow" capex, capex_rows, _ = _cashflow_side_amount(db, mapping.entity_id, period, cash_subjects, include_prefixes=capex_prefixes, side=capex_side) value_yuan = amount - capex parts.append(f"资本支出({capex_side}) {capex:.2f}元/{capex_rows}行") value = round(float(value_yuan) * unit, dp) message = (f"现金流量直接法(period={period}, bucket={bucket}, 现金凭证{vouchers}张/对手方{rows}行): " + " − ".join(parts) + f" × {unit}") if write_kpi: _upsert_auto_value(db, mapping, period, value, f"自动归集: 现金流量直接法({bucket})") 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()