certflow.handlers.save_handler 源代码

"""数据库保存处理器模块

提供销售计划记录的数据转换和变更检测功能,
ORM 操作通过回调委托给 Service 层。
"""

from __future__ import annotations

import hashlib
import json
import re
from datetime import datetime
from typing import Any, Protocol

from loguru import logger
from openpyxl.styles.colors import COLOR_INDEX
from sqlalchemy.orm import Session

from certflow.config.settings import (
    DUAL_FIELD_MAPPING,
    ON_COLLISION,
    REQUIRE_NUMBERED,
    UPDATE_ON_DUPLICATE,
)
from certflow.handlers import IDGenerator
from certflow.models import SalePlan, SalePlanChange
from certflow.utils import extract_yymm_from_date
from certflow.utils.date_utils import normalize_plan_date
from certflow.utils.status_inference import StatusInference

# 6 位十六进制颜色(RRGGBB / BGR 字面量等)
_HEX6_RE = re.compile(r"^[0-9A-Fa-f]{6}$")


[文档] def resolve_dual_fields( record: dict[str, Any], import_mode: str, pairs: list[dict[str, str]], ) -> dict[str, str]: """按 import_mode 计算 cert_* 合格证字段值(蓝图 §4.2.1)。 Args: record: 已映射字段的导入记录(可能含 cert_* 键,仅当 Excel 提供对应列) import_mode: ``copy`` / ``override`` / ``lazy`` pairs: 双字段映射对列表,每项 ``{contract, cert}`` Returns: dict: ``{cert_field: value}``,仅含 pairs 中声明的 cert 字段 规则: - ``copy``:cert_* = 合同值(杜绝"改合同静默跟随"风险) - ``override``:仅当源行含 cert 列(Excel 提供)时写 cert_*,否则留空回退 - ``lazy``(默认):cert_* 留空,打印时回退合同值(保持现状) """ import_mode = (import_mode or "lazy").strip().lower() def _clean(v: Any) -> str: s = "" if v is None else str(v) return "" if "【空白" in s else s.strip() result: dict[str, str] = {} for p in pairs or []: contract = p.get("contract") cert = p.get("cert") if not contract or not cert: continue contract_val = _clean(record.get(contract, "")) cert_src = _clean(record.get(cert, "")) if import_mode == "copy": result[cert] = contract_val elif import_mode == "override": result[cert] = cert_src # 仅当 Excel 提供 cert 列时非空 else: # lazy(默认) result[cert] = "" return result
def _indexed_to_bgr(idx: int) -> int | None: """openpyxl 索引色调色板 → BGR int(与其余分支统一 BGR 方向)。""" if not (0 <= idx < len(COLOR_INDEX)): return None hexv = COLOR_INDEX[idx] if not hexv: return None hexv = hexv[-6:] # 去掉可能的 alpha 前缀 return int(hexv[4:6] + hexv[2:4] + hexv[0:2], 16) def _coerce_color_int(v: Any) -> int | None: """颜色值统一转为整数 key(BGR 格式,与 config.yaml 的 bg/font 映射对齐) 支持来源: - int / float:直接取整(config 的 BGR 整数键) - 6 位十六进制字符串 ``"RRGGBB"``:**按 BGR 方向反转**为 int (openpyxl 存的是 RGB 字面量,config 键是 BGR,方向必须反转; 例如字体蓝 ``"0000FF"`` → 255<<16 = 16711680) - ``"INDEXED_n"``:经 openpyxl 标准索引色调色板转为 BGR int - ``"THEME_n"``:主题色(透明/默认,无业务信号)→ 返回 None - 纯数字字符串:按 int 解析 旧实现仅 ``int()`` 数字,遇到 ``"INDEXED_27"`` / ``"0000FF"`` / ``"THEME_0"`` 一律返回 None,导致背景色 / 字体色派生全部失效、回落错误默认。 """ if v is None or isinstance(v, bool): return None if isinstance(v, int | float): return int(v) s = str(v).strip() if not s: return None if _HEX6_RE.match(s): s = s.upper() # RRGGBB -> BGR int return int(s[4:6] + s[2:4] + s[0:2], 16) if s.upper().startswith("INDEXED_"): try: return _indexed_to_bgr(int(s.split("_", 1)[1])) except (ValueError, IndexError): return None if s.upper().startswith("THEME_"): # 主题色在 openpyxl 中无法可靠转为 BGR(且与白透明塌缩),视为无信号 return None try: return int(s) except ValueError: return None def _comments_to_text(comments: Any) -> str: """将 _comments 列表([{column, text}, ...])拼为单行变更说明文本。 用于在 SalePlan.change_note 中保留单元格批注(变更说明)的可追溯原文。 """ if not comments: return "" parts: list[str] = [] for c in comments: if isinstance(c, dict): text = str(c.get("text", "")).strip() if not text: continue col = str(c.get("column", "")).strip() parts.append(f"[{col}] {text}" if col else text) elif c and str(c).strip(): parts.append(str(c).strip()) return "\n".join(parts) def _refresh_comment_fields(existing: SalePlan, record: dict[str, Any]) -> None: """重导入时刷新 技术要求原文 与 批注(来自 Excel 最新单元格/批注)。 - technical_requirement_raw:仅当本次导入的技术要求非空时覆盖,避免清空首发快照。 - change_note:仅当本次记录携带批注时刷新(无批注行保持既有值)。 - comments:批注 JSON([{column, text}, ...]),仅当本次记录携带批注时刷新; 无批注行保持既有值,避免清空首发导入时已落库的批注原文。 """ raw = record.get("tech_requirements") if raw not in (None, ""): existing.technical_requirement_raw = str(raw) logger.debug(f"刷新 technical_requirement_raw | id={existing.id} 长度={len(str(raw))}") comments = record.get("_comments") if comments: existing.change_note = _comments_to_text(comments) existing.comments = json.dumps(comments, ensure_ascii=False) n = len(comments) if isinstance(comments, list) else "?" logger.debug(f"刷新批注 | id={existing.id} 批注条数={n}")
[文档] class SaveHandlerOrmDelegate(Protocol): """SaveHandler 的 ORM 委托协议 Service 层实现此协议,Handler 通过此接口间接操作数据库。 """
[文档] def find_by_unique_key(self, unique_key: str) -> SalePlan | None: """按唯一键查找销售计划记录。 Args: unique_key: 记录唯一键(由 IDGenerator 生成)。 Returns: SalePlan | None: 匹配的销售计划记录;未找到时返回 None。 """ ...
[文档] def find_by_production_order_no(self, production_order_no: str) -> SalePlan | None: """按生产令号查找销售计划记录(老数据降级匹配)。 用于缺少唯一键的老数据模式,与生产令号精确匹配。 Args: production_order_no: 生产令号。 Returns: SalePlan | None: 匹配的销售计划记录;未找到时返回 None。 """ ...
[文档] def find_by_contract_and_model(self, contract_no: str, product_model: str) -> SalePlan | None: """按合同号 + 产品型号兜底匹配(跨来源补充场景)。 当 unique_key 与生产令号均不匹配时,用合同号与型号跨来源 补充缺失字段。 Args: contract_no: 合同号。 product_model: 产品型号。 Returns: SalePlan | None: 匹配的销售计划记录;未找到时返回 None。 """ ...
[文档] def add_sale_plan(self, sale_plan: SalePlan) -> None: """向会话新增一条销售计划记录(不自动提交)。 Args: sale_plan: 待新增的 SalePlan 实例。 """ ...
[文档] def add_change(self, change: SalePlanChange) -> None: """向会话新增一条变更记录(不自动提交)。 Args: change: 待新增的 SalePlanChange 实例。 """ ...
[文档] def flush_session(self) -> None: """刷新会话,将挂起的对象写入数据库(不提交事务)。""" ...
[文档] def commit_session(self) -> None: """提交事务,持久化会话中所有挂起的变更。""" ...
[文档] class SaveHandler: """数据库保存处理器 负责数据转换和变更检测,ORM 操作通过 orm_delegate 回调委托给 Service 层。 Examples: >>> handler = SaveHandler(session, orm_delegate=service) >>> imported, skipped = handler.save_styled_records( ... sorted_records, file_path, sheet_name, batch_id, "2601", 2601 ... ) """ def __init__( self, session: Session, orm_delegate: SaveHandlerOrmDelegate | None = None, ): """初始化保存处理器 Args: session: SQLAlchemy 数据库会话对象(用于只读查询) orm_delegate: ORM 操作委托对象(Service 层实现) """ self.session = session self._orm = orm_delegate # 本批次已插入记录的 unique_key 集合,用于在兜底匹配时排除 # 同批次内刚插入的行(避免把不同 unique_key 但共享生产令号/ # 合同号+型号的行误判为"已存在"而漏存并产生虚假变更)。 self._batch_inserted_keys: set[str] = set() def _find_existing(self, record: dict[str, Any], unique_key: str) -> SalePlan | None: """查找已有记录:三层降级匹配策略。 1. 按 unique_key 精确匹配 2. 按 production_order_no 匹配(老数据模式降级) 3. 按 contract_no + product_model 匹配(跨来源补充场景) 老数据模式下,同一条记录从不同来源导入时字段值可能有差异, 导致 unique_key 不匹配。通过 contract_no + product_model 兜底匹配, 实现缺失字段的跨来源补充。 """ orm = self._orm if orm is None: return None existing = orm.find_by_unique_key(unique_key) if self._match_ok(existing) is not None: return existing # 兜底匹配只应命中「既往批次」记录;本批次内刚插入的行 # (unique_key 不同但共享生产令号 / 合同号+型号)应视为独立新记录, # 否则会漏存数据并产生虚假「变更」。 def _is_prior_batch(record_: SalePlan) -> bool: return record_.unique_key not in self._batch_inserted_keys pno = str(record.get("production_order_no", "")).strip() if pno and not pno.startswith(IDGenerator._get_empty_marker_prefix()): cand = orm.find_by_production_order_no(pno) if cand and _is_prior_batch(cand) and self._match_ok(cand) is not None: logger.debug(f"按生产令号 {pno} 匹配到已有记录 (id={cand.id})") return cand # 第三级降级:按 contract_no + product_model 兜底匹配 contract_no = str(record.get("contract_no", "")).strip() product_model = str(record.get("product_model", "")).strip() if ( contract_no and not contract_no.startswith(IDGenerator._get_empty_marker_prefix()) and product_model and not product_model.startswith(IDGenerator._get_empty_marker_prefix()) ): cand = orm.find_by_contract_and_model(contract_no, product_model) if cand and _is_prior_batch(cand) and self._match_ok(cand) is not None: logger.debug( f"按合同号 {contract_no} + 型号 {product_model} 匹配到已有记录 (id={cand.id})" ) return cand return None @staticmethod def _match_ok(cand: SalePlan | None) -> SalePlan | None: """require_numbered 门控(§9 落地待办①) 返回 cand 本身(命中可用);若启用 require_numbered 且 cand 未编号 (无证书号),则视为新行不参与判重,返回 None。 """ if cand is None: return None if REQUIRE_NUMBERED and not (cand.certificate_number or "").strip(): logger.debug(f"require_numbered: 命中行 id={cand.id} 未编号,视为新行不判重") return None return cand def _handle_existing( self, existing: SalePlan, record: dict[str, Any], source_file: str, source_sheet: str, import_batch_id: str, ) -> int: """处理匹配到的已有记录:补充缺失字段 + 检测变更。 Returns: 产生的变更记录数 """ orm = self._orm filled_fields = self._fill_missing_fields(existing, record) if filled_fields: logger.info(f"记录 {existing.id} 补充缺失字段: {', '.join(filled_fields)}") # B0-2 / B0-5:无论是否启用变更追踪,重复导入都刷新 技术要求原文 与 变更说明 _refresh_comment_fields(existing, record) if not UPDATE_ON_DUPLICATE: return [] # §9 落地待办②:变更签名短路——身份键命中时若内容签名一致则判定无变更, # 跳过逐字段 diff(签名由 monitored_fields 摘要,与 _detect_changes 判定等价)。 new_sig = IDGenerator.generate_change_signature(record) if existing.change_signature and existing.change_signature == new_sig: changes = [] else: changes = self._detect_changes( existing, record, source_file, source_sheet, import_batch_id ) existing.change_signature = new_sig # #30 P0 颜色语义显式化:带格式重导入时刷新 供货类型/发货状态 self._refresh_styled_fields(existing, record) # #30 P0 DN/PN 字典 DB 化:重复导入时刷新解析压力/标准号与标黄。 # 压力/标准号非空才覆盖,避免清空人工修正值;标黄按当前记录的解析信号 # 重新计算(不再 OR 既有 flag)——字典补全后即解除标黄,符合「重导刷新」语义。 if record.get("pressure_value"): existing.pressure_value = record["pressure_value"] if record.get("test_standard"): existing.test_standard = record["test_standard"] existing.flag = bool( record.get("flag", False) or record.get("spec_needs_manual", False) or record.get("pn_needs_manual", False) ) if changes and orm: for change in changes: orm.add_change(change) orm.flush_session() logger.debug(f"记录 {existing.id}{len(changes)} 个字段变更") # 返回本记录发生的字段变更列表(空列表=无变更),供上层分类统计。 return changes def _refresh_styled_fields(self, existing: SalePlan, record: dict[str, Any]) -> None: """带格式重导入时刷新 供货类型/发货状态(仅覆盖可判定值,不降级、不还原手动值)。 仅在记录确为带格式导入(含颜色键)时生效;供货类型只覆盖已判定值 或既往为空者,绝不把既有的 自产/外购 降级为 待定。 发货状态(shipping_status) 溯源保护(#30 P0 A 方案): - 若既有来源为 UI 手动设置(source=='manual'),则跳过字体色覆盖, 避免下月重导入把人工修正值还原为颜色派生值; - 否则覆盖,并写入 source='import'、set_at=now 的溯源信息; - 单调守卫:已发货/已收款 等终态不被降级为 未发货; - 当本次派生为 已发货 且 shipped_date 为空时,记录发货日期(导入日期)。 """ if "_background_colors" not in record and "_font_colors" not in record: return supply, shipping_status = self._resolve_color_statuses(record) if shipping_status: self._apply_shipping_status(existing, shipping_status) if supply and supply != "待定": existing.outsource_type = supply existing.supply_type = supply elif not (existing.supply_type or "").strip(): # 仅当既往为空时才落“待定”占位,绝不覆盖已判定值 existing.outsource_type = supply existing.supply_type = supply @staticmethod def _apply_shipping_status(existing: SalePlan, shipping_status: str) -> None: """应用派生发货状态并维护溯源(#30 P0 A 方案) 规则见 ``_refresh_styled_fields`` 文档。手动来源受保护,终态不被降级。 """ # 1) 手动来源受保护:跳过字体色覆盖,避免还原人工修正 if (existing.shipping_status_source or "import") == "manual": logger.debug( f"记录 {existing.id} 发货状态为手动设置(source=manual)," f"跳过导入派生覆盖(派生值={shipping_status})" ) return # 2) 应用并写入导入溯源(带格式重导入以当前字体色派生为准,刷新终态) if existing.shipping_status != shipping_status: existing.shipping_status = shipping_status existing.shipping_status_source = "import" existing.shipping_status_set_at = datetime.now() # 4) 首次判定为已发货时记录发货日期 if shipping_status == "已发货" and not (existing.shipped_date or "").strip(): existing.shipped_date = datetime.now().strftime("%Y-%m-%d") @staticmethod def _resolve_color_statuses( record: dict[str, Any], bg_map: dict[Any, str] | None = None, font_map: dict[int, str] | None = None, ) -> tuple[str, str]: """由已捕获的颜色字典 + 执列文字派生 供货类型 与 发货状态(#30 P0) 颜色 JSON(font_colors / background_colors)保留为真相源,本列为派生物。 供货类型(supply_type) 派生优先级(对齐用户最终裁定): 1. ``执`` 列文字(已随列映射进入 ``record["supply_type"]``,最强信号): ``生产`` → 自产;供应商名等其它非空 → 外购; 2. 背景色(次要信号):命中 外购未回/外购已回/外购 → 外购; 3. 2025+ 经 AO(销售订单号)+AP(生产令号) 确认真实 ERP 订单,但 AP 顺序分配 无法区分自产/外购 → 待定; 4. 其余无可判信号 → **待定**(绝不猜“自产”,透明/白 ≠ 自产)。 发货状态(shipping_status) 由字体色派生:蓝(16711680)→已发货、黑(0)→未发货、 红(255)→已收款。未捕获字体色时返回空,避免覆盖既有值。 Args: record: 单条导入记录 bg_map: 背景色映射(BGR 整数键或 "INDEXED_n" 字符串键;默认读配置) font_map: 字体色映射(默认读配置) Returns: (supply_type, shipping_status) """ from certflow.config.settings import ( BG_COLOR_TO_STATUS, FONT_COLOR_TO_SHIPPING, ) if bg_map is None: bg_map = BG_COLOR_TO_STATUS or {} if font_map is None: font_map = FONT_COLOR_TO_SHIPPING or {} bg = record.get("_background_colors") or {} font = record.get("_font_colors") or {} if not isinstance(bg, dict): bg = {} if not isinstance(font, dict): font = {} # 非带格式导入(record 不含任何颜色键)→ 返回空,避免覆盖既有有效值 if "_background_colors" not in record and "_font_colors" not in record: return "", "" supply = SaveHandler._resolve_supply_type(record, bg, bg_map) shipping = SaveHandler._resolve_shipping_status(font, font_map) return supply, shipping @staticmethod def _resolve_supply_type( record: dict[str, Any], bg: dict[Any, Any], bg_map: dict[Any, str] ) -> str: """供货类型派生:执文字 > 背景色(直接映射) > 待定 背景色经 openpyxl 读取后可能为 ``"INDEXED_27"`` / ``"THEME_0"`` 等字符串, 故映射查询同时尝试整数 key 与原始字符串 key(config.yaml 两种都配)。 背景色映射 ``bg_map`` 已是显式状态(外购未回/外购已回/自产/待编号), 直接采用,不再折叠为 外购/待定。 """ # 1) 执 列文字(最强信号) zhi = str(record.get("supply_type", "") or "").strip() # 导入管线会把空单元格填成 “【空白供货类型】” 等占位符,视为无信号 if zhi.startswith("【空白"): zhi = "" if zhi == "生产": return "自产" if zhi: # 供应商名等 → 外购 return "外购" # 2) 背景色(次要信号):仅命中明确的外部采购状态才派生 # 外购未回/外购已回/外购 → 采用该显式状态; # 其余背景色(透明/待编号/特殊订单/订单取消等)不参与供货类型判定, # 交由下方默认分支兜底。 for v in bg.values(): vi = _coerce_color_int(v) s = bg_map.get(vi) if vi is not None else None if s is None: s = bg_map.get(str(v)) # 支持字符串 key(如 "INDEXED_27") if s in ("外购未回", "外购已回", "外购"): return s # 3) / 4) 无强信号 → 默认 自产(带格式导入的白色/透明背景即视为自产) return "自产" @staticmethod def _resolve_shipping_status(font: dict[Any, Any], font_map: dict[int, str]) -> str: """字体色字典 → 发货状态(蓝→已发货、红→红字、黑→未发货) 字体色字典为空(非带格式导入)时按既定默认返回 未发货 (调用方在既无背景色也无字体色时已先行返回空,避免覆盖既有有效值)。 """ for v in font.values(): vi = _coerce_color_int(v) if vi is None: continue s = font_map.get(vi) if s == "已发货": return "已发货" if s == "红字": return "红字" if s == "已收款": return "已收款" return "未发货"
[文档] def save_styled_records( # noqa: C901 self, sorted_records: list[dict[str, Any]], file_path: str, sheet_name: str, import_batch_id: str, year_month_prefix: str, year_month_int: int, conflict_resolutions: dict[str, str] | None = None, collision_policy: str | None = None, ) -> dict[str, Any]: """保存带格式的分组记录到数据库 Args: sorted_records: 已分组排序的记录列表 file_path: 源文件路径 sheet_name: 工作表名称 import_batch_id: 导入批次ID year_month_prefix: 年月前缀字符串,如 "2601" year_month_int: 年月前缀整数,如 2601 conflict_resolutions: db_conflict 决议(unique_key -> 动作) collision_policy: 批次内撞键处理策略(UI 选择,覆盖 ON_COLLISION 配置): isolate(隔离,默认)/ first(取首行)/ discard(丢弃全部重复) Returns: dict: {'new_count': int, 'duplicate_count': int, 'changes_count': int, ...} - new_count: 新增记录数 - duplicate_count: 跳过(已存在且内容无变更)记录数; 注意「变更」行不计入此处,避免与 changes_count 重叠 - changes_count: 已存在但内容发生字段变更的记录数 - change_categories / changed_fields: 变更按字段归类统计 """ orm = self._orm imported_count = 0 skipped_count = 0 changes_count = 0 change_categories: dict[str, int] = {} changed_fields: dict[str, int] = {} quarantined_count = 0 collision_quarantined = 0 # 批次内撞键(unique_key 重复)隔离数 collision_dropped = 0 # 批次内撞键按 first/discard 直接丢弃的行数 resolution_quarantined = 0 # 冲突决议选「隔离」的隔离数 merged_count = 0 # 批内撞键按 on_collision=merge_* 合并的行数 import_date = datetime.now() seen_keys: set[str] = set() # 同批次内存去重 self._seen_objects: dict[str, Any] = {} # 同批次首个插入的 ORM 对象(供 merge 更新数量) self._batch_inserted_keys = set() # 同批次已插入的 unique_key self._collision_policy = collision_policy # 显式撞键策略(UI 选择,覆盖 ON_COLLISION) # 已隔离未处理行的记录内容哈希集合(幂等:避免重导时重复隔离撞键行) existing_q_keys = self._load_existing_quarantine_hashes(file_path, sheet_name) for original_idx, record in enumerate(sorted_records): record = IDGenerator.normalize_spec(record, self.session) record = IDGenerator.normalize_pn(record, self.session) unique_key = IDGenerator.generate_unique_key(record) # 1. 同批次内存去重 if unique_key in seen_keys: # 撞键:按 collision_policy(UI 显式选择)或 ON_COLLISION(配置)处理 action = self._apply_batch_collision( record, unique_key, existing_q_keys, file_path, sheet_name, import_batch_id, self._collision_policy, ) if action == "merged": merged_count += 1 elif action == "quarantine": collision_quarantined += 1 else: # "drop"(取首行 / 丢弃全部重复) collision_dropped += 1 continue seen_keys.add(unique_key) # 丢弃全部重复:首行也不入库(整组判废,不进主表/待处理表) if self._collision_policy == "discard": continue # 生成序号字段 sort_group, original_order, sort_order = self._generate_order_fields( record=record, original_idx=original_idx, year_month_prefix=year_month_prefix, year_month_int=year_month_int, ) # 2. 数据库去重(unique_key + 生产令号降级) existing = self._find_existing(record, unique_key) if existing: action = (conflict_resolutions or {}).get(unique_key, "skip") decision, delta, new_key = self._resolve_existing( existing, record, unique_key, file_path, sheet_name, import_batch_id, action ) if decision == "updated": changes_count += delta continue if decision == "isolate": resolution_quarantined += 1 continue if decision == "new": unique_key = new_key # 以新键插入,existing 保留 else: # 与入库同清洗管线已在上游完成;此处仅判定内容是否变化。 # 注意:变更行不得计入「跳过」,否则「跳过」与「变更」重叠。 rec_changes = self._handle_existing( existing, record, file_path, sheet_name, import_batch_id ) if rec_changes: changes_count += 1 self._accumulate_change_categories( rec_changes, change_categories, changed_fields ) else: skipped_count += 1 continue # 创建新记录 sale_plan = self._create_sale_plan( record=record, unique_key=unique_key, original_order=original_order, sort_order=sort_order, sort_group=sort_group, file_path=file_path, sheet_name=sheet_name, import_batch_id=import_batch_id, import_date=import_date, ) if orm: orm.add_sale_plan(sale_plan) self._seen_objects[unique_key] = sale_plan self._batch_inserted_keys.add(unique_key) imported_count += 1 if imported_count % 100 == 0 and orm: orm.commit_session() logger.info(f"已提交 {imported_count} 条记录") if orm: orm.commit_session() _cat_str = ( ", ".join(f"{k}:{v}" for k, v in change_categories.items()) if change_categories else "" ) logger.info( f"保存完成: 新增 {imported_count} 条, 跳过(无变更) {skipped_count} 条, " f"变更 {changes_count} 条" + (f" [分类: {_cat_str}]" if _cat_str else "") + ( f", 批内撞键合并 {merged_count} 条(on_collision={ON_COLLISION})" if merged_count else "" ) ) quarantined_count = collision_quarantined + resolution_quarantined return { "new_count": imported_count, "duplicate_count": skipped_count, "changes_count": changes_count, "change_categories": change_categories, "changed_fields": changed_fields, "merged_count": merged_count, "quarantined_count": quarantined_count, "collision_quarantined": collision_quarantined, "collision_dropped": collision_dropped, "resolution_quarantined": resolution_quarantined, }
[文档] def save_records_with_stats( # noqa: C901 self, records: list[dict[str, Any]], source_file: str, source_sheet: str, import_batch_id: str, year_month_prefix: str, year_month_int: int, conflict_resolutions: dict[str, str] | None = None, collision_policy: str | None = None, ) -> dict[str, Any]: """保存记录到数据库,并返回统计信息(不带格式) Args: records: 待保存的记录字典列表 source_file: 源文件路径 source_sheet: 源工作表名称 import_batch_id: 导入批次ID year_month_prefix: 年月前缀字符串 year_month_int: 年月前缀整数 Returns: Dict[str, Any]: 保存统计信息 """ from collections import defaultdict orm = self._orm new_count = 0 duplicate_count = 0 quarantined_count = 0 collision_quarantined = 0 # 批次内撞键(unique_key 重复)隔离数 collision_dropped = 0 # 批次内撞键按 first/discard 直接丢弃的行数 resolution_quarantined = 0 # 冲突决议选「隔离」的隔离数 merged_count = 0 # 批内撞键按 on_collision=merge_* 合并的行数 changes_count = 0 change_categories: dict[str, int] = {} changed_fields: dict[str, int] = {} duplicates = [] group_stats = defaultdict(int) import_date = datetime.now() seen_keys: set[str] = set() self._seen_objects: dict[str, Any] = {} # 同批次首个插入的 ORM 对象(供 merge 更新数量) self._batch_inserted_keys = set() self._collision_policy = collision_policy # 显式撞键策略(UI 选择,覆盖 ON_COLLISION) existing_q_keys = self._load_existing_quarantine_hashes(source_file, source_sheet) for original_idx, record in enumerate(records): record = IDGenerator.normalize_spec(record, self.session) record = IDGenerator.normalize_pn(record, self.session) unique_key = IDGenerator.generate_unique_key(record) # 1. 同批次内存去重 if unique_key in seen_keys: # 撞键:按 collision_policy(UI 显式选择)或 ON_COLLISION(配置)处理 action = self._apply_batch_collision( record, unique_key, existing_q_keys, source_file, source_sheet, import_batch_id, self._collision_policy, ) if action == "merged": merged_count += 1 elif action == "quarantine": collision_quarantined += 1 else: # "drop"(取首行 / 丢弃全部重复) collision_dropped += 1 continue seen_keys.add(unique_key) # 丢弃全部重复:首行也不入库(整组判废,不进主表/待处理表) if self._collision_policy == "discard": continue # 生成序号字段 sort_group, original_order, sort_order = self._generate_order_fields( record=record, original_idx=original_idx, year_month_prefix=year_month_prefix, year_month_int=year_month_int, ) existing = self._find_existing(record, unique_key) if existing: action = (conflict_resolutions or {}).get(unique_key, "skip") decision, delta, new_key = self._resolve_existing( existing, record, unique_key, source_file, source_sheet, import_batch_id, action ) if decision == "updated": changes_count += delta continue if decision == "isolate": resolution_quarantined += 1 continue if decision == "new": unique_key = new_key # 以新键插入,existing 保留 else: # 变更行不计入「跳过」,避免「跳过」与「变更」重叠统计。 rec_changes = self._handle_existing( existing, record, source_file, source_sheet, import_batch_id ) if rec_changes: changes_count += 1 self._accumulate_change_categories( rec_changes, change_categories, changed_fields ) else: duplicate_count += 1 duplicate_info = { "product_model": record.get("product_model", "N/A"), "order_no": record.get("order_no", record.get("sales_order_no", "N/A")), "reason": f"已存在 (ID: {existing.id})", } duplicates.append(duplicate_info) continue # 统计分组 group_key = record.get("_group_key", "") if group_key: group_stats[group_key] += 1 # 创建新记录 sale_plan = self._create_sale_plan( record=record, unique_key=unique_key, original_order=original_order, sort_order=sort_order, sort_group=sort_group, file_path=source_file, sheet_name=source_sheet, import_batch_id=import_batch_id, import_date=import_date, ) if orm: orm.add_sale_plan(sale_plan) self._seen_objects[unique_key] = sale_plan self._batch_inserted_keys.add(unique_key) new_count += 1 if orm: orm.commit_session() _cat_str = ( ", ".join(f"{k}:{v}" for k, v in change_categories.items()) if change_categories else "" ) logger.info( f"成功保存 {new_count} 条记录,跳过(无变更) {duplicate_count} 条重复" + (f" [变更分类: {_cat_str}]" if _cat_str else "") + ( f", 批内撞键合并 {merged_count} 条(on_collision={ON_COLLISION})" if merged_count else "" ) ) quarantined_count = collision_quarantined + resolution_quarantined return { "new_count": new_count, "duplicate_count": duplicate_count, "changes_count": changes_count, "change_categories": change_categories, "changed_fields": changed_fields, "merged_count": merged_count, "duplicates": duplicates, "group_stats": dict(group_stats), "quarantined_count": quarantined_count, "collision_quarantined": collision_quarantined, "collision_dropped": collision_dropped, "resolution_quarantined": resolution_quarantined, }
# ============================================================ # 撞键隔离(坑1 修复:撞键行进待处理表而非合并/覆盖) # ============================================================ @staticmethod def _record_content_hash(record: dict[str, Any]) -> str: """记录内容哈希(幂等隔离用)。同文件重导时内容一致 → 命中跳过。""" try: s = json.dumps(record, sort_keys=True, ensure_ascii=False, default=str) except Exception: s = json.dumps(record, sort_keys=True, default=str) return hashlib.sha256(s.encode("utf-8")).hexdigest() def _load_existing_quarantine_hashes(self, source_file: str, source_sheet: str) -> set[str]: """加载该 (文件, 表) 已隔离未处理行的记录内容哈希集合,供撞键隔离幂等判定。 以「记录内容」而非 unique_key 作幂等键:同组内多条撞键行(如分批发货、 数量不同)内容各异,都应各自隔离;重导同一文件时内容一致则命中跳过。 """ hashes: set[str] = set() if self.session is None: return hashes try: from sqlalchemy import select from certflow.models.quarantine_sale_plan import QuarantineSalePlan stmt = select(QuarantineSalePlan).where( QuarantineSalePlan.source_file == source_file, QuarantineSalePlan.source_sheet == source_sheet, QuarantineSalePlan.resolved == False, # noqa: E712 ) for q in self.session.execute(stmt).scalars().all(): try: hashes.add(self._record_content_hash(json.loads(q.raw_record_json or "{}"))) except Exception: continue except Exception as e: logger.warning(f"加载已隔离哈希失败(忽略): {e}") return hashes # ============================================================ # 冲突决议(B0-8:同 unique_key 但字段值不同的 5 选项处理) # ============================================================ # 受保护、不被覆盖/合并写入的系统字段 _RESOLUTION_PROTECTED = { "id", "unique_key", "created_at", "updated_at", "import_date", "import_batch_id", "source_file", "source_sheet", "original_order", "sort_order", "sort_group", "change_signature", "source_row_index", } # 变更分类(导入结果按字段归类,便于查询「哪些记录因何变更」) CHANGE_CATEGORIES: dict[str, set[str]] = { "发货日期": {"plan_date", "planned_delivery_date", "contract_delivery_date"}, "数量": {"quantity"}, "供货方式": {"supply_type"}, "产品信息": {"product_name", "product_model", "product_spec"}, "客户项目": {"customer", "project_name"}, } @staticmethod def _accumulate_change_categories( changes: list[Any], category_counter: dict[str, int], field_counter: dict[str, int] ) -> None: """把一批 SalePlanChange 累加进分类计数与字段计数(一条记录可命中多类)。""" for ch in changes: field_name = getattr(ch, "field_name", None) if not field_name: continue field_counter[field_name] = field_counter.get(field_name, 0) + 1 for cat, fields in SaveHandler.CHANGE_CATEGORIES.items(): if field_name in fields: category_counter[cat] = category_counter.get(cat, 0) + 1 break def _apply_record_to_existing( self, existing: Any, record: dict[str, Any], overwrite: bool ) -> None: """把 incoming record 的字段写回 existing(覆盖/合并)。 - overwrite=True:record 中所有非空字段均写回(含空串会清空现有值)。 - overwrite=False(merge):仅写回 record 中**非空**字段,空字段保留现有值。 系统字段(id/unique_key/时间戳等)受保护不被改写。 """ for key, val in record.items(): if key.startswith("_") or key in self._RESOLUTION_PROTECTED: continue if not hasattr(existing, key): continue if val is None: continue sval = str(val).strip() if isinstance(val, str) else val if not overwrite and (isinstance(sval, str) and sval == ""): continue # merge:空值保留现有 setattr(existing, key, sval) # B0-2 / B0-5:覆盖/合并决议时同步刷新 技术要求原文 与 变更说明 _refresh_comment_fields(existing, record) existing.updated_at = datetime.now() self.session.commit() def _resolve_existing( self, existing: Any, record: dict[str, Any], unique_key: str, source_file: str, source_sheet: str, import_batch_id: str, action: str | None, ) -> tuple[str, int, str | None]: """处理「已存在记录(同 unique_key)」的冲突决议。 Returns: (decision, change_delta, new_unique_key) - decision: "skip" 调用方按默认跳过(计 duplicate) "updated" 已就地覆盖/合并 existing,调用方 continue(计 changes) "isolate" 已将 incoming 隔离到待处理表,调用方 continue(计 quarantined) "new" 调用方应以 new_unique_key 作为新记录插入(existing 不动) - change_delta: updated 时为 1,其余 0 - new_unique_key: decision=="new" 时返回新键,否则 None """ action = (action or "skip").lower() if action in ("", "skip"): return ("skip", 0, None) if action == "overwrite": self._apply_record_to_existing(existing, record, overwrite=True) return ("updated", 1, None) if action == "merge": self._apply_record_to_existing(existing, record, overwrite=False) return ("updated", 1, None) if action == "isolate": q_keys = self._load_existing_quarantine_hashes(source_file, source_sheet) self._route_collision_to_quarantine( record, unique_key, source_file, source_sheet, import_batch_id, q_keys ) return ("isolate", 0, None) if action == "new": from uuid import uuid4 new_key = f"{unique_key}_new_{uuid4().hex[:6]}" return ("new", 0, new_key) return ("skip", 0, None) def _apply_batch_collision( self, record: dict[str, Any], unique_key: str, existing_q_keys: set[str], file_path: str, sheet_name: str, import_batch_id: str, collision_policy: str | None = None, ) -> str: """批内撞键(unique_key 重复)的处理。 显式 ``collision_policy``(UI 选择)优先于全局 ``ON_COLLISION`` 配置。 返回 "merged" / "quarantine" / "drop",由调用方计数: - merge_max / merge_sum:同键数量不同 → 合并(取大/求和)到首个插入的 ORM 对象(self._seen_objects[unique_key]),不隔离、不新增行; - first(取首行)/ discard(丢弃全部重复):后续撞键行直接丢弃(不隔离、不入库); 首行是否入库由调用方在首次出现时按 discard 跳过决定; - 其他(默认 isolate / quarantine):隔离到待处理表。 """ policy = collision_policy or ON_COLLISION if policy in ("merge_max", "merge_sum") and unique_key in self._seen_objects: kept = self._seen_objects[unique_key] try: cur = int(record.get("quantity") or 0) except (TypeError, ValueError): cur = 0 try: base = int(getattr(kept, "quantity", 0) or 0) except (TypeError, ValueError): base = 0 merged = max(base, cur) if policy == "merge_max" else base + cur kept.quantity = merged logger.debug( f"批内撞键合并(policy={policy}): {unique_key} 数量 {base} -> {merged} (本行 {cur})" ) return "merged" if policy in ("first", "discard"): # 取首行 / 丢弃全部重复:后续撞键行直接丢弃(不隔离、不入库) logger.debug(f"批内撞键丢弃(policy={policy}): {unique_key}") return "drop" self._route_collision_to_quarantine( record, unique_key, file_path, sheet_name, import_batch_id, existing_q_keys ) return "quarantine" def _route_collision_to_quarantine( self, record: dict[str, Any], unique_key: str, source_file: str, source_sheet: str, import_batch_id: str, existing_q_hashes: set[str], ) -> None: """撞键行隔离到 sale_plans_quarantine(而非合并/覆盖),供 UI 人工处理。 幂等:若该记录内容哈希已存在于未处理隔离行中,则不重复写入。 对应需求:空白库首导保留全量(804 进主表 + 13 进待处理),不动主表 UNIQUE 约束。 """ content_hash = self._record_content_hash(record) if content_hash in existing_q_hashes: logger.debug(f"撞键行已隔离,跳过重复隔离: {unique_key}") return try: from certflow.models.quarantine_sale_plan import QuarantineSalePlan q = QuarantineSalePlan.from_record( record, meta={ "source_file": source_file, "source_sheet": source_sheet, "import_batch_id": import_batch_id, "reason": "导入撞键(unique_key 重复,待人工合并/改源后重导)", }, ) self.session.add(q) existing_q_hashes.add(content_hash) logger.info(f"撞键行已隔离到待处理表: {unique_key}") except Exception as e: logger.error(f"撞键行隔离失败(忽略,继续): {e}") def _generate_order_fields( self, record: dict[str, Any], original_idx: int, year_month_prefix: str, year_month_int: int, ) -> tuple[str, int, int]: """生成排序相关字段 Returns: tuple: (sort_group, original_order, sort_order) """ # original_order: 原始导入顺序(年月 + 原始行号) # year_month_int 可能为 None(如测试或缺失年月前缀时),缺省按 0 处理避免 None*int 崩溃。 ym = year_month_int or 0 original_order = ym * 100000 + (original_idx + 1) # sort_group: 分组标识(年月-分组前缀) # 使用 _group_prefix(如 G001)而非 _group_key(如 2026-01-01_客户A_项目X) group_prefix = record.get("_group_prefix", "G000") group_number = group_prefix[1:] if group_prefix.startswith("G") else group_prefix sort_group = f"{year_month_prefix}-{group_prefix}" # sort_order: 年月索引(3位) + 分组号(3位) + 组内序号(3位) group_seq = record.get("group_seq", f"{original_idx + 1:03d}") group_number_int = int(group_number) if group_number.isdigit() else 0 group_seq_int = int(group_seq) if group_seq.isdigit() else 0 # 计算年月索引 (2000年1月 = 1) year = 2000 + (ym // 100) month = ym % 100 month_index = (year - 2000) * 12 + month # 1-1200 sort_order = month_index * 1000000 + group_number_int * 1000 + group_seq_int return sort_group, original_order, sort_order @staticmethod def _resolve_production_status( record: dict[str, Any], ) -> tuple[str, str | None, bool]: """从记录中解析 production_status、execution_date 和 execution_inferred 优先保留已有的 production_status(非默认值时),否则从 execution_status 推断。 Args: record: 原始数据行 Returns: (production_status, execution_date, execution_inferred) 三元组 """ raw_execution_status = record.get("execution_status", "") existing_production_status = record.get("production_status", "") execution_date: str | None = None inferred_status = "待生产" if raw_execution_status: execution_date = StatusInference.extract_date(raw_execution_status) inferred_status = StatusInference.infer(raw_execution_status) if existing_production_status and existing_production_status != "待生产": return existing_production_status, execution_date, False return inferred_status, execution_date, bool(raw_execution_status) def _create_sale_plan( self, record: dict[str, Any], unique_key: str, original_order: int, sort_order: int, sort_group: str, file_path: str, sheet_name: str, import_batch_id: str, import_date: datetime, ) -> SalePlan: """创建 SalePlan 实例""" def _safe_get(val: str) -> str: """清理【空白】占位符 Args: val: 输入字符串值 Returns: 清理后的字符串,如果包含【空白】占位符则返回空字符串 """ return "" if "【空白" in val else val def _safe_float(val: Any, default: float = 0.0) -> float: """安全转换为浮点数 Args: val: 需要转换的值 default: 转换失败时的默认值 Returns: 转换后的浮点数,如果转换失败则返回默认值 """ if val is None: return default if isinstance(val, (int | float)): return float(val) try: # 清理【空白】占位符 str_val = str(val).strip() if "【空白" in str_val: return default return float(str_val) except (ValueError, TypeError): return default def _format_plan_no(record: dict[str, Any]) -> str: """格式化计划单号 规则: - 纯数字且长度 <= 4: 补零到4位,如 123 -> 0123 - 纯数字且长度 > 4: 截取右边4位,如 54321 -> 4321 - 非纯数字或无 YYMM: 返回原值(清理【空白】) Args: record: 记录数据 Returns: 格式化后的计划单号 """ plan_no_raw = str(record.get("plan_no", "")) plan_date = record.get("plan_date") yymm = extract_yymm_from_date(plan_date) if plan_no_raw and plan_no_raw.isdigit() and yymm: length = len(plan_no_raw) formatted_no = plan_no_raw.zfill(4) if length <= 4 else plan_no_raw[-4:] return f"{yymm}_{formatted_no}" return _safe_get(plan_no_raw) # 解析数量和日期 quantity = self._parse_quantity(record.get("quantity")) plan_date = normalize_plan_date(record.get("plan_date", "")) planned_delivery_date = normalize_plan_date(record.get("planned_delivery_date", "")) contract_delivery_date = normalize_plan_date(record.get("contract_delivery_date", "")) # 状态推断:从 execution_status 推断 production_status raw_execution_status = record.get("execution_status", "") production_status, execution_date, execution_inferred = self._resolve_production_status( record ) # 双字段映射(蓝图 §4.2.1):按 import_mode 决定 cert_* 合格证字段写入 _dual_pairs = DUAL_FIELD_MAPPING.get("pairs", []) or [] _import_mode = (DUAL_FIELD_MAPPING.get("import_mode", "lazy") or "lazy").strip().lower() _cert_fields = resolve_dual_fields(record, _import_mode, _dual_pairs) # #30 P0 颜色语义显式化:由已捕获的颜色字典 + 执列文字派生 供货类型/发货状态 supply, shipping_status = self._resolve_color_statuses(record) from certflow.services.cert_numbering_policy import derive_needs_numbering readable_record = dict(record) readable_record["product_spec"] = record.get( "product_spec_norm", record.get("product_spec", "") ) _raw_len = len(str(record.get("tech_requirements", "") or "")) _cmt = record.get("_comments") _cmt_len = len(_cmt) if isinstance(_cmt, list) else ("?" if _cmt else 0) logger.debug( f"新建 SalePlan | 捕获 technical_requirement_raw 长度={_raw_len} " f"change_note 批注条数={_cmt_len}" ) return SalePlan( unique_key=unique_key, unique_key_readable=IDGenerator.generate_unique_key_readable(readable_record), original_order=original_order, sort_order=sort_order, sort_group=sort_group, source_file=file_path, source_sheet=sheet_name, import_batch_id=import_batch_id, import_date=import_date, change_signature=IDGenerator.generate_change_signature(readable_record), source_row_index=int(record.get("source_row_index", 0) or 0), contract_no=_safe_get(str(record.get("contract_no", ""))), sales_order_no=_safe_get(str(record.get("sales_order_no", ""))), production_order_no=_safe_get(str(record.get("production_order_no", ""))), plan_no=_format_plan_no(record), plan_date=plan_date, customer=_safe_get(str(record.get("customer", ""))), project_name=_safe_get(str(record.get("project_name", ""))), project_unit=_safe_get(str(record.get("project_unit", ""))), product_name=_safe_get(str(record.get("product_name", ""))), product_model=_safe_get(str(record.get("product_model", ""))), product_spec=_safe_get(str(record.get("product_spec", ""))), spec_norm=_safe_get( str(record.get("product_spec_norm", record.get("product_spec", ""))) ), # 双字段映射(蓝图 §4.2.1):cert_* 由 import_mode 决定(lazy 默认留空) cert_product_name=_cert_fields.get("cert_product_name", ""), cert_product_model=_cert_fields.get("cert_product_model", ""), cert_product_spec=_cert_fields.get("cert_product_spec", ""), # #30 P0 DN/PN 字典 DB 化:由 product_model 经 PNService 解析得到的公称压力/标准号 pressure_value=_safe_get(str(record.get("pressure_value", ""))), test_standard=_safe_get(str(record.get("test_standard", ""))), quantity=quantity, weight=_safe_float(record.get("weight")), category=record.get("category", ""), unit_price=_safe_float(record.get("unit_price")), total_price=_safe_float(record.get("total_price")), business_dept=record.get("business_dept", ""), affiliated_dept=record.get("affiliated_dept", ""), payment_method=record.get("payment_method", ""), supply_type=supply, tech_requirements=record.get("tech_requirements", ""), technical_requirement_raw=str(record.get("tech_requirements", "") or ""), change_note=_comments_to_text(record.get("_comments", [])), shipped_quantity=0, shipment_batches="[]", equipment_code=record.get("equipment_code", ""), product_code=record.get("product_code", ""), production_status=production_status, execution_status=raw_execution_status, execution_date=execution_date, execution_inferred=execution_inferred, sales_plan_remarks=record.get("sales_plan_remarks", ""), order_no=record.get("order_no", record.get("sales_order_no", "")), specification=record.get("specification", record.get("product_spec", "")), material=record.get("material", ""), format_status=record.get("_format_status", ""), font_colors=json.dumps(record.get("_font_colors", {}), ensure_ascii=False), background_colors=json.dumps(record.get("_background_colors", {}), ensure_ascii=False), comments=json.dumps(record.get("_comments", []), ensure_ascii=False), needs_numbering=derive_needs_numbering(record), is_hidden=record.get("_hidden", False), outsource_type=supply, shipping_status=shipping_status, # #30 P0 DN/PN 字典 DB 化:口径待复核(spec_needs_manual) 或 标准号缺失(pn_needs_manual) # → 标黄放行(非 Stop)。原 record["flag"] 也保留(兼容其他来源)。 flag=bool( record.get("flag", False) or record.get("spec_needs_manual", False) or record.get("pn_needs_manual", False) ), planned_delivery_date=planned_delivery_date, contract_delivery_date=contract_delivery_date, ) # 当已有记录缺失、新数据有值时,需要补充填充的业务字段 # 这些字段不在 MONITORED_FIELDS 中,但在跨来源去重时应当被补全 _FILLABLE_FIELDS: list[str] = [ "production_order_no", "contract_no", "sales_order_no", "supply_type", "category", "business_dept", "affiliated_dept", "payment_method", "tech_requirements", "equipment_code", "product_code", "material", "order_no", "specification", "unit_price", "total_price", "weight", ] @classmethod def _fill_missing_fields( cls, existing: SalePlan, record: dict[str, Any], ) -> list[str]: """补充已有记录中缺失的字段值(仅填充,不覆盖已有值) 当从两个不同来源(如"销售计划"和"本地计划")导入同一条记录时, 其中一个来源可能缺少某些字段。此方法用新数据中的非空值补充 已有记录中的空字段,且只填充不覆盖。 Args: existing: 已存在的销售计划记录 record: 新的记录数据 Returns: 被填充的字段名列表(用于日志记录) """ filled: list[str] = [] for field in cls._FILLABLE_FIELDS: if field not in record: continue new_raw = record.get(field) # 将 None 和空字符串等同处理 new_val = str(new_raw).strip() if new_raw is not None else "" if not new_val: continue # 跳过空白占位符 if "【空白" in new_val: continue old_val = str(getattr(existing, field, None) or "").strip() if not old_val: try: setattr(existing, field, new_raw) except (TypeError, ValueError): setattr(existing, field, new_val) filled.append(field) return filled def _detect_changes( self, existing: SalePlan, record: dict[str, Any], source_file: str, source_sheet: str, import_batch_id: str, ) -> list[SalePlanChange]: """检测记录变更 Args: existing: 已存在的销售计划记录 record: 新的记录数据 source_file: 数据来源文件 source_sheet: 数据来源工作表 import_batch_id: 导入批次ID Returns: 变更记录列表 """ from certflow.config.settings import MONITORED_FIELDS from certflow.utils.date_utils import normalize_plan_date # 需要统一格式化的日期字段列表 date_fields = ["plan_date", "planned_delivery_date", "contract_delivery_date"] changes = [] for field in MONITORED_FIELDS: old_val = str(getattr(existing, field, "") or "") # supply_type 为派生字段('执'文本+背景色经 _resolve_color_statuses 派生), # 落库存的是派生值;比对须用「派生值 vs 派生值」,否则原始'执'文本与派生值 # (生产→自产、供应商名→外购)必然不等,产生假阳性变更。 # 仅带格式导入(含颜色键)可重新派生;非带格式导入无颜色真相源,跳过比对 # (与 _create_sale_plan 中 supply_type 留空的行为一致,避免假阳性/误覆盖)。 if field == "supply_type" and ( "_background_colors" in record or "_font_colors" in record ): new_val = SaveHandler._resolve_color_statuses(record)[0] elif field == "supply_type": continue else: new_val = str(record.get(field, "") or "") # 对所有日期字段统一格式化为 YYYY-MM-DD if field in date_fields: old_val = normalize_plan_date(old_val) or "" new_val = normalize_plan_date(new_val) or "" old_normalized = self._normalize_for_comparison(old_val) new_normalized = self._normalize_for_comparison(new_val) if old_normalized != new_normalized: # supply_type 特殊保护:如果新值为空(或空白占位符),保留已有有效值不覆盖 if field == "supply_type" and not new_normalized and old_normalized: continue # 跳过:不能把已有的有效供货方式覆盖为空 # 数量合并保护:分批发货的结转行数量(余数)小于原始订单量时, # 保留较大的原始订单总量,避免被后期余数覆盖成"1台"。 if field == "quantity": old_q = self._parse_quantity(old_val) new_q = self._parse_quantity(new_val) if new_q <= old_q: continue # 余数不大于原量 → 保留原值,不记变更 effective_new_val = new_val else: effective_new_val = new_val changes.append( SalePlanChange( sale_plan_id=existing.id, unique_key=existing.unique_key, field_name=field, old_value=old_val, new_value=effective_new_val, source_file=source_file, source_sheet=source_sheet, import_batch_id=import_batch_id, comment_text=str(record.get("_comments", "")), ) ) setattr(existing, field, effective_new_val) return changes @staticmethod def _normalize_for_comparison(value: str) -> str: """规范化值用于比较 Args: value: 需要规范化的字符串值 Returns: 规范化后的字符串值 """ import re if not value: return "" if re.match(r"^【空白.*】$", value): return "" return value.strip() @staticmethod def _parse_quantity(quantity_val: Any) -> int: """解析数量字段为整数 Args: quantity_val: 需要解析的数量值 Returns: 解析后的整数数量值 """ quantity_str = str(quantity_val if quantity_val else "1") try: return ( int(float(quantity_str)) if quantity_str.replace(".", "").replace("-", "").isdigit() else 1 ) except (ValueError, TypeError): return 1 @staticmethod def _parse_plan_date(plan_date_raw: Any) -> str | None: """解析计划日期,返回 YYYY-MM-DD 格式的日期字符串 委托给全局工具方法 normalize_plan_date 处理。 """ from certflow.utils.date_utils import normalize_plan_date return normalize_plan_date(plan_date_raw)