certflow.services.cert_numbering 源代码

# src/certflow/services/cert_numbering.py
"""合格证编号与数据生成服务

负责编号模式(自动/手动/复制)和 Certificate 表写入。
"""

from __future__ import annotations

from datetime import datetime

from sqlalchemy.orm import Session

from certflow.models.certificate import Certificate
from certflow.models.sale_plan import SalePlan
from certflow.services.cert_numbering_policy import get_sn_kks_position
from certflow.services.dn_service import DNService
from certflow.services.pn_service import PNService
from certflow.utils.logger import logger


def _default_template_type_for(supply_type: str | None) -> str:
    """由供货类型推导默认模板语言族(A4)

    自产 → 全中文;外购/采购/进口 → 中英文;含「俄」→ 俄英文;其余 → 全中文。
    与 vba_mapping.get_style_code 的语言族取值保持一致。
    """
    st = (supply_type or "").strip()
    if "俄" in st:
        return "俄英文"
    if any(k in st for k in ("外购", "采购", "进口")):
        return "中英文"
    return "全中文"


[文档] class CertNumberingService: """合格证编号与 Certificate 写入服务 提供三种编号模式 + Certificate 表写入 + Access/CSV 导出。 **编号模式**: - auto_number: 自动编号(正则解析 production_order_no / supply_type,最常用) - manual_number: 手动指定前缀和起始序号 - copy_number: 复制已有编号(单件直接复制) **持久化**: - create_certificates: 将已编号的 SalePlan 写入 Certificate 表 - export_to_access: 导出到 Access 数据库 - export_sign_tb_csv: 导出 CSV """ def __init__(self, session: Session) -> None: self.session = session # ============================================================ # 编号模式 # ============================================================
[文档] def auto_number(self, ids: list[int], prefix_override: str | None = None) -> int: """自动编号(正则解析 production_order_no / supply_type,最常用) 根据生产令号和供货类型自动判定后缀(L/B/G/Y/XL), 按 sort_group 排序后依次编号,同一批次序列号连续递增。 Args: ids: 需要编号的 SalePlan ID 列表 prefix_override: 手动指定的年月前缀(格式 YYMM),为 None 则使用当前年月 Returns: int: 成功编号的记录数 """ from certflow.services.certificate_number_service import CertificateNumberService service = CertificateNumberService(prefix_override) return service.generate_numbers(self.session, ids)
[文档] def manual_number( self, ids: list[int], prefix: str, start: int, quantity: int, suffix: str = "" ) -> int: """手动编号 手动指定编号前缀、起始序号和数量后缀,为选中的记录生成编号。 只对尚未编号的记录(product_code 为空)进行操作。 Args: ids: 需要编号的 SalePlan ID 列表 prefix: 编号前缀(如 "V2506") start: 起始序列号 quantity: 每台设备的数量 suffix: 编号后缀(如 "Y"、"L"),默认为空 Returns: int: 成功编号的记录数 """ records = ( self.session.query(SalePlan) .filter( SalePlan.id.in_(ids), (SalePlan.product_code.is_(None)) | (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) .all() ) serial = start last_end = start - 1 # 编号阶段分隔符(默认 "---",§5.7.2-B);与自动编号一致 from certflow.services.cert_numbering_policy import get_number_model from certflow.services.certificate_number_service import CertificateNumberService sep = CertificateNumberService.stage_separator() for record in records: # 军工三位流水(mode:7):无 V+YYMM,前缀已是军工前缀(如 JG), # 用清单分隔符 "-" 连接逐台独立码(对齐 VBA getKKS 单 '-' 形态) is_military = get_number_model(record) == 7 mil_sep = CertificateNumberService.list_separator() if is_military else sep end = serial + (record.quantity or quantity) - 1 if serial == end: record.product_code = f"{prefix}{serial:03d}{suffix}" else: record.product_code = f"{prefix}{serial:03d}{suffix}{mil_sep}{end:03d}{suffix}" serial = end + 1 last_end = end # 手动编号后同步计数器,避免后续自动编号回退/碰撞(BUG-005 计数器为唯一权威源) if records: from certflow.services.certificate_number_service import CertificateNumberService CertificateNumberService()._bump_serial(self.session, prefix, last_end) self.session.commit() logger.info(f"手动编号完成 | 数量={len(records)}") return len(records)
[文档] def copy_number(self, ids: list[int], source_code: str) -> int: """复制编号(单件直接复制) 将指定编号直接复制给选中的所有记录,适用于同一产品重复使用的场景。 Args: ids: 需要编号的 SalePlan ID 列表 source_code: 要复制的编号字符串 Returns: int: 成功编号的记录数 """ records = self.session.query(SalePlan).filter(SalePlan.id.in_(ids)).all() for record in records: record.product_code = source_code self.session.commit() logger.info(f"复制编号完成 | 数量={len(records)} | 编号={source_code}") return len(records)
# ============================================================ # 生成 Certificate 并写入 Access # ============================================================
[文档] def create_certificates( self, ids: list[int], printer_name: str = "", print_status: str = "已打印" ) -> int: """将已编号的记录写入 Certificate 表 从 SalePlan 表中读取已编号记录,创建对应的 Certificate 记录。 会自动合并 cert_product_name/model/spec 字段(优先使用 cert\\_ 前缀字段,回退到原始字段)。 同时从产品型号解析公称压力(PN)信息,并标准化口径(DN)。 当 ``certificate.numbering.timing == "on_import"`` 时,先对未编号记录 自动编号(步骤 6.1),使写入 Certificate 即带编号。``certificate_no`` 由组A 服务 ``generate_certificate_no`` 生成合格证号 ``CERT-YYYYMMDD-NNNN``(逐台一个 id,G1);产品编号区间串逐台拆填写入 ``Certificate.product_code_range``(G2)。默认 ``on_demand`` 保持现状 (仅收已编号记录)。 合格证按「实际发货台数」逐台生成(BUG-006:合格证数量 = 发货台数总和, 而非销售计划记录数)。一个 SalePlan 的 ``product_code`` 区间串会展开为 多台 Certificate,每台独立 ``product_code_range`` / ``certificate_no`` / 审计字段(G5)。 Args: ids: 已编号的 SalePlan ID 列表 printer_name: 打印机名称,默认为空 print_status: 打印状态,默认"已打印",查询页自动编号时传"待打印" Returns: int: 成功创建的 Certificate 记录数 """ from certflow.services.cert_numbering_policy import ( get_issue_date_source, get_numbering_timing, parse_military_code_meta, parse_product_code_meta, ) # 步骤 6.1:on_import 时机——先给未编号记录自动编号 if get_numbering_timing() == "on_import": self.auto_number(ids) records = ( self.session.query(SalePlan) .filter( SalePlan.id.in_(ids), SalePlan.product_code.is_not(None), SalePlan.product_code != "", SalePlan.product_code.not_like("【空白%"), ) .all() ) # === 组A:合格证编号 CERT-YYYYMMDD-NNNN(G1,与文档 §11.4.2 一致)=== # certificate_no 为「逐台一个」的唯一合格证号(格式 CERT-日期-序号), # 与组B 产品编号区间串 (Certificate.product_code_range,G2 填充) 解耦; # 序号按当天已建数 base_seq 递增,保证全局唯一。产品编号单码只存于 # product_code_range(逐台),绝不写入 certificate_no(避免 UNIQUE 冲突)。 from certflow.services.certificate_number_service import CertificateNumberService from certflow.services.certificate_service import CertificateService cert_service = CertificateService(self.session) base_seq = cert_service.count_certificates_created_today() pn_service = PNService(self.session) dn_service = DNService(self.session) # === G5:预统计每个 SalePlan 已存在的 Certificate 数量与累计台数 === # 供 batch_seq(第几批) / cumulative_quantity(累计台数) 计算(审计用)。 from sqlalchemy import func existing_rows = ( self.session.query( Certificate.sale_plan_id, func.count(Certificate.id), func.coalesce(func.sum(Certificate.quantity), 0), func.coalesce(func.max(Certificate.batch_seq), 0), ) .filter(Certificate.sale_plan_id.in_([r.id for r in records])) .group_by(Certificate.sale_plan_id) .all() ) existing_map = {row[0]: (row[1], int(row[2]), int(row[3])) for row in existing_rows} # === 逐台展开:一个 SalePlan 的 product_code 区间串 → 多台 Certificate === # 合格证按「实际发货台数」逐台生成(BUG-006:合格证数量 = 发货台数总和, # 而非销售计划记录数)。每台独立 product_code_range / certificate_no / 审计字段。 created_units = 0 for sp in records: # 解析产品编号区间串 → 逐台完整编码列表 # 军工三位流水(mode:7,无 V+YYMM):走军工专用解析,逐台独立码 mil_meta = parse_military_code_meta(sp.product_code) meta = mil_meta or parse_product_code_meta(sp.product_code) is_military = mil_meta is not None if meta: if is_military: # 军工逐台独立码:前缀 + 三位流水 + 后缀(无年月) prefix = meta["prefix"] ym = "" # 军工无 V+YYMM 年月 suffix = meta["suffix"] unit_codes = [ f"{prefix}{seq:03d}{suffix}" for seq in range(meta["seq_start"], meta["seq_end"] + 1) ] else: prefix, ym, suffix = meta["prefix"], meta["ym"], meta["suffix"] # 单台码用前缀+yy+mm+流水 (无分隔符),与 _format_code 单台形态一致 unit_codes = [ f"{prefix}{ym}{CertificateNumberService._format_serial(seq)}{suffix}" for seq in range(meta["seq_start"], meta["seq_end"] + 1) ] else: # 非标准编号(单台/特殊码):整体作为一台的产品编号 unit_codes = [sp.product_code] # 出厂日期年月来源(配置驱动):默认同步编号年月,可选当前年月 # 军工三位流水(mode:7)无 V+YYMM → meta 无 "ym",落当前年月 pc_ym = meta.get("ym", "") if meta else "" if get_issue_date_source() == "current_ym" or not pc_ym: issue_date = datetime.now().strftime("%Y-%m-%d") else: # pc_ym 形如 "2607" → 取该年月首日,保证与编号年月一致 issue_date = f"20{pc_ym[:2]}-{pc_ym[2:4]}-01" # G5:本 SalePlan 既有批次数 / 累计台数(本次调用 = 该计划的一批发货) prev_count, prev_cum, prev_batch = existing_map.get(sp.id, (0, 0, 0)) local_batch = prev_batch + 1 created_units += self._expand_plan_to_certificates( sp=sp, unit_codes=unit_codes, meta=meta, prefix=prefix, ym=ym, issue_date=issue_date, local_batch=local_batch, prev_cum=prev_cum, base_seq=base_seq, created_so_far=created_units, print_status=print_status, printer_name=printer_name, pn_service=pn_service, dn_service=dn_service, ) self.session.commit() logger.info(f"写入 Certificate 完成 | 数量={created_units}") return created_units
def _expand_plan_to_certificates( self, sp: SalePlan, unit_codes: list[str], meta: dict | None, prefix: str, ym: str, issue_date: str, local_batch: int, prev_cum: int, base_seq: int, created_so_far: int, print_status: str, printer_name: str, pn_service: PNService, dn_service: DNService, ) -> int: """逐台展开一个 SalePlan 的 product_code 区间串为 Certificate 记录。 抽出 create_certificates 内层「组B 逐台拆填」循环,降低主函数圈复杂度 (C901)。逻辑与原实现逐行一致,仅改变作用域;合格证号 ``certificate_no`` 由组A 服务 ``CertificateService.generate_certificate_no`` 生成唯一 ``CERT-YYYYMMDD-NNNN`` 号(G1,与文档 §11.4.2 一致),序号按 ``base_seq + created_so_far + created + 1`` 递增,每台独立;产品编号单码 仅存入 ``product_code_range``(逐台),二者解耦。 """ from certflow.config.settings import CERTIFICATE_PREFIX from certflow.services.certificate_service import CertificateService created = 0 for k, code in enumerate(unit_codes): # 幂等:同一 SalePlan 内相同 product_code_range(逐台单码)已建则跳过, # 避免重跑 create_certificates 产生重复合格证。certificate_no 为全局 # 唯一 CERT- 号,不参与去重判定。 dup = ( self.session.query(Certificate) .filter_by(sale_plan_id=sp.id, product_code_range=code) .first() ) if dup is not None: continue cert_no = CertificateService.generate_certificate_no( prefix=CERTIFICATE_PREFIX, sequence=base_seq + created_so_far + created + 1, ) # === 字典兜底(烘焙非空合格证):sp 的温度/介质/标准为空时, # 用 model_param_mappings[型号] 补齐,避免生成后 9 字段空白。 # 优先级:SalePlan 合同值 > 型号字典(仅补空,不覆盖已有值)。 model = (sp.cert_product_model or sp.product_model or "").strip() _mpm = None if model: from certflow.models.model_param_mapping import ModelParamMapping _mpm = self.session.query(ModelParamMapping).filter_by(product_model=model).first() # 注:SalePlan 无 working_temp/working_medium 列,仅 Certificate 与 # model_param_mappings 持有;此处以 getattr 容错,温度/介质的唯一来源即字典。 _wt = getattr(sp, "working_temp", None) or (_mpm.working_temp if _mpm else None) _wm = getattr(sp, "working_medium", None) or (_mpm.working_medium if _mpm else None) _ts = sp.test_standard or (_mpm.test_standard if _mpm else None) cert = Certificate( sale_plan_id=sp.id, unique_key=sp.unique_key, production_order_no=sp.production_order_no, sales_order_no=sp.sales_order_no, certificate_no=cert_no, product_name=sp.cert_product_name or sp.product_name, product_model=sp.cert_product_model or sp.product_model, product_spec=sp.cert_product_spec or sp.product_spec, plan_date=sp.plan_date, customer=sp.customer, project_name=sp.project_name, supplier=getattr(sp, "cert_supplier", None) or getattr(sp, "supplier", None), test_standard=_ts, working_temp=_wt, working_medium=_wm, print_status=print_status, issue_date=issue_date, printer_name=printer_name, print_time=datetime.now(), ) # === 组B:逐台产品编号拆填(G2) === cert.product_code_range = code if meta: cert.product_code_prefix = prefix cert.product_code_ym = ym cert.product_code_seq_start = meta["seq_start"] + k cert.product_code_seq_end = meta["seq_start"] + k else: cert.product_code_prefix = "" cert.product_code_ym = "" cert.product_code_seq_start = 0 cert.product_code_seq_end = 0 cert.quantity = 1 # === 双编码位置约定(mode 5/6,对齐 VBA ar(2,8))=== # 由编号模式规则命中时一并确定(电建三双编码客户 → 产品编号/KKS 显示框互换)。 # 落库后供打印/扫描派生 SN/KKS 时读取(替代原先永远空串导致 mode 5/6 失效)。 cert.sn_kks_position = get_sn_kks_position(sp) # === G5:批次审计字段(逐台) === cert.batch_seq = local_batch cert.cumulative_quantity = prev_cum + k + 1 # === PN 解析 === model = sp.cert_product_model or sp.product_model or "" if model: pn_info = pn_service.resolve_pn(model=model) if pn_info.get("pn_value"): cert.pn_value = pn_info["pn_value"] cert.pn_unit = pn_info["pn_unit"] cert.pn_display = pn_info["pn_display"] # === DN 标准化 === spec = sp.cert_product_spec or sp.product_spec or "" if spec: dn_result = dn_service.resolve_caliber(spec, model=model) if dn_result.get("caliber_value"): cert.product_spec = dn_result["caliber_value"] # A4:supply_type → 默认模板(自产全中文 / 外购中英文 / 俄单俄英文) cert.template_type = _default_template_type_for(getattr(sp, "supply_type", None)) # 检验工号:按模板语言族烘焙默认(全中文→检5 / 其他→No5), # 打印页可覆盖(与 print.guard 的 _apply_inspector_default 同源)。 from certflow.services.cert_log_service import _default_inspector_id cert.inspector_id = _default_inspector_id(cert.template_type) self.session.add(cert) created += 1 return created
[文档] def export_to_access(self, ids: list[int], mdb_path: str | None = None) -> int: """导出到 Access 数据库(委托 CertExportService) Args: ids: Certificate.id 列表 mdb_path: 目标 .mdb 文件路径,None 则使用默认路径 Returns: int: 成功导出的记录数 """ from certflow.services.cert_export_service import CertExportService return CertExportService(self.session).export_to_access(ids, mdb_path)
[文档] def export_sign_tb_csv(self, output_path: str | None = None, limit: int = 1000) -> str | None: """导出 CSV(委托 CertExportService) Args: output_path: 输出 CSV 文件路径,None 则使用默认路径 limit: 最大导出条数 Returns: str | None: 生成的 CSV 文件路径;失败返回 None """ from certflow.services.cert_export_service import CertExportService return CertExportService(self.session).export_sign_tb_csv(output_path, limit)