certflow.services.certificate_number_service 源代码

# src/certflow/services/certificate_number_service.py
"""合格证自动编号服务(配置驱动)

后缀判定规则从 config.yaml → numbering.suffix_rules 读取,
可在不修改代码的情况下添加/修改后缀规则。
"""

from __future__ import annotations

import re
from datetime import date, datetime

from sqlalchemy.orm import Session

from certflow.models.sale_plan import SalePlan
from certflow.utils.logger import logger


[文档] class CertificateNumberService: """合格证自动编号服务(配置驱动) 后缀判定规则按 numbering.suffix_rules 顺序匹配,命中即返回。 编号格式:V{YYMM}{起始序号:03d}{后缀}-{结束序号:03d}{后缀} 单台时: V{YYMM}{序号:03d}{后缀} """ def __init__(self, prefix_override: str | None = None) -> None: """初始化合格证自动编号服务 Args: prefix_override: 自定义前缀覆盖(如 "2506"),None 则按当前年月生成 """ self._prefix_override = prefix_override # ============================================================ # 分隔符 / 前缀来源(配置驱动,§5.7.2-B/G) # ============================================================ @staticmethod def _cfg(key: str, default: str) -> str: try: from certflow.config.settings import cfg as _cfg return str(_cfg(f"certificate.numbering.{key}", default) or default) except Exception: return default
[文档] @classmethod def stage_separator(cls) -> str: """编号阶段分隔符(默认 '---')。""" return cls._cfg("separator_stage", "---")
[文档] @classmethod def list_separator(cls) -> str: """清单保存分隔符(默认 '-')。""" return cls._cfg("separator_list", "-")
[文档] @classmethod def prefix_source(cls) -> str: """前缀(编号年月)来源:now / plan_date / shipped_date。""" src = cls._cfg("prefix_source", "now") return src if src in ("now", "plan_date", "shipped_date") else "now"
[文档] @classmethod def military_prefix(cls) -> str: """军工三位流水模式前缀(mode:7,无 V+YYMM,如 "JG")。""" return cls._cfg("military_prefix", "JG") or "JG"
@staticmethod def _is_military_mode(record: SalePlan | None) -> bool: """记录是否走军工三位流水模式(number_model==7)。""" if record is None: return False try: from certflow.services.cert_numbering_policy import get_number_model return get_number_model(record) == 7 except Exception: return False
[文档] @classmethod def to_list_format(cls, code: str) -> str: """把编号阶段格式(含 '---')转换为清单保存格式('-')。 仅替换首尾范围串中的 stage 分隔符为 list 分隔符,不影响前缀内的 '-' (前缀形如 ``V2607`` 不含连字符);多段区间也一并处理。 """ stage = cls.stage_separator() if not stage or stage == cls.list_separator(): return code # 把 stage 分隔符(连续连字符)替换为 list 分隔符 import re as _re return _re.sub(_re.escape(stage), cls.list_separator(), code)
# ============================================================ # 公共方法 # ============================================================
[文档] def generate_numbers(self, session: Session, sale_plan_ids: list[int]) -> int: """为指定销售计划记录批量生成产品编号 仅对 product_code 为空或以「【空白」开头的记录编号,按 sort_group/sort_order 顺序分配连续流水号,并写入 SalePlan。 Args: session: SQLAlchemy 数据库会话对象 sale_plan_ids: 待编号的 SalePlan.id 列表 Returns: int: 成功编号并更新入库的记录数 """ records = ( session.query(SalePlan) .filter( SalePlan.id.in_(sale_plan_ids), ( SalePlan.product_code.is_(None) | (SalePlan.product_code == "") | (SalePlan.product_code.like("【空白%")) ), # H(维度16):跳过不需要编号的记录(needs_numbering=False); # 历史 NULL 视为需要编号,兼容旧数据(避免全量跳过)。 (SalePlan.needs_numbering.is_(True) | (SalePlan.needs_numbering.is_(None))), ) .order_by(SalePlan.sort_group, SalePlan.sort_order) .all() ) if not records: return 0 prefix = self._get_prefix(records[0]) updated = 0 for record in records: suffix = self._get_suffix(record) quantity = record.quantity or 1 # 军工三位流水模式(mode:7,无 V+YYMM 前缀,逐台独立三位流水) if self._is_military_mode(record): mil_prefix = self._military_prefix() # 军工计数器按军工前缀独立归组(不与 V+YYMM 混),三位流水连续 start_no = self._allocate_serial(session, mil_prefix, quantity) end_no = start_no + quantity - 1 record.product_code = ( f"{mil_prefix}{start_no:03d}{suffix}-{mil_prefix}{end_no:03d}{suffix}" ) logger.debug( f"自动编号(军工) | {record.product_code} | 订单={record.sales_order_no}" ) else: start_no = self._allocate_serial(session, prefix, quantity) end_no = start_no + quantity - 1 record.product_code = self._format_code( prefix, start_no, end_no, suffix, separator=self.stage_separator() ) logger.debug( f"自动编号 | {record.product_code} | " f"型号={record.product_model} | " f"后缀={suffix or '(空)'} | " f"订单={record.sales_order_no}" ) updated += 1 session.commit() logger.info(f"自动编号完成 | 数量={updated} | 前缀={prefix}") return updated
[文档] def generate_batch( self, session: Session, sale_plan_id: int, batch_qty: int, ym: str | None = None, start_override: int | None = None, ) -> dict: """按「本批台数」为单个销售计划生成一批产品编号(批次 0.5 / G4) 合同总数从 ``SalePlan.quantity`` 读取,用户只传 ``batch_qty``; 用 ``shipped_quantity``(已发累计)判定剩余与最后一批,从根上消除 ``(4,1)`` 类参数歧义(见 OPERATOR_WORKFLOW §11.4.5)。生成的范围串 写入 ``SalePlan.product_code``(本批区间),并累加 ``shipped_quantity``; 发满则标记 ``shipping_status='已发货'``(与 B0-3 ``record_shipment`` 一致)。 编号前缀默认取当前月(``V+YYMM``);传 ``ym``(如 ``"2604"``)可生成 **历史年月**批次,流水从该月已用最大流水续号(``peek_next_serial``)。 ``start_override``(特殊插入)强制起始流水为指定值,并把计数器压到该起点。 Args: session: SQLAlchemy 会话 sale_plan_id: 销售计划 ID batch_qty: 本批发货台数(必须 > 0 且 <= 剩余可发) ym: 目标编号年月(4 位 YYMM,如 ``2604``);None 取当前月 start_override: 起始流水手填覆盖(特殊插入);None 表示自动续号 Returns: dict: 含 product_code / seq_start / seq_end / prefix / ym / shipped_quantity / remaining / fully_shipped Raises: ValueError: 计划不存在 / batch_qty 非法 / 超过剩余可发 """ plan = session.get(SalePlan, sale_plan_id) if plan is None: raise ValueError(f"SalePlan(id={sale_plan_id}) 不存在") total = plan.quantity or 0 shipped = plan.shipped_quantity or 0 remaining = total - shipped if batch_qty <= 0: raise ValueError(f"本批台数必须 > 0,收到 {batch_qty}") if batch_qty > remaining: raise ValueError( f"本批台数 {batch_qty} 超过剩余可发 {remaining}(合同 {total} / 已发 {shipped})" ) # 编号前缀:默认当前月(或 prefix_source 来源);ym 覆盖为目标历史年月(如 2604 → V2604) prefix = f"V{ym}" if ym else self._get_prefix(plan) # 起始流水手填覆盖(特殊插入):先把计数器压到 start-1,再分配即得指定起点 if start_override is not None: self._seed_counter_to(session, prefix, int(start_override) - 1) start_no = self._allocate_serial(session, prefix, batch_qty) end_no = start_no + batch_qty - 1 # 无论自动/覆盖,写后把计数器推进到「已用最大流水」与本次结束号的较大者, # 避免「起始流水手填覆盖」低于现有最大值时计数器回退,导致后续自动续号碰撞。 safe_end = max(self._max_serial_across(session, prefix), end_no) self._bump_serial(session, prefix, safe_end) suffix = self._get_suffix(plan) range_str = self._format_code( prefix, start_no, end_no, suffix, separator=self.stage_separator() ) plan.product_code = range_str plan.shipped_quantity = shipped + batch_qty fully_shipped = False if plan.shipped_quantity >= total: plan.shipped_quantity = total plan.shipping_status = "已发货" fully_shipped = True plan.fully_shipped = fully_shipped session.commit() logger.info( f"批次编号完成 | id={plan.id} | {range_str} | " f"已发={plan.shipped_quantity}/{total} | 满发={fully_shipped}" ) return { "sale_plan_id": sale_plan_id, "product_code": range_str, "prefix": prefix, "ym": prefix[1:] if prefix.startswith("V") else prefix, "seq_start": start_no, "seq_end": end_no, "shipped_quantity": plan.shipped_quantity, "remaining": max(0, total - plan.shipped_quantity), "fully_shipped": fully_shipped, }
[文档] def get_prefix(self) -> str: """获取当前编号前缀(V + YYYYMM 或覆盖前缀) Returns: str: 编号前缀字符串 """ return self._get_prefix()
# ============================================================ # 前缀生成 # ============================================================ def _get_prefix(self, record: SalePlan | None = None) -> str: """计算编号前缀(V + 覆盖值或来源年月 YYMM) 来源由 ``certificate.numbering.prefix_source`` 决定(§5.7.2-G): - ``now``(默认):实时年月; - ``plan_date`` / ``shipped_date``:取记录对应日期的年月(贴近 VBA 「生产成功并发货时」语义);记录缺失或日期为空时回退 ``now``。 ``prefix_override`` 优先级最高(手动覆盖,如历史年月批次)。 Args: record: 取日期来源的 SalePlan(plan_date/shipped_date);批量同组 通常同月,取首条即可。 Returns: str: 编号前缀 """ if self._prefix_override: return "V" + self._prefix_override src = self.prefix_source() if src in ("plan_date", "shipped_date") and record is not None: date_val = getattr(record, src, None) or getattr(record, "plan_date", None) if date_val: try: ym = date_val[:4] + date_val[5:7] if "-" in date_val else date_val[:6] if len(ym) == 6 and ym.isdigit(): return "V" + ym[2:] except Exception: pass return "V" + datetime.now().strftime("%y%m") # ============================================================ # 后缀判定(配置驱动) # ============================================================ # SalePlan 属性名 → 配置中 field 中文名的映射 # 仅保留 SalePlan 真实存在的列(注意:后缀规则作用于 SalePlan, # 不能用 Certificate.supplier / 不存在的 color_mark)。 _FIELD_CN_TO_ATTR = { "生产令号": "production_order_no", "种类": "category", "供货类型": "supply_type", "产品名称": "product_name", } def _get_field_value(self, record: SalePlan, field_name: str) -> str: """获取 SalePlan 对象中指定字段的值 Args: record: SalePlan ORM 对象 field_name: 配置中的中文字段名(映射到 ORM 属性) Returns: str: 字段值的去空格字符串,缺失返回空串 """ attr = self._FIELD_CN_TO_ATTR.get(field_name, field_name) value = getattr(record, attr, "") or "" return str(value).strip() def _get_suffix(self, record: SalePlan) -> str: """判定编号后缀(唯一真相源:config.numbering.suffix_rules) 规则按配置顺序匹配,命中即返回后缀。配置段为空或加载失败时返回空串 (不再回退硬编码规则,保证「配置驱动」语义不被旁路)。 Args: record: SalePlan ORM 对象 Returns: str: 匹配到的后缀字符串,无匹配返回空串 """ try: from certflow.config.settings import cfg except ImportError: logger.warning("无法加载配置,后缀置空(不再使用硬编码回退)") return "" rules = cfg("numbering.suffix_rules", []) if not rules: logger.warning("配置 numbering.suffix_rules 为空,后缀置空") return "" for rule in rules: field = rule.get("field", "") keywords = rule.get("keywords", []) exclude = rule.get("exclude", []) suffix = rule.get("suffix", "") field_value = self._get_field_value(record, field) if field else "" # 无 field 但有 keywords:遍历所有已知字段 if not field and keywords: all_values = [ self._get_field_value(record, f) for f in self._FIELD_CN_TO_ATTR.values() ] field_value = " ".join(v for v in all_values if v) if not field_value: continue # exclude 检查 if exclude and field_value in exclude: continue # keywords 匹配 if keywords and any(kw in field_value for kw in keywords): logger.debug(f"后缀匹配: {suffix} (字段={field}, 值={field_value[:30]})") return suffix return "" # ============================================================ # 序列号管理 # ============================================================ def _get_max_serial_legacy(self, session: Session, prefix: str) -> int: """播种用:解析同前缀 SalePlan.product_code 的最大结束流水(一次性)。 仅看 SalePlan(产品编号源),不解析 Certificate.certificate_no (其形如 ``CERT-YYYYMMDD-NNNN``,与产品编号 ``V...`` 是两个体系, 原分支恒不命中,见 BUG-004)。遍历全部同前缀码取 ``max``,规避 ``order_by(desc).first()`` 在 ≥1000 时字符串序≠数值序的 BUG-001 风险。 Args: session: SQLAlchemy 数据库会话对象 prefix: 编号前缀 Returns: int: 最大结束流水号,无记录返回 0 """ max_serial = 0 rows = ( session.query(SalePlan.product_code) .filter(SalePlan.product_code.like(f"{prefix}%")) .all() ) for (code,) in rows: if code: max_serial = max(max_serial, self._extract_end_serial(code)) return max_serial def _allocate_serial(self, session: Session, prefix: str, count: int) -> int: """从 ``AutoNumberCounter`` 原子分配 ``count`` 个连续流水号,返回起始流水号。 设计(BUG-005 / BUG-001 / BUG-004 统一修复): - **单一连续计数器**,按前缀(``V+YYMM``)分组 → 每月自动归零 (新前缀 = 新计数器行 = 从 1 起),无需 numbering_scheme 枚举。 - **不做后缀/类别隔离**:B 类等上千编号与日常共用同一流水,后续继续即可, 流水保持连续(用户明确:不引入内部/外部双字段,避免流水断续)。 - **异构/手动码不进计数器**:播种时仅按同前缀规范码取最大流水, ``A008`` 等乱序自由文本(不以 ``V+YYMM`` 开头)不命中前缀 → 不污染。 - 计数器行缺失时由既有 ``SalePlan.product_code`` 一次性播种,之后以计数器为准。 Args: session: SQLAlchemy 会话 prefix: 编号前缀(如 ``V2607``) count: 需连续分配的流水个数 Returns: int: 起始流水号(下一个可用号) """ from certflow.models.auto_number import AutoNumberCounter counter = ( session.query(AutoNumberCounter) .filter( AutoNumberCounter.rule_code == "product_code", AutoNumberCounter.group_key == prefix, ) .first() ) if counter is None: # 一次性播种:取同前缀既有 product_code 的最大流水(乱序码不命中前缀→排除) seed = self._get_max_serial_legacy(session, prefix) counter = AutoNumberCounter( rule_code="product_code", group_key=prefix, current_serial=seed, reset_date=datetime.now().strftime("%Y%m"), ) session.add(counter) session.flush() start = counter.current_serial + 1 counter.current_serial = start + count - 1 return start def _bump_serial(self, session: Session, prefix: str, end_serial: int) -> None: """手动编号后,确保计数器不低于 ``end_serial``。 避免「先自动编号(已建计数器行)→ 再手动编号到更大号 → 再自动编号」 时计数器回退导致流水碰撞。计数器行缺失时由既有 product_code 播种并与 ``end_serial`` 取大。 Args: session: SQLAlchemy 会话 prefix: 编号前缀(如 ``V2506``) end_serial: 手动编号覆盖到的最大流水号 """ from certflow.models.auto_number import AutoNumberCounter counter = ( session.query(AutoNumberCounter) .filter( AutoNumberCounter.rule_code == "product_code", AutoNumberCounter.group_key == prefix, ) .first() ) if counter is None: seed = self._get_max_serial_legacy(session, prefix) counter = AutoNumberCounter( rule_code="product_code", group_key=prefix, current_serial=max(seed, end_serial), reset_date=datetime.now().strftime("%Y%m"), ) session.add(counter) elif end_serial > counter.current_serial: counter.current_serial = end_serial session.flush() def _extract_end_serial(self, code: str, prefix: str | None = None) -> int: """从编号字符串中提取结束流水号 Args: code: 编号字符串(支持 "前缀-结束序号" 或 "前缀+序号" 形式) prefix: 显式前缀(用于历史前缀提取,避免误用当前月前缀)。 为 None 时回退 ``_get_prefix()``(保持既有行为)。 Returns: int: 提取到的结束流水号,未匹配返回 0 """ match = re.search(r"-(\d+)", code) if match: return int(match.group(1)) pfx = prefix or self._get_prefix() pattern = re.escape(pfx) + r"(\d+)" match = re.search(pattern, code) if match: return int(match.group(1)) return 0 def _max_serial_across(self, session: Session, prefix: str) -> int: """跨 SalePlan.product_code 与 Certificate.product_code_range 取该前缀最大结束流水。 编号可能落在销售计划(自动/导入编号)或合格证(打印视图手动编)任一侧, 续号必须综合两表,否则手动编号不回灌会导致后续自动续号遗漏。 Args: session: SQLAlchemy 会话 prefix: 编号前缀(如 ``V2604``) Returns: int: 该前缀最大结束流水号,无记录返回 0 """ from certflow.models.certificate import Certificate max_serial = 0 sp_rows = ( session.query(SalePlan.product_code) .filter(SalePlan.product_code.like(f"{prefix}%")) .all() ) for (code,) in sp_rows: if code: max_serial = max(max_serial, self._extract_end_serial(code, prefix)) cert_rows = ( session.query(Certificate.product_code_range) .filter(Certificate.product_code_range.like(f"{prefix}%")) .all() ) for (code,) in cert_rows: if code: max_serial = max(max_serial, self._extract_end_serial(code, prefix)) return max_serial
[文档] def peek_next_serial(self, session: Session, prefix: str) -> int: """只读预瞄某前缀下一个可用流水号(不消耗、不建计数器)。 优先读 ``AutoNumberCounter``(已分配则 ``current_serial+1``); 计数器缺失时回退跨表最大流水 + 1。供 UI 自动续号与「下一号」预览。 Args: session: SQLAlchemy 会话 prefix: 编号前缀(如 ``V2604``) Returns: int: 下一可用流水号(从 1 起) """ from certflow.models.auto_number import AutoNumberCounter counter = ( session.query(AutoNumberCounter) .filter( AutoNumberCounter.rule_code == "product_code", AutoNumberCounter.group_key == prefix, ) .first() ) if counter is not None: return counter.current_serial + 1 return self._max_serial_across(session, prefix) + 1
def _seed_counter_to(self, session: Session, prefix: str, serial: int) -> None: """将某前缀计数器行的 ``current_serial`` 强制设为 ``serial``(缺失则建行)。 用于「起始流水手填覆盖」:先把计数器压到 ``start-1``,随后 ``_allocate_serial`` 即返回用户指定的起始号,且计数器随分配推进,保证后续自动续号不回退。 Args: session: SQLAlchemy 会话 prefix: 编号前缀(如 ``V2604``) serial: 目标 current_serial 值 """ from certflow.models.auto_number import AutoNumberCounter counter = ( session.query(AutoNumberCounter) .filter( AutoNumberCounter.rule_code == "product_code", AutoNumberCounter.group_key == prefix, ) .first() ) if counter is None: counter = AutoNumberCounter( rule_code="product_code", group_key=prefix, current_serial=serial, reset_date=datetime.now().strftime("%Y%m"), ) session.add(counter) else: counter.current_serial = serial session.flush() # ============================================================ # 编号格式化 # ============================================================ @staticmethod def _format_serial(serial: int) -> str: """格式化流水号(对齐VBA: <1000→3位, ≥1000→4位)""" if serial < 1000: return f"{serial:03d}" return f"{serial:04d}" def _format_code( self, prefix: str, start: int, end: int, suffix: str, separator: str | None = None ) -> str: """拼接完整编号字符串 Args: prefix: 编号前缀 start: 起始流水号 end: 结束流水号 suffix: 编号后缀 separator: 范围串分隔符(默认取 stage 分隔符 "---");清单保存用 ``to_list_format`` 转 list 分隔符 "-"(§5.7.2-B)。 Returns: str: 单台为 "前缀+序号+后缀",多台为 "前缀+起始+分隔符+结束+后缀" """ if separator is None: separator = self.stage_separator() start_str = self._format_serial(start) if start == end: return f"{prefix}{start_str}{suffix}" end_str = self._format_serial(end) return f"{prefix}{start_str}{suffix}{separator}{end_str}{suffix}" # ============================================================ # 军工三位流水模式(对齐VBA dd.vb:468-512) # ============================================================
[文档] @staticmethod def generate_military_numbers( prefix: str, start_number: int, quantity: int, suffix: str = "", ) -> list[str]: """军工三位流水模式:前缀 + Format(i, "000") + 后缀 对齐 VBA dd.vb 第468-512行「军工 三位流水无V年月前缀」模式。 不包含 V+年月前缀,完全由用户手动指定。 Args: prefix: 用户指定的前缀字符串(如 "JG") start_number: 起始编号 quantity: 打印份数 suffix: 编号后缀 Returns: list[str]: 编号列表,如 ["JG001A", "JG002A", ...] Examples: >>> CertificateNumberService.generate_military_numbers("JG", 1, 3, "A") ['JG001A', 'JG002A', 'JG003A'] """ return [f"{prefix}{i:03d}{suffix}" for i in range(start_number, start_number + quantity)]
# ============================================================ # 报告流水号分配(与合格证编号同源的 AutoNumberCounter 计数基础设施) # ============================================================
[文档] def allocate_report_serials(self, session: Session, make_date, count: int) -> list[int]: """为报告分配按「制作日期」归组的连续流水号(一页一个,返回 count 个)。 复用与合格证产品编号(``V+YYMM``)同源的 ``AutoNumberCounter`` 计数器, rule_code 固定为 ``report_serial``,group_key 为制作日期 ``YYMMDD``; 按日归组、每日从 1 起连续递增,跨页/跨单整体续号,不依赖 JSON 文件或 ``report_serial_seq`` 表。 编号渲染交由调用方(如 ``report_fill_service``)拼成 ``C/1 编号:YYMMDDNNN`` 形态,本方法只负责分配底层连续流水。 Args: session: SQLAlchemy 会话 make_date: 报告制作日期(``date`` 或 ISO 字符串);流水按此日期归组。 count: 需分配的流水号个数(= 报告页数,每页一个编号)。 Returns: list[int]: 长度 = count 的连续流水号(从当前计数器 +1 起); ``count<=0`` 时返回空列表。 """ from certflow.models.auto_number import AutoNumberCounter if count <= 0: return [] if isinstance(make_date, date): key = make_date.strftime("%y%m%d") reset = make_date.strftime("%Y%m%d") else: s = str(make_date)[:10] key = s[2:].replace("-", "") if len(s) >= 10 else s reset = s.replace("-", "") counter = ( session.query(AutoNumberCounter) .filter( AutoNumberCounter.rule_code == "report_serial", AutoNumberCounter.group_key == key, ) .first() ) if counter is None: counter = AutoNumberCounter( rule_code="report_serial", group_key=key, current_serial=0, reset_date=reset, ) session.add(counter) session.flush() start = counter.current_serial + 1 counter.current_serial = start + count - 1 session.commit() return list(range(start, start + count))