"""业务数据仓储:业务单、明细、状态留痕与归属留痕。 只读写数据并 flush;commit/rollback 与业务异常一律在 service 层。 归属留痕复用 ownership_change_logs(owner_type=2 为业务维度,常量在 models/customer.py 定义)。 """ from sqlalchemy import Row, Select, delete, exists, func, or_, select, update from sqlalchemy.orm import Session, aliased from app.models import ( Business, BusinessItem, BusinessStatusLog, Customer, OwnershipChangeLog, Personnel, Receipt, SecondaryAllocation, Shipment, ) from app.models.customer import OWNER_TYPE_BUSINESS from app.repositories import ownership_log_repository from app.repositories.ownership_log_repository import OwnershipLogRef from app.schemas.business import BusinessQuery def get_by_id(session: Session, business_id: int, *, for_update: bool = False) -> Business | None: """按主键取业务行;for_update 供删除保护、编辑门槛与状态机使用。""" stmt = select(Business).where(Business.id == business_id) if for_update: stmt = stmt.with_for_update() return session.execute(stmt).scalars().first() def has_docs(session: Session, business_id: int) -> bool: """业务名下是否已有发货单或回款(换客户/删除保护的引用检查)。 单条 OR exists 一次往返;新增挂靠业务的单据类型时在此补充条件, 编辑与删除两处共用同一判定,防「delete 补了检查、update 漏补」的分叉。 """ stmt = select( or_( exists().where(Shipment.business_id == business_id), exists().where(Receipt.business_id == business_id), ) ) return bool(session.execute(stmt).scalar()) def create(session: Session, business: Business) -> None: """新增业务行(不 commit,flush 让自增 id 就位)。""" session.add(business) session.flush() def get_with_names( session: Session, business_id: int, *, for_update: bool = False ) -> tuple[Business, str, str | None, str | None] | None: """业务行 + 客户名 + 负责人名 + 协助人名(DTO 与审计摘要共用)。 for_update 供编辑/状态流转/归属变更使用:与发货/回款写路径的 get_by_id(for_update=True) 同一锁口径,编辑事务与单据写事务互斥, 「有发货禁换客户」的快照读校验不再被并发新增旁路。 """ owner = aliased(Personnel) assistant = aliased(Personnel) stmt = ( select(Business, Customer.company_name, owner.personnel_name, assistant.personnel_name) .join(Customer, Customer.id == Business.customer_id) .outerjoin(owner, owner.id == Business.owner_personnel_id) .outerjoin(assistant, assistant.id == Business.assistant_personnel_id) .where(Business.id == business_id) ) if for_update: stmt = stmt.with_for_update() return session.execute(stmt).first() def _apply_filters(stmt: Select, query: BusinessQuery) -> Select: """业务列表的筛选条件(调用方须已 join Customer)。""" if query.keyword: # 业务编号/公司名称双列 OR 模糊;contains(autoescape) 同 query_utils.fuzzy # 的转义口径(fuzzy 为单列 where 语义,双列 OR 在此直接组合) stmt = stmt.where( or_( Business.business_no.contains(query.keyword, autoescape=True), Customer.company_name.contains(query.keyword, autoescape=True), ) ) if query.status is not None: stmt = stmt.where(Business.status == query.status) if query.customer_id is not None: stmt = stmt.where(Business.customer_id == query.customer_id) if query.owner_personnel_id is not None: stmt = stmt.where(Business.owner_personnel_id == query.owner_personnel_id) if query.date_from is not None: stmt = stmt.where(Business.business_date >= query.date_from) if query.date_to is not None: stmt = stmt.where(Business.business_date <= query.date_to) return stmt def list_businesses( session: Session, query: BusinessQuery ) -> list[tuple[Business, str, str | None, str | None]]: """分页查询业务(附客户名/负责人名/协助人名),按 id 倒序。""" owner = aliased(Personnel) assistant = aliased(Personnel) stmt = select( Business, Customer.company_name, owner.personnel_name, assistant.personnel_name ).join(Customer, Customer.id == Business.customer_id) stmt = _apply_filters(stmt, query) stmt = ( stmt.outerjoin(owner, owner.id == Business.owner_personnel_id) .outerjoin(assistant, assistant.id == Business.assistant_personnel_id) .order_by(Business.id.desc()) .offset(query.offset) .limit(query.page_size) ) return list(session.execute(stmt).all()) def count_businesses(session: Session, query: BusinessQuery) -> int: stmt = ( select(func.count()) .select_from(Business) .join(Customer, Customer.id == Business.customer_id) ) stmt = _apply_filters(stmt, query) return int(session.execute(stmt).scalar_one()) def list_items(session: Session, business_id: int) -> list[BusinessItem]: """业务明细行(编辑回显与汇总重算共用)。""" return list( session.execute( select(BusinessItem) .where(BusinessItem.business_id == business_id) .order_by(BusinessItem.id) ).scalars() ) def replace_items(session: Session, business_id: int, rows: list[BusinessItem]) -> None: """全量替换明细:先删、立即 flush、再插入(replace_roles 先例的防御写法)。 同一 flush 中 SQLAlchemy 先处理 INSERT 后处理 DELETE,先删后插不落库 会在有唯一键的表上撞键;business_items 虽无唯一键,统一按此写法防御。 """ session.execute(delete(BusinessItem).where(BusinessItem.business_id == business_id)) session.flush() for row in rows: row.business_id = business_id session.add(row) session.flush() def list_allocations( session: Session, business_id: int, *, active_only: bool = True ) -> list[SecondaryAllocation]: """二次分配方案(详情回显与核算取数;默认仅当前生效版本)。""" stmt = select(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id) if active_only: stmt = stmt.where(SecondaryAllocation.is_active == 1) return list( session.execute( stmt.order_by(SecondaryAllocation.version_no, SecondaryAllocation.id) ).scalars() ) def list_allocation_versions(session: Session, business_id: int) -> list[SecondaryAllocation]: """方案全部版本行(含历史;业务详情「方案历史」用,按版本正序)。""" return list_allocations(session, business_id, active_only=False) def replace_allocations( session: Session, business_id: int, rows: list[SecondaryAllocation] ) -> None: """全量替换二次分配方案:旧生效行置历史,新行按下一版本号插入。 不 commit(事务边界在服务层)。版本行 append-only——编辑与清空都不 物理删除,驳回后仍可回溯被否决方案的参与人与比例。空 ``rows`` 表示 只清空生效态(不产生新版本)。 """ deactivate_allocations(session, business_id) if not rows: return next_version = ( session.execute( select(func.coalesce(func.max(SecondaryAllocation.version_no), 0)).where( SecondaryAllocation.business_id == business_id ) ).scalar_one() + 1 ) for row in rows: row.business_id = business_id row.version_no = next_version row.is_active = 1 session.add(row) session.flush() def deactivate_allocations(session: Session, business_id: int) -> None: """把业务的当前生效方案置为历史(审批驳回/编辑替换时调用;行保留)。 被否决的方案不得以「当前生效行」身份继续被核算取到;``is_active=0`` 后核算侧自然读不到,而历史版本仍可在业务详情回溯。不 commit。 显式声明 ``synchronize_session="fetch"``:Core UPDATE 默认走 auto 策略 (优先 evaluate,本例的条件恰好能在 Python 侧求值,故实测也会刷新在途 对象)。显式写 fetch 是为了不把「在途对象被刷新」这件事押在 evaluate 能否解析条件上——条件一旦复杂化,auto 会退化为不刷新,同一会话里已 加载的方案行就会停留在 ``is_active=1`` 的过期内存值(查询虽有 is_active 过滤,但行对象本身仍是「生效态」,被序列化或参与判断就是 错的)。 """ session.execute( update(SecondaryAllocation) .where( SecondaryAllocation.business_id == business_id, SecondaryAllocation.is_active == 1, ) .values(is_active=0) .execution_options(synchronize_session="fetch") ) session.flush() def add_status_log(session: Session, log: BusinessStatusLog) -> None: """追加状态留痕(不 commit)。""" session.add(log) session.flush() def list_status_logs( session: Session, business_id: int, *, offset: int, limit: int ) -> list[BusinessStatusLog]: """状态流转时间线(详情页),按时间正序(时间线从创建起展示)。""" return list( session.execute( select(BusinessStatusLog) .where(BusinessStatusLog.business_id == business_id) .order_by(BusinessStatusLog.create_time, BusinessStatusLog.id) .offset(offset) .limit(limit) ).scalars() ) def count_status_logs(session: Session, business_id: int) -> int: return int( session.execute( select(func.count()) .select_from(BusinessStatusLog) .where(BusinessStatusLog.business_id == business_id) ).scalar_one() ) def add_ownership_log(session: Session, log: OwnershipChangeLog) -> None: """追加归属变更留痕(共享实现,不 commit)。""" ownership_log_repository.add(session, log) def list_ownership_logs( session: Session, business_id: int, *, offset: int, limit: int ) -> list[Row]: """某业务的归属变更留痕(共享实现,owner_type/business_id 参数化)。""" return ownership_log_repository.list_by_ref( session, _business_log_ref(business_id), offset=offset, limit=limit ) def count_ownership_logs(session: Session, business_id: int) -> int: return ownership_log_repository.count_by_ref(session, _business_log_ref(business_id)) def _business_log_ref(business_id: int) -> OwnershipLogRef: """业务维度的归属留痕锚点。""" return OwnershipLogRef( owner_type=OWNER_TYPE_BUSINESS, ref_column=OwnershipChangeLog.business_id, ref_id=business_id, )