certflow.services.query_service 源代码

"""查询服务模块

提供销售计划的多条件组合查询功能,支持参数化查询和分页。
支持关联 Certificate 表进行联合查询。
"""

from __future__ import annotations

from typing import Any

from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session, selectinload

from certflow.models import SalePlan
from certflow.models.certificate import Certificate  # 新增导入
from certflow.utils.logger import logger

# 快查候选项字段组合分隔符(与 query_view._on_quick_search 回填切分共用,必须一致)。
# 用「空格+竖线+空格」可读且低冲突;保留空段以保证段数恒等于字段数,
# 避免中间字段为空时按位置回填整体错位。
QUICK_SEARCH_SEP = " | "


[文档] class QueryService: """销售计划查询服务 提供灵活的多条件组合查询功能: - 支持模糊匹配、精确匹配、范围查询 - 支持多字段组合查询 - 支持分页和排序 - 支持关联 Certificate 表查询 Examples: >>> service = QueryService(session) >>> results = service.query( ... conditions={ ... "product_model": {"value": "阀门", "operator": "contains"}, ... "customer": {"value": "东方电气", "operator": "contains"}, ... "quantity": {"value": 10, "operator": "gte"} ... } ... ) """ OPERATORS = { "eq": lambda field, value: field == value, "ne": lambda field, value: field != value, "contains": lambda field, value: field.contains(value), "not_contains": lambda field, value: ~field.contains(value), "startswith": lambda field, value: field.startswith(value), "endswith": lambda field, value: field.endswith(value), "gt": lambda field, value: field > value, "gte": lambda field, value: field >= value, "lt": lambda field, value: field < value, "lte": lambda field, value: field <= value, "in": lambda field, value: field.in_(value), "is_null": lambda field, _: field.is_(None), "is_not_null": lambda field, _: field.isnot(None), } # SalePlan 表的字段列表(用于判断字段归属) _salepian_fields: set[str] | None = None # Certificate 表的字段列表 _certificate_fields: set[str] | None = None @classmethod def _get_saleplan_fields(cls) -> set[str]: if cls._salepian_fields is None: cls._salepian_fields = {col.key for col in SalePlan.__table__.columns} return cls._salepian_fields @classmethod def _get_certificate_fields(cls) -> set[str]: if cls._certificate_fields is None: cls._certificate_fields = {col.key for col in Certificate.__table__.columns} return cls._certificate_fields @property def saleplan_fields(self) -> set[str]: """SalePlan 表全部列名集合。 惰性计算并缓存(``_salepian_fields``),用于判定查询字段归属 SalePlan 表。 Returns: set[str]: SalePlan 表列名集合。 """ return self._get_saleplan_fields() @property def certificate_fields(self) -> set[str]: """Certificate 表全部列名集合。 惰性计算并缓存(``_certificate_fields``),用于判定查询字段归属 Certificate 表。 Returns: set[str]: Certificate 表列名集合。 """ return self._get_certificate_fields() # 混合属性字段映射(@hybrid_property,不是列但可用于查询过滤) HYBRID_FIELDS: dict[str, Any] = {} # 字段别名映射(YAML 用名 → 数据库实际列名) FIELD_ALIASES = { "cert_status": "print_status", "print_status": "print_status", } def __init__(self, session: Session): """初始化查询服务 Args: session: SQLAlchemy数据库会话对象 """ self.session = session if not self.HYBRID_FIELDS: self.HYBRID_FIELDS["is_ready_to_ship"] = SalePlan.is_ready_to_ship
[文档] def build_status_filter(self, status: str) -> Any: """构建状态筛选条件(同时匹配 production_status 和 execution_status) 当用户筛选标准化状态时,同时模糊匹配 execution_status 原始文本, 避免因原始文本包含状态关键词而遗漏记录。 关键词从 StatusInference 配置中获取(config.yaml production_status.query_keywords)。 Args: status: 状态值,如 "已发货"、"已完成"、"生产中" Returns: SQLAlchemy 过滤表达式 """ from certflow.utils.status_inference import StatusInference # 确保从配置加载 StatusInference.init_from_config() keyword = StatusInference.get_query_keyword(status) return or_( SalePlan.production_status == status, SalePlan.execution_status.contains(keyword), )
def _resolve_field(self, field_name: str) -> Any: """解析字段名,返回对应表的 SQLAlchemy 列属性 先通过 FIELD_ALIASES 映射,再判断属于 Certificate 还是 SalePlan 表。 最后检查混合属性字段。 Args: field_name: 字段名(支持别名) Returns: SQLAlchemy 列属性对象或混合属性,未知字段返回 None """ actual_name = self.FIELD_ALIASES.get(field_name, field_name) # 检查混合属性 if actual_name in self.HYBRID_FIELDS: return self.HYBRID_FIELDS[actual_name] if actual_name in self.certificate_fields and actual_name not in self.saleplan_fields: return getattr(Certificate, actual_name) if actual_name in self.saleplan_fields: return getattr(SalePlan, actual_name) logger.warning(f"未知字段: {field_name}") return None def _is_cert_field(self, field_name: str) -> bool: """判断字段是否属于 Certificate 表(且不是 SalePlan 共有字段) 先经过 FIELD_ALIASES 映射,再判断归属。 Args: field_name: 字段名(支持别名) Returns: bool: True 表示该字段仅属于 Certificate 表 """ actual_name = self.FIELD_ALIASES.get(field_name, field_name) return actual_name in self.certificate_fields and actual_name not in self.saleplan_fields def _build_boolean_filter( self, field: Any, operator: str, value: bool, *, is_hybrid: bool = False, ) -> Any: """为布尔字段/hybrid_property 构建过滤条件 Args: field: SQLAlchemy 列属性或 hybrid_property operator: 操作符名称 value: 布尔值 is_hybrid: 是否为 hybrid_property 类型 """ if is_hybrid: # hybrid_property 布尔字段:直接使用表达式本身作为条件, # 避免 field == True 生成 (expression) = 1 这类无效 SQL if operator in ("contains", "eq"): return field if value else ~field if operator == "ne": return ~field if value else field logger.debug(f"混合属性不支持操作符: {operator},已回退为 eq") return field if value else ~field # 普通 Column 布尔列使用精确匹配(eq/ne) if operator in ("contains", "eq"): return field == value if operator == "ne": return field != value logger.debug(f"布尔字段不支持操作符: {operator},已回退为 eq") return field == value def _build_filter(self, field: Any, operator: str, value: Any) -> Any: """根据操作符构建单个 SQLAlchemy 过滤条件 Args: field: SQLAlchemy 列属性 operator: 操作符名称 (eq/ne/contains/gt/gte/lt/lte/in/between/is_null 等) value: 过滤值 Returns: SQLAlchemy 过滤表达式,未知操作符返回 None """ from sqlalchemy import Boolean as BooleanType from sqlalchemy.ext.hybrid import hybrid_property as hybrid_property_type # 检测布尔字段:Column(Boolean) 有 .type 属性,hybrid_property 无 field_is_bool = hasattr(field, "type") and isinstance(field.type, BooleanType) field_is_hybrid = isinstance(field, hybrid_property_type) # 自动转换布尔值(字符串 "True"/"true"/"False"/"false" → bool) if isinstance(value, str) and value.lower() in ("true", "false"): value = value.lower() == "true" # 布尔/hybrid 字段处理 if isinstance(value, bool) and (field_is_bool or field_is_hybrid): return self._build_boolean_filter(field, operator, value, is_hybrid=field_is_hybrid) if operator == "contains": return field.contains(str(value)) if operator == "in": return field.in_(value) if operator == "between": if isinstance(value, list) and len(value) == 2: return and_(field >= value[0], field <= value[1]) logger.warning(f"between 操作符需要 [min, max] 列表: {value}") return None op_func = self.OPERATORS.get(operator) if not op_func: logger.warning(f"未知操作符: {operator}") return None return op_func(field, value) def _build_filters(self, conditions: dict[str, Any]) -> list[Any]: """从查询条件字典构建 SQLAlchemy 过滤表达式列表 Args: conditions: 查询条件字典 Returns: 过滤表达式列表 """ filters = [] for field_name, condition in conditions.items(): # 支持两种条件格式: # - 富格式: {"operator": "contains", "value": "V100"} # - 简写格式: "V100" / 123(标量即按 `eq` 精确匹配) if not isinstance(condition, dict): condition = {"operator": "eq", "value": condition} operator = condition.get("operator") or "contains" value = condition.get("value") # is_null / is_not_null 的 value 可以是 None,不能跳过 if operator not in ("is_null", "is_not_null") and ( value is None or (isinstance(value, str) and not value.strip()) ): continue field = self._resolve_field(field_name) if field is None: continue # 生产状态使用专门的过滤方法(同时匹配 execution_status) if field_name == "production_status" and operator in ("eq", "contains"): filter_expr = self.build_status_filter(value) if filter_expr is not None: filters.append(filter_expr) continue filter_expr = self._build_filter(field, operator, value) if filter_expr is not None: filters.append(filter_expr) return filters def _build_or_filters(self, or_filters: Any) -> list[Any]: """将 ``or_filters``(``[{field, operator, value}, ...]``)转为 SQLAlchemy 表达式列表。 用于快查「无分隔符」场景:在 preset 全部字段上做 ``contains`` OR 匹配, 任一字段命中即返回(避免「只匹配首字段 → 0 条」)。 """ exprs: list[Any] = [] for cond in or_filters or []: if not isinstance(cond, dict): continue field_name = cond.get("field") if not field_name: continue operator = cond.get("operator") or "contains" value = cond.get("value") if value is None or (isinstance(value, str) and not value.strip()): continue field = self._resolve_field(field_name) if field is None: continue if field_name == "production_status" and operator in ("eq", "contains"): fe = self.build_status_filter(value) else: fe = self._build_filter(field, operator, value) if fe is not None: exprs.append(fe) return exprs def _needs_join( self, conditions: dict[str, Any], or_filters: Any, order_by: str | None = None, ) -> bool: """判断查询是否需要在 SalePlan 与 Certificate 间 JOIN。""" return ( any(self._is_cert_field(fn) for fn in conditions) or any(self._is_cert_field(c.get("field")) for c in (or_filters or [])) or (order_by is not None and self._is_cert_field(order_by)) ) def _apply_conditions( self, query: Any, conditions: dict[str, Any], or_filters: Any = None ) -> Any: """在主 conditions(AND)基础上,将 or_filters 以 OR 组合并应用过滤。""" filters = self._build_filters(conditions) or_exprs = self._build_or_filters(or_filters) if or_exprs: filters.append(or_(*or_exprs)) if filters: query = query.filter(and_(*filters)) return query
[文档] def build_query(self, conditions: dict[str, Any], or_filters: Any = None) -> Any: """根据条件构建查询对象 自动识别字段所属表: - SalePlan 字段直接过滤 - Certificate 字段通过 LEFT JOIN 过滤 Args: conditions: 查询条件字典,格式: { "字段名": { "value": 查询值, "operator": "操作符" # 默认 "contains" } } or_filters: 可选 OR 条件列表 ``[{field, operator, value}, ...]``, 与主 conditions(AND)以 OR 组合,用于跨字段「任一命中」检索。 Returns: SQLAlchemy查询对象(可能包含 JOIN) """ needs_join = self._needs_join(conditions, or_filters) query = self.session.query(SalePlan) if needs_join: query = query.outerjoin( Certificate, SalePlan.unique_key == Certificate.unique_key, ).options(selectinload(SalePlan.certificates)) return self._apply_conditions(query, conditions, or_filters)
[文档] def query( self, conditions: dict[str, Any] = None, page: int = 1, page_size: int = 50, order_by: str = "sort_group", order_desc: bool = False, or_filters: Any = None, ) -> dict[str, Any]: """执行多条件组合查询 Args: conditions: 查询条件字典 page: 页码(从1开始) page_size: 每页记录数 order_by: 排序字段 order_desc: 是否降序 Returns: Dict: 包含 total, page, page_size, total_pages, records, conditions """ conditions = conditions or {} # 判断是否需要 JOIN(筛选条件 + OR 跨字段条件 + 排序字段,一次性判断) needs_join = self._needs_join(conditions, or_filters, order_by) query = self.session.query(SalePlan) if needs_join: query = query.outerjoin( Certificate, SalePlan.unique_key == Certificate.unique_key ).options(selectinload(SalePlan.certificates)) query = self._apply_conditions(query, conditions, or_filters) # 排序 actual_order_by = self.FIELD_ALIASES.get(order_by, order_by) python_sort_field: str | None = None if self._is_cert_field(actual_order_by): order_field = getattr(Certificate, actual_order_by) elif hasattr(SalePlan, actual_order_by): attr = getattr(SalePlan, actual_order_by) # hybrid_property 只有定义了 SQL 表达式(expr is not None)才能用于排序 from sqlalchemy.ext.hybrid import hybrid_property as hp_type descriptor = getattr(attr, "descriptor", None) if isinstance(descriptor, hp_type) and descriptor.expr is None: # 仅有 Python getter 的 hybrid_property,改为 Python 内存排序 python_sort_field = actual_order_by order_field = None else: order_field = attr else: order_field = SalePlan.sort_group if python_sort_field: # Python 排序:只查主键 + 排序字段,排序后按主键查当前页完整记录 pk_col = SalePlan.id all_ids = query.with_entities(pk_col, getattr(SalePlan, python_sort_field)).all() total = len(all_ids) def _sort_key(row): val = row[1] if isinstance(val, str) and val.endswith("%"): return int(val.rstrip("%")) return val or "" all_ids.sort(key=_sort_key, reverse=order_desc) offset = (page - 1) * page_size page_ids = [row[0] for row in all_ids[offset : offset + page_size]] # 按主键批量查当前页完整记录,保持排序顺序 if page_ids: records = self.session.query(SalePlan).filter(SalePlan.id.in_(page_ids)).all() id_order = {pk: i for i, pk in enumerate(page_ids)} records.sort(key=lambda r: id_order[r.id]) else: records = [] else: query = query.order_by(order_field.desc() if order_desc else order_field) # 统计总数 total = query.count() # 分页 offset = (page - 1) * page_size records = query.offset(offset).limit(page_size).all() # 计算总页数 total_pages = (total + page_size - 1) // page_size if total > 0 else 1 logger.info(f"查询完成: 总记录数={total}, 当前页={page}, 每页={page_size}") return { "total": total, "page": page, "page_size": page_size, "total_pages": total_pages, "records": records, "conditions": conditions, }
[文档] def get_field_values( self, field_name: str, search: str = None, order_desc: bool = True ) -> list[str]: """获取某字段的所有唯一值(用于下拉框) Args: field_name: 字段名 search: 搜索关键字(可选) order_desc: 是否降序排序(最新在前),默认True Returns: list[str]: 所有唯一值列表 """ if not hasattr(SalePlan, field_name): logger.warning(f"未知字段: {field_name}") return [] field = getattr(SalePlan, field_name) query = self.session.query(field).filter(field.isnot(None), field != "") if search: query = query.filter(field.contains(search)) # 排序 query = query.order_by(field.desc() if order_desc else field.asc()) values = query.distinct().all() return [v[0] for v in values if v[0]]
[文档] def get_distinct_field_combos( self, fields: list[str], conditions: dict[str, Any] | None = None ) -> list[str]: """返回指定字段组合的去重值列表(全量,不受分页影响),用于快查框候选项。 跨整个结果集(尊重 conditions,但忽略分页)计算去重组合, 避免候选项仅来自当前页导致「越查越窄 / 只能选已显示项」。 Args: fields: 字段名列表(如 ["customer", "product_model"]) conditions: 当前筛选条件(可选),把候选项限定在已筛选范围内 Returns: list[str]: 去重后的组合字符串(字段值以 QUICK_SEARCH_SEP 连接,保留空段),按出现顺序 """ cols = [] for f in fields: if hasattr(SalePlan, f): cols.append(getattr(SalePlan, f)) elif hasattr(Certificate, f): cols.append(getattr(Certificate, f)) else: logger.warning(f"未知字段(快查候选项): {f}") if not cols: return [] needs_join = any(self._is_cert_field(f) for f in fields) query = self.session.query(*cols) if needs_join: query = query.join(Certificate, SalePlan.unique_key == Certificate.unique_key) filters = self._build_filters(conditions or {}) if filters: query = query.filter(and_(*filters)) rows = query.distinct().all() seen: set[str] = set() combos: list[str] = [] for row in rows: # 保留空段(不跳过),确保段数恒等于字段数,回填时按位置一一对应不错位 vals = [str(v or "") for v in row] key = QUICK_SEARCH_SEP.join(vals) if key not in seen: seen.add(key) combos.append(key) return combos
[文档] def search( self, conditions: dict[str, Any] = None, page: int = 1, page_size: int = 50, order_by: str = "sort_group", order_desc: bool = False, ) -> dict[str, Any]: """执行查询并返回与 ``query`` 一致的结果字典 兼容旧测试/调用方:在 ``query`` 基础上将记录字段命名为 ``results`` (``query`` 使用 ``records``),其余字段(total/page/page_size/ total_pages/conditions)保持一致。 Args: conditions: 查询条件字典 page: 页码(从1开始) page_size: 每页记录数 order_by: 排序字段 order_desc: 是否降序 Returns: Dict: 包含 total, page, page_size, total_pages, results, conditions """ result = self.query( conditions=conditions, page=page, page_size=page_size, order_by=order_by, order_desc=order_desc, ) result["results"] = result.pop("records", []) return result
[文档] def get_statistics(self, conditions: dict[str, Any] = None) -> dict[str, Any]: """获取查询结果统计信息 Args: conditions: 查询条件 Returns: Dict: 统计信息 """ query = self.build_query(conditions or {}) # 数量统计 total = query.count() # 总数量(按 quantity 求和,缺失按 0 计) total_quantity = ( query.with_entities(func.coalesce(func.sum(SalePlan.quantity), 0)).scalar() or 0 ) # 分组统计(按生产状态) status_stats = {} status_results = ( query.with_entities(SalePlan.production_status, func.count(SalePlan.id)) .group_by(SalePlan.production_status) .all() ) for status, count in status_results: status_stats[status or "未设置"] = count return { "total": total, "total_count": total, "total_quantity": total_quantity, "status_stats": status_stats, "by_status": status_stats, }
# ============================================================ # CRUD 操作(从 QueryController 迁移) # ============================================================
[文档] def get_records_by_ids(self, ids: list[int]) -> list[Any]: """根据 ID 列表获取 SalePlan 记录 Args: ids: 记录 ID 列表 Returns: 记录列表 """ return self.session.query(SalePlan).filter(SalePlan.id.in_(ids)).all()
[文档] def get_certificate_count(self, plan_ids: list[int]) -> int: """获取关联的合格证数量 Args: plan_ids: 销售计划 ID 列表 Returns: 关联的合格证数量 """ return ( self.session.query(Certificate).filter(Certificate.sale_plan_id.in_(plan_ids)).count() )
[文档] def delete_records(self, ids: list[int]) -> tuple[bool, int, str]: """删除 SalePlan 记录(事务回滚) 检查是否有关联合格证,如有则拒绝删除。 Args: ids: 记录 ID 列表 Returns: tuple[bool, int, str]: (是否成功, 删除数量, 消息) """ from sqlalchemy.exc import SQLAlchemyError plans = self.session.query(SalePlan).filter(SalePlan.id.in_(ids)).all() if not plans: return False, 0, "未找到要删除的记录" cert_count = self.get_certificate_count([p.id for p in plans]) if cert_count > 0: return False, 0, f"存在 {cert_count} 条关联合格证,请使用级联删除" deleted_count = 0 try: for plan in plans: self.session.delete(plan) deleted_count += 1 self.session.commit() logger.info(f"成功删除 {deleted_count} 条记录") return True, deleted_count, "" except SQLAlchemyError as e: self.session.rollback() error_msg = f"数据库错误: {e}" logger.error(f"删除失败,已回滚: {e}") return False, 0, error_msg except Exception as e: self.session.rollback() error_msg = f"未知错误: {e}" logger.error(f"删除失败,已回滚: {e}") return False, 0, error_msg
[文档] def delete_with_certificates(self, ids: list[int]) -> tuple[bool, int, int, str]: """级联删除 SalePlan 记录及关联的 Certificate Args: ids: 记录 ID 列表 Returns: tuple[bool, int, int, str]: (是否成功, 删除计划数, 删除合格证数, 消息) """ from sqlalchemy.exc import SQLAlchemyError plans = self.session.query(SalePlan).filter(SalePlan.id.in_(ids)).all() if not plans: return False, 0, 0, "未找到要删除的记录" plan_ids = [p.id for p in plans] deleted_count = 0 cert_deleted = 0 try: cert_deleted = ( self.session.query(Certificate) .filter(Certificate.sale_plan_id.in_(plan_ids)) .delete(synchronize_session=False) ) for plan in plans: self.session.delete(plan) deleted_count += 1 self.session.commit() msg = f"已删除 {deleted_count} 条记录及 {cert_deleted} 条合格证" logger.info(msg) return True, deleted_count, cert_deleted, msg except SQLAlchemyError as e: self.session.rollback() error_msg = f"数据库错误: {e}" logger.error(f"级联删除失败,已回滚: {e}") return False, 0, 0, error_msg except Exception as e: self.session.rollback() error_msg = f"未知错误: {e}" logger.error(f"级联删除失败,已回滚: {e}") return False, 0, 0, error_msg
[文档] def batch_update_field(self, ids: list[int], field_name: str, value: Any) -> int: """批量更新 SalePlan 字段(事务回滚) Args: ids: 记录 ID 列表 field_name: 字段名 value: 要设置的值 Returns: 更新记录数 """ from sqlalchemy.exc import SQLAlchemyError plans = self.session.query(SalePlan).filter(SalePlan.id.in_(ids)).all() updated = 0 try: for plan in plans: old_value = getattr(plan, field_name) setattr(plan, field_name, value) updated += 1 logger.debug(f"更新 {field_name}: {old_value!r}{value!r} (ID={plan.id})") # #30 P0 A 方案:手动改发货状态时写入溯源,使其在下月重导入时不受字体色覆盖 if field_name == "shipping_status": self._stamp_shipping_source(plans, source="manual") self.session.commit() logger.info(f"批量更新 {field_name}={value} 完成,共 {updated} 条") return updated except SQLAlchemyError as e: self.session.rollback() logger.error(f"批量更新失败,已回滚: {e}") raise except Exception as e: self.session.rollback() logger.error(f"批量更新失败,已回滚: {e}") raise
[文档] def batch_update_production_status( self, ids: list[int], status: str, update_relations: list[dict] | None = None ) -> int: """批量更新生产状态(支持关联字段更新) Args: ids: 记录 ID 列表 status: 目标状态 update_relations: 关联更新规则列表 Returns: 更新记录数 """ from datetime import datetime from sqlalchemy.exc import SQLAlchemyError plans = self.session.query(SalePlan).filter(SalePlan.id.in_(ids)).all() updated = 0 update_relations = update_relations or [] try: for plan in plans: old_status = plan.production_status plan.production_status = status for rel in update_relations: if rel.get("when_status", "") == status: for f_name, f_val in rel.get("set_fields", {}).items(): actual_val = datetime.now() if f_val == "datetime('now')" else f_val setattr(plan, f_name, actual_val) updated += 1 logger.debug(f"更新生产状态: {old_status!r}{status!r} (ID={plan.id})") self.session.commit() logger.info(f"批量更新生产状态完成: {status},共 {updated} 条") return updated except SQLAlchemyError as e: self.session.rollback() logger.error(f"批量更新生产状态失败,已回滚: {e}") raise
def _stamp_shipping_source(self, plans: list[SalePlan], source: str = "manual") -> None: """为发货状态写入溯源信息(#30 P0 A 方案) 使 UI 手动设置的发货状态在重导入时不被字体色派生值还原。 Args: plans: 已更新 shipping_status 的 SalePlan 列表 source: 来源标记,'manual'=UI 手动设置 """ from datetime import datetime from getpass import getuser now = datetime.now() operator = getuser() or "unknown" for plan in plans: plan.shipping_status_source = source plan.shipping_status_set_at = now plan.shipping_status_set_by = operator # 手动标记已发货时记录发货日期 if (plan.shipping_status or "") == "已发货" and not (plan.shipped_date or "").strip(): plan.shipped_date = now.strftime("%Y-%m-%d")
[文档] def copy_cert_info_to_plan(self, ids: list[int]) -> int: """复制合同信息到合格证字段 Args: ids: 记录 ID 列表 Returns: 更新记录数 """ from sqlalchemy.exc import SQLAlchemyError plans = self.session.query(SalePlan).filter(SalePlan.id.in_(ids)).all() updated = 0 try: for plan in plans: if not plan.cert_product_name: plan.cert_product_name = plan.product_name if not plan.cert_product_model: plan.cert_product_model = plan.product_model if not plan.cert_product_spec: plan.cert_product_spec = plan.product_spec updated += 1 self.session.commit() logger.info(f"复制合同信息到合格证字段,共 {updated} 条") return updated except SQLAlchemyError as e: self.session.rollback() logger.error(f"复制失败,已回滚: {e}") raise
[文档] def fill_plan_no(self, ids: list[int], sequence: str) -> int: """填充计划单号(批量更新版本) 使用每条记录的 plan_date 生成对应的年月格式 plan_no。 如果 plan_date 为空,则回退到当前年月。 Args: ids: 记录 ID 列表 sequence: 序号 Returns: 更新记录数 """ from collections import defaultdict from datetime import datetime from sqlalchemy.exc import SQLAlchemyError plans = ( self.session.query(SalePlan) .filter(SalePlan.id.in_(ids), SalePlan.plan_no.is_(None)) .all() ) if not plans: logger.info("没有需要填充的计划单号") return 0 stats: dict[str, int] = defaultdict(int) update_mappings: list[dict] = [] for plan in plans: if plan.plan_date: ym = plan.plan_date.strftime("%Y%m") else: ym = datetime.now().strftime("%Y%m") logger.warning(f"计划 {plan.id} plan_date 为空,使用当前年月 {ym}") update_mappings.append({"id": plan.id, "plan_no": f"{ym}_{sequence}"}) stats[ym] += 1 try: self.session.bulk_update_mappings(SalePlan, update_mappings) self.session.commit() stats_msg = ", ".join(f"{ym}: {cnt}条" for ym, cnt in sorted(stats.items())) logger.info( f"填充计划单号 {len(update_mappings)} 条,序号: {sequence},分布: {stats_msg}" ) return len(update_mappings) except SQLAlchemyError as e: self.session.rollback() logger.error(f"填充计划单号失败,已回滚: {e}") raise
[文档] def get_certificates_by_plan_ids(self, plan_ids: list[int]) -> list[Any]: """根据销售计划 ID 查询关联的合格证记录 Args: plan_ids: 销售计划 ID 列表 Returns: 合格证记录列表 """ return self.session.query(Certificate).filter(Certificate.sale_plan_id.in_(plan_ids)).all()
[文档] def get_latest_certificate(self, plan_ids: list[int]) -> Any: """获取最近创建的一条合格证记录(用于参数补充) Args: plan_ids: 销售计划 ID 列表 Returns: 最近创建的合格证记录,或 None """ return ( self.session.query(Certificate) .filter( Certificate.sale_plan_id.in_(plan_ids), Certificate.print_status == "已打印", ) .order_by(Certificate.id.desc()) .first() )