certflow.services.import_gate_service 源代码

# certflow/services/import_gate_service.py
"""#30 P1 导入门控:yaml 驱动跳过/隔离无单号行

替代 VBA「要货单号='假'/空 → 整行跳过」逻辑,全配置驱动。
门控在读表得到 records 之后、进入 save_handler 之前过滤,**不改动源文件**。

配置(config.yaml sales_plan.import_gate):
- enabled: 总开关
- order_identity_fields: 现代要货单号来源字段(2025+ 行凭此逃逸门控)
- legacy_field: legacy 要货单号来源字段(pre-2025 行凭 plan_no 逃逸门控)
- mode:
    - skip    = 直接丢弃(不导入、不隔离)
    - isolate = 隔离到待复核(返回 gated 列表,由调用方决定落库/展示)
    - warn    = 仍导入但给该行打 flag=True(黄标,等价于非阻塞标黄)
- match_keys: 门控匹配维度(隔离时与既有行归并的键,预留)

门控语义(业务正确,替代 VBA「要货单号='假'/空→整行跳过」):
仅当「全部订单身份字段(order_identity_fields + legacy_field)均缺失」时,
该行才被判定为「无单号」无效行并触发门控;任一身份字段非空即逃逸。
—— 因此 pre-2025 行(凭 plan_no/计划单号4位,VBA 即依此派生要货单号)不会被误门控;
    post-2025 行(凭 sales_order_no/production_order_no)也不会;
    仅真正全缺的行才被门控。
(single-SO=batch 等「有单号但清单不全」的无效情形不自动检测,由人工在销售计划副本补录。)
"""

from __future__ import annotations

from typing import Any

from sqlalchemy.orm import Session

from certflow.config.settings import IMPORT_GATE


[文档] class ImportGateService: """导入门控:按 config.yaml sales_plan.import_gate 过滤无单号行。""" # VBA 中「要货单号='假'」表示无效单号,等同缺失 _INVALID_SENTINELS = {"假", "false", "none", "null", "na", "n/a"} @staticmethod def _is_missing(record: dict[str, Any], field: str) -> bool: """该字段在记录中是否视为缺失(空 / 空串 / VBA 无效哨兵)。""" v = record.get(field) if v is None: return True if isinstance(v, str): s = v.strip() if s == "": return True if s.lower() in ImportGateService._INVALID_SENTINELS: return True return False
[文档] @classmethod def is_gated(cls, record: dict[str, Any], gate: dict[str, Any] | None = None) -> bool: """该记录是否触发门控(「全部订单身份字段均缺失」才判定为无单号无效行)。 - order_identity_fields(现代:sales_order_no/production_order_no) - legacy_field(legacy:plan_no/计划单号4位,VBA 依此派生要货单号) 任一非空即逃逸门控;全缺才门控。 """ gate = gate if gate is not None else IMPORT_GATE identity_fields = list(gate.get("order_identity_fields", []) or []) # 兼容旧配置名 if not identity_fields: identity_fields = list(gate.get("skip_when_missing", []) or []) legacy = gate.get("legacy_field") if legacy: identity_fields = identity_fields + [legacy] if not identity_fields: return False # 全缺才门控(任一存在即视为有订单身份) return all(cls._is_missing(record, f) for f in identity_fields)
[文档] @classmethod def apply( cls, records: list[dict[str, Any]], gate: dict[str, Any] | None = None, ) -> dict[str, Any]: """对记录列表应用门控。 Returns: { "kept": 参与后续导入的记录(list[dict]), "gated": 触发门控的记录(用于隔离/统计), "mode": 实际生效的 mode(未启用则为 None), "applied": 是否实际执行了门控(enabled 开关), } """ gate = gate if gate is not None else IMPORT_GATE if not gate.get("enabled", False): return {"kept": list(records), "gated": [], "mode": None, "applied": False} mode = (gate.get("mode") or "isolate").lower() gated = [r for r in records if cls.is_gated(r, gate)] kept = [r for r in records if not cls.is_gated(r, gate)] if mode == "warn": # 仍导入,但给触发行打黄标(非阻塞,等价于 VBA 标黄继续) for r in gated: r["flag"] = True kept = list(records) return {"kept": kept, "gated": gated, "mode": mode, "applied": True}
[文档] @classmethod def persist_isolated( cls, gated: list[dict[str, Any]], meta: dict[str, Any] | None = None, session: Session | None = None, ) -> int: """将 isolate 模式门控的行持久化到隔离表(回收站)。 isolate 模式语义:被门控的无效行不进 sale_plans,而是隔离到 sale_plans_quarantine,供「校正队列」人工复核 / 释放 / 补录字典自学习。 Args: gated: ImportGateService.apply 返回的 gated 记录列表 meta: 导入上下文(source_file/source_sheet/import_batch_id/reason) session: SQLAlchemy 会话(隔离行写入此会话并提交) Returns: int: 实际写入隔离表的行数 """ if not gated or session is None: return 0 from certflow.models.quarantine_sale_plan import QuarantineSalePlan count = 0 for rec in gated: session.add(QuarantineSalePlan.from_record(rec, meta)) count += 1 if count: session.commit() return count