"""外部输入台账 → kpi_values 派生模块(能力评价 M2 准入3 派生,2026-09-15) 三张外部输入台账(app/models/ext_input_ledger.py)→ 三个能力评价指标值: P_TAX_TIMELY = 当期 on_time=1 的税种数 ÷ 当期应申报税种数 × 100 (月频) L_EMPLOYEE_TURNOVER = separations ÷ headcount_begin × 100 (月频) P_LICENCE_OK = 有效证照数 ÷ 应持证照数 × 100 (季频,季末为基准日) 硬规则(任务 0-P0-project-20260915-cma-eval-m2-ext-input-tables,直接进验收): 1. **分母为 0 必须返回 None(无定义 / 不参评),不得写 0、不得填大数。** 依据:F_INTEREST_COVER 已因「利息费用=0 → 分母 0」失去定义;同族错误不得在 3 张新台账上重演。 → 值为 None 时**不写 kpi_values**;若该期已存在旧的 derived 行则只回报 stale_row_present,不删不改。 2. **只写 kpi_values**(entity_id / kpi_id / period / actual_value + 来源标识 source_type='derived'), **不新建、不改动任何 KPI 定义**:定义缺失 → 跳过并回报 skipped_no_definition。 3. **幂等**:同一 (entity_id, kpi_id, period, source_type='derived') 重复运行只更新不新增 (与 scripts/kpi_bep_deriver.py 同范式)。 本模块只依赖「台账行对象」的属性(ORM 实例 / 简易对象皆可),纯计算函数可脱离数据库单测。 """ from __future__ import annotations import calendar import logging import re from datetime import date, datetime from typing import Iterable, Optional, Sequence from sqlalchemy.orm import Session from app.models import KPIDefinition, KPIValue, TaxFilingLog, HrMonthlyLedger, LicenceLedger logger = logging.getLogger("cma.ext_input_derive") TAX_TIMELY_CODE = "P_TAX_TIMELY" EMPLOYEE_TURNOVER_CODE = "L_EMPLOYEE_TURNOVER" LICENCE_OK_CODE = "P_LICENCE_OK" ALL_KPI_CODES = (TAX_TIMELY_CODE, EMPLOYEE_TURNOVER_CODE, LICENCE_OK_CODE) # 数据来源标识(写入 kpi_values.source_type;与 dashboard「最新派生期间」取数口径一致) SOURCE_TYPE = "derived" _MONTH_RE = re.compile(r"^(\d{4})-(0[1-9]|1[0-2])$") _QUARTER_RE = re.compile(r"^(\d{4})-Q([1-4])$") # ============================================================ # 期间工具 # ============================================================ def is_month_period(period: str) -> bool: return bool(_MONTH_RE.match(str(period or ""))) def is_quarter_period(period: str) -> bool: return bool(_QUARTER_RE.match(str(period or ""))) def month_to_quarter(period: str) -> str: """2026-06 → 2026-Q2(季频指标按「所在季度」归集)""" m = _MONTH_RE.match(str(period or "")) if not m: raise ValueError(f"非法月期间: {period}(应为 YYYY-MM)") quarter = (int(m.group(2)) - 1) // 3 + 1 return f"{m.group(1)}-Q{quarter}" def quarter_end_date(period: str) -> date: """季末基准日。接受 2026-Q2(季频)或 2026-06(月频,自动归到所在季度)""" q = _QUARTER_RE.match(str(period or "")) if q: year, quarter = int(q.group(1)), int(q.group(2)) else: q2 = re.match(r"^(\d{4})-Q([1-4])$", month_to_quarter(period)) if not q2: raise ValueError(f"非法季期间: {period}(应为 YYYY-Qn 或 YYYY-MM)") year, quarter = int(q2.group(1)), int(q2.group(2)) last_month = quarter * 3 return date(year, last_month, calendar.monthrange(year, last_month)[1]) # ============================================================ # 1. 申报行为台账 → P_TAX_TIMELY # ============================================================ def derive_on_time(filed_date: Optional[date], legal_deadline: Optional[date]) -> Optional[int]: """`on_time` 的**唯一**服务端派生规则(录入方不得手填): - 已申报且不晚于法定期限 → 1 - 已申报但晚于法定期限 → 0 - 未申报(filed_date 为空) → 0(逾期未申报,计入分母) - 已申报但缺法定期限(无法判定)→ None(该行不计入分子;分母仍计) """ if filed_date is None: return 0 if legal_deadline is None: return None return 1 if filed_date <= legal_deadline else 0 def compute_tax_timely_rate(rows: Iterable) -> Optional[float]: """P_TAX_TIMELY = on_time=1 的税种数 ÷ 应申报税种数 × 100;分母为 0 → None""" rows = list(rows) denominator = len(rows) if denominator == 0: return None timely = sum(1 for r in rows if getattr(r, "on_time", None) == 1) return round(timely / denominator * 100.0, 1) def explain_tax_timely_rate(rows: Iterable) -> dict: rows = list(rows) value = compute_tax_timely_rate(rows) return { "denominator": len(rows), "timely": sum(1 for r in rows if getattr(r, "on_time", None) == 1), "unknown": sum(1 for r in rows if getattr(r, "on_time", None) is None), "value": value, "no_definition": value is None, } # ============================================================ # 2. 人事月度台账 → L_EMPLOYEE_TURNOVER # ============================================================ def check_headcount_equation(headcount_begin, hires, separations, headcount_end) -> Optional[str]: """期末 = 期初 + 入职 − 离职。通过返回 None,不通过返回错误说明(调用方拒绝写入并回报)""" try: lhs = int(headcount_end) rhs = int(headcount_begin) + int(hires) - int(separations) except (TypeError, ValueError): return "期初/入职/离职/期末必须为整数" if lhs != rhs: return (f"人事台账等式不成立: 期末({lhs}) ≠ 期初({headcount_begin}) + 入职({hires}) " f"− 离职({separations}) = {rhs}(拒绝写入)") return None def compute_employee_turnover(row) -> Optional[float]: """L_EMPLOYEE_TURNOVER = separations ÷ headcount_begin × 100;期初为 0(或缺失)→ None""" if row is None: return None try: begin = int(getattr(row, "headcount_begin")) separations = int(getattr(row, "separations")) except (TypeError, ValueError): return None if begin == 0: return None return round(separations / begin * 100.0, 2) def explain_employee_turnover(row) -> dict: """并列返回「期初期末平均在职」口径值,便于复核(平均在职为 0 → 该口径同样 None)""" value = compute_employee_turnover(row) detail = { "headcount_begin": getattr(row, "headcount_begin", None), "headcount_end": getattr(row, "headcount_end", None), "hires": getattr(row, "hires", None), "separations": getattr(row, "separations", None), "value": value, "no_definition": value is None, "avg_headcount": None, "value_on_avg": None, } if row is None: return detail try: avg = (int(row.headcount_begin) + int(row.headcount_end)) / 2.0 except (TypeError, ValueError): avg = None detail["avg_headcount"] = avg if avg: detail["value_on_avg"] = round(int(row.separations) / avg * 100.0, 2) return detail # ============================================================ # 3. 证照台账 → P_LICENCE_OK # ============================================================ def is_licence_valid(licence, as_of: date) -> bool: """有效证照判定:状态非「过期」且(valid_to 为空=长期有效 或 valid_to >= 基准日)""" if str(getattr(licence, "annual_check_status", "") or "").strip() == "过期": return False valid_to = getattr(licence, "valid_to", None) if valid_to is None: return True if isinstance(valid_to, datetime): valid_to = valid_to.date() return valid_to >= as_of def compute_licence_ok(rows: Iterable, as_of: date) -> Optional[float]: """P_LICENCE_OK = 有效证照数 ÷ 应持证照数 × 100(基准日=季末);应持证照数为 0 → None""" rows = list(rows) denominator = len(rows) if denominator == 0: return None valid = sum(1 for r in rows if is_licence_valid(r, as_of)) return round(valid / denominator * 100.0, 1) def explain_licence_ok(rows: Iterable, as_of: date) -> dict: rows = list(rows) value = compute_licence_ok(rows, as_of) expired = [getattr(r, "licence_no", "") for r in rows if str(getattr(r, "annual_check_status", "")) == "过期"] return { "as_of": as_of.isoformat(), "denominator": len(rows), "valid": sum(1 for r in rows if is_licence_valid(r, as_of)), "expired_or_outdated": expired, "value": value, "no_definition": value is None, } # ============================================================ # 落库(幂等 upsert;只写 kpi_values) # ============================================================ def _resolve_kpi(db: Session, entity_id: int, kpi_code: str) -> Optional[KPIDefinition]: """按 (entity_id, kpi_code) 取定义(仅 active)。定义唯一性在库内非全局: e1/e2 可各有一条同名 kpi_code,故必须带 entity_id 过滤 + order_by(id) 取稳定行。""" return (db.query(KPIDefinition) .filter(KPIDefinition.entity_id == entity_id, KPIDefinition.kpi_code == kpi_code, KPIDefinition.status == "active") .order_by(KPIDefinition.id.asc()) .first()) def upsert_kpi_value(db: Session, entity_id: int, kpi: KPIDefinition, period: str, value: float, remark: str, batch: str) -> str: """幂等写 kpi_values:命中 (kpi_id, period, source_type='derived') 则更新,返回 'insert'/'upsert'""" existing = (db.query(KPIValue) .filter(KPIValue.kpi_id == kpi.id, KPIValue.period == period, KPIValue.source_type == SOURCE_TYPE) .order_by(KPIValue.id.asc()) .first()) if existing is not None: action = "upsert" existing.actual_value = value existing.source_batch = batch existing.data_status = "pending" existing.remark = remark else: db.add(KPIValue( entity_id=entity_id, kpi_id=kpi.id, period=period, actual_value=value, source_type=SOURCE_TYPE, source_batch=batch, data_status="pending", remark=remark, )) action = "insert" db.flush() return action def run_derive(db: Session, entity_id: int, period: str, kpis: Optional[Sequence[str]] = None, write: bool = True) -> dict: """按实体+期间派生 3 个指标并写 kpi_values(幂等)。 period:P_TAX_TIMELY / L_EMPLOYEE_TURNOVER 用 YYYY-MM; P_LICENCE_OK 用 YYYY-Qn(传 YYYY-MM 时自动归到所在季度,落库 period 为季度)。 write=False → 只算不写(dry-run)。 返回 {'entity_id', 'period', 'batch', 'items': {kpi_code: {...}}} """ codes = list(kpis or ALL_KPI_CODES) unknown = [c for c in codes if c not in ALL_KPI_CODES] if unknown: raise ValueError(f"不支持的指标编码: {unknown}(可用: {list(ALL_KPI_CODES)})") batch = f"ext-ledger-{datetime.now().strftime('%Y%m%d%H%M%S')}" items: dict = {} for code in codes: kpi = _resolve_kpi(db, entity_id, code) if kpi is None: items[code] = { "action": "skipped_no_definition", "reason": f"entity={entity_id} 无 active 定义 {code}(定义变更属另一派单,本模块不新建)", "actual_value": None, } continue if code == TAX_TIMELY_CODE: rows = (db.query(TaxFilingLog) .filter(TaxFilingLog.entity_id == entity_id, TaxFilingLog.period == period) .order_by(TaxFilingLog.id.asc()).all()) detail = explain_tax_timely_rate(rows) target_period = period remark = (f"台账派生: 应申报{detail['denominator']}个税种,按时{detail['timely']}个," f"无法判定{detail['unknown']}个(tax_filing_log {period})") elif code == EMPLOYEE_TURNOVER_CODE: row = (db.query(HrMonthlyLedger) .filter(HrMonthlyLedger.entity_id == entity_id, HrMonthlyLedger.period == period) .order_by(HrMonthlyLedger.id.asc()).first()) detail = explain_employee_turnover(row) target_period = period remark = (f"台账派生: 离职{detail['separations']}/期初在职{detail['headcount_begin']}×100 " f"(期初{detail['headcount_begin']}+入职{detail['hires']}-离职{detail['separations']}" f"=期末{detail['headcount_end']});并列口径 期初期末平均在职{detail['avg_headcount']}人 " f"→ {detail['value_on_avg']}%") else: # LICENCE_OK —— 季频 target_period = month_to_quarter(period) if is_month_period(period) else period as_of = quarter_end_date(target_period) rows = (db.query(LicenceLedger) .filter(LicenceLedger.entity_id == entity_id) .order_by(LicenceLedger.id.asc()).all()) detail = explain_licence_ok(rows, as_of) remark = (f"台账派生: 季末基准日{detail['as_of']},有效{detail['valid']}/应持证{detail['denominator']}" f"(过期或失效: {detail['expired_or_outdated'] or '无'})") value = detail.get("value") item = { "kpi_id": kpi.id, "kpi_code": code, "period": target_period, "actual_value": value, "detail": detail, } if value is None: # 硬规则:分母 0 → 无定义,不写 kpi_values(不写 0、不填大数) stale = (db.query(KPIValue) .filter(KPIValue.kpi_id == kpi.id, KPIValue.period == target_period, KPIValue.source_type == SOURCE_TYPE) .order_by(KPIValue.id.asc()).first()) item["action"] = "skipped_denominator_zero" item["reason"] = "分母为 0 → 无定义(不参评),不写 kpi_values" item["stale_row_present"] = stale is not None items[code] = item continue if write: item["action"] = upsert_kpi_value(db, entity_id, kpi, target_period, value, remark, batch) else: item["action"] = "dry-run" item["remark"] = remark items[code] = item if write: db.commit() return {"entity_id": entity_id, "period": period, "batch": batch, "write": write, "items": items}