"""发货管理业务编排(需求 2.2.2)。 事务边界在本层:repository 只 flush,commit/rollback 只写在这里。 - 行金额(amount)与发货单总金额(total_amount)服务层按 数量×单价 计算 固化,不信任前端传值;单价可空时行金额记 0(对齐业务明细口径) - 业务级汇总(发货/运费合计)回写统一收口 ``business_service._recalc_totals``, 本模块不另写聚合逻辑;发货单 total_amount 由 repository 行 SUM 重算 - **写操作前 FOR UPDATE 锁业务行**(receipt_service 同款写准入):发货与 回款并发写同一业务时相互串行化,防 ``_recalc_totals`` 重算互相覆盖 (丢失更新);锁序统一「业务行 → 单据编号区间」,与业务/回款同构 - 业务状态门槛:仅执行中(1)业务可写发货单 —— 终态业务的发货单禁改禁删 (一期保守口径:编辑不影响已核算回款、核算未发生,但会改业务级汇总 基线,设计未定项待业务确认后可放开终态编辑) - 删除保护:被回款批次指定(receipts.shipment_id)的发货单禁删,先解除 回款的批次指定;删除连带明细行并重算业务级汇总 - 编号 SH 生成复用 no_generator.next_no;残余竞态以唯一键(1062)冲突 重试一次兜底,模式同 business_service.create_business - 审计:锁行与编号生成放在进入审计前(不产生未提交变更),状态校验在 审计上下文内,失败也留痕 —— 失败回滚释放已取序号,当日序号空洞无害 (唯一性不依赖连续);ship_date 是回款天数基准日,编辑即时生效(M3 按 编辑后基准日实时派生,无固化快照可漂移) """ import logging from decimal import ROUND_HALF_UP, Decimal from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.core.constants import ( ACTION_CREATE, ACTION_DELETE, ACTION_UPDATE, MODULE_SHIPMENT, NO_PREFIX_SHIPMENT, ) from app.core.db_errors import is_duplicate_entry from app.core.exceptions import BadRequestError, NotFoundError from app.core.response import PageResult from app.models import Business, Shipment, ShipmentItem, User from app.models.business import BUSINESS_STATUS_ACTIVE from app.repositories import business_repository, shipment_repository from app.schemas.shipment import ( ShipmentCreateIn, ShipmentDetailOut, ShipmentItemIn, ShipmentItemOut, ShipmentOut, ShipmentQuery, ShipmentUpdateIn, ) from app.services import audit_service from app.services.audit_service import AuditRecord, RequestMeta from app.services.business_service import _recalc_totals from app.services.no_generator import next_no logger = logging.getLogger(__name__) # 编号唯一键冲突后的重试次数(模式同 business_service._NO_RETRY) _NO_RETRY = 1 def _to_out(row: tuple[Shipment, str]) -> ShipmentOut: """(shipment, business_no) → 响应模型;金额出参 str。""" shipment, business_no = row return ShipmentOut( id=shipment.id, shipment_no=shipment.shipment_no, business_id=shipment.business_id, business_no=business_no, ship_date=shipment.ship_date, freight_amount=str(shipment.freight_amount), total_amount=str(shipment.total_amount), remark=shipment.remark, ) def _item_to_out(item: ShipmentItem) -> ShipmentItemOut: return ShipmentItemOut( id=item.id, product_id=item.product_id, product_name=item.product_name, spec_model=item.spec_model, unit=item.unit, quantity=str(item.quantity), sale_price=str(item.sale_price) if item.sale_price is not None else None, amount=str(item.amount), ) def _amount_or_zero(value: str | None) -> Decimal: """可空金额入参转 Decimal,空记 0(运费可缺省的统一归一)。""" return Decimal(value) if value is not None else Decimal("0") def _build_item_rows(items: list[ShipmentItemIn]) -> list[ShipmentItem]: """入参明细行转 ORM 行:amount 服务层按 数量×单价 计算固化。 不信任前端传值(schema 亦无 amount 入参)——单价可空时行金额记 0, 由用户在编辑中补价后重算;shipment_id 由 replace_items 统一回填。 乘积显式 quantize 两位固化(模式同 business_service._build_item_rows): 数量(2位)×单价(4位) 可产 6 位小数,直写 DECIMAL(14,2) 会被 MySQL 静默舍入且回显漂移。 """ rows: list[ShipmentItem] = [] for item in items: quantity = Decimal(item.quantity) sale_price = Decimal(item.sale_price) if item.sale_price is not None else None amount = ( (quantity * sale_price).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) if sale_price is not None else Decimal("0.00") ) rows.append( ShipmentItem( product_id=item.product_id, product_name=item.product_name, spec_model=item.spec_model or "", unit=item.unit, quantity=quantity, sale_price=sale_price, amount=amount, ) ) return rows def _generate_no(session: Session) -> str: """生成发货单号(SH 前缀;MAX+FOR UPDATE 与 1062 兜底见 no_generator)。""" return next_no(session, model=Shipment, column_name="shipment_no", prefix=NO_PREFIX_SHIPMENT) def _ensure_business_writable(session: Session, business_id: int, action: str) -> Business: """锁业务行并要求状态=1 执行中(新增/编辑/删除共用的写准入)。 锁在判定之前取:判定结果基于锁定后的行,配合 _recalc_totals 的重算 同事务提交,并发写不会丢失更新。与 receipt_service._ensure_business_writable 同构 —— 文案单据名落到发货单(私有函数各自保留,不为一个字符串上提 公共层)。 :raises NotFoundError: 业务不存在 :raises BadRequestError: 业务已完成或已终止 """ business = business_repository.get_by_id(session, business_id, for_update=True) if business is None: raise NotFoundError("业务不存在") if business.status != BUSINESS_STATUS_ACTIVE: raise BadRequestError(f"业务已完成或已终止,禁止{action}") return business def _is_duplicate_entry(exc: IntegrityError) -> bool: """识别 MySQL 唯一键冲突(1062),与 business/receipt_service 同口径。""" return is_duplicate_entry(exc) def list_shipments(session: Session, query: ShipmentQuery) -> PageResult[ShipmentOut]: """分页查询发货单(附业务号;business_id 可空=全量,一期按业务维度查看为主)。""" rows = shipment_repository.list_shipments( session, offset=query.offset, limit=query.page_size, business_id=query.business_id, ship_date_from=query.ship_date_from, ship_date_to=query.ship_date_to, ) total = shipment_repository.count_shipments( session, business_id=query.business_id, ship_date_from=query.ship_date_from, ship_date_to=query.ship_date_to, ) return PageResult[ShipmentOut]( records=[_to_out(row) for row in rows], total=total, page_num=query.page_num, page_size=query.page_size, ) def get_detail(session: Session, shipment_id: int) -> ShipmentDetailOut: """发货单详情(附明细行,供编辑抽屉回显)。 :raises NotFoundError: 发货单不存在 """ row = shipment_repository.get_with_business_no(session, shipment_id) if row is None: raise NotFoundError("发货单不存在") out = ShipmentDetailOut(**_to_out(row).model_dump()) out.items = [ _item_to_out(item) for item in shipment_repository.list_items(session, shipment_id) ] return out def create_shipment( session: Session, payload: ShipmentCreateIn, *, operator: User, meta: RequestMeta | None = None, ) -> ShipmentOut: """新增发货单:锁业务行 → 校验 → 建单 + 明细 + 双重汇总回写,一个事务。 摘要两号(发货单号/业务号)在进入审计前就绪:业务号取自锁行结果、 发货单号先行生成(SELECT MAX FOR UPDATE,不产生未提交变更);状态 校验在审计上下文内,失败也留痕 —— 失败路径回滚释放已取序号,当日 序号出现空洞无害(唯一性不依赖连续)。 :raises NotFoundError: 业务不存在 :raises BadRequestError: 业务已完成或已终止,禁止新发货 """ business = business_repository.get_by_id(session, payload.business_id, for_update=True) shipment_no = _generate_no(session) with audit_service.audit( session, operator=operator, record=AuditRecord( module=MODULE_SHIPMENT, action=ACTION_CREATE, summary=( f"新增发货单 {shipment_no}" f"(业务 {business.business_no if business else f'#{payload.business_id}'}," f"明细 {len(payload.items)} 行)" ), request_data=payload.model_dump(exclude_none=True), ), meta=meta, ): # 单号唯一键冲突的重试整体收在审计上下文内(模式同 # business_service.create_business):rollback 释放旧锁,重试前重新 # 锁业务行与生成编号——成功/最终失败各只记一条审计,无递归 for attempt in range(_NO_RETRY + 1): try: if business is None: raise NotFoundError("业务不存在") if business.status != BUSINESS_STATUS_ACTIVE: raise BadRequestError("业务已完成或已终止,禁止新发货") shipment = Shipment( shipment_no=shipment_no, business_id=payload.business_id, ship_date=payload.ship_date, freight_amount=_amount_or_zero(payload.freight_amount), total_amount=Decimal("0"), remark=payload.remark, ) shipment_repository.create(session, shipment) shipment_repository.replace_items( session, shipment.id, _build_item_rows(payload.items) ) total = shipment_repository.sum_item_amount(session, shipment.id) shipment.total_amount = total if total is not None else Decimal("0") _recalc_totals(session, business) out = _to_out((shipment, business.business_no)) new_id, new_no = shipment.id, shipment_no # 固化:commit 后不碰 ORM session.commit() break except IntegrityError as exc: session.rollback() if _is_duplicate_entry(exc) and attempt < _NO_RETRY: business = business_repository.get_by_id( session, payload.business_id, for_update=True ) shipment_no = _generate_no(session) continue raise logger.info( "新增发货单 | shipment_id=%s | no=%s | business_id=%s", new_id, new_no, payload.business_id, ) return out def update_shipment( session: Session, shipment_id: int, payload: ShipmentUpdateIn, *, operator: User, meta: RequestMeta | None = None, ) -> ShipmentOut: """编辑发货单(明细全量替换)+ 双重汇总回写,一个事务。 business_id 不在编辑入参内 —— 发货单挂靠的业务创建时定死,改挂会让 已指定本发货单的回款语义断裂。终态业务的发货单禁改(与回款写准入同 口径;一期保守处理,设计未定项待业务确认后可放开——发货编辑不影响 已核算回款,核算未发生,无固化快照可漂移)。 :raises NotFoundError: 发货单不存在 / 业务不存在 :raises BadRequestError: 业务已完成或已终止 """ shipment = shipment_repository.get_by_id(session, shipment_id) with audit_service.audit( session, operator=operator, record=AuditRecord( module=MODULE_SHIPMENT, action=ACTION_UPDATE, summary=f"修改发货单 {shipment.shipment_no if shipment else f'#{shipment_id}'}", request_data=payload.model_dump(exclude_none=True), ), meta=meta, ): if shipment is None: raise NotFoundError("发货单不存在") business = _ensure_business_writable(session, shipment.business_id, "编辑发货单") shipment.ship_date = payload.ship_date shipment.freight_amount = _amount_or_zero(payload.freight_amount) shipment.remark = payload.remark shipment_repository.replace_items(session, shipment_id, _build_item_rows(payload.items)) total = shipment_repository.sum_item_amount(session, shipment_id) shipment.total_amount = total if total is not None else Decimal("0") _recalc_totals(session, business) # business_id 创建时定死不可改挂,锁定的 business.business_no 与重查同源 out = _to_out((shipment, business.business_no)) shipment_no = shipment.shipment_no session.commit() # commit 后不再触碰 ORM 属性(audit_service 约定):单号已固化 logger.info("修改发货单 | shipment_id=%s | no=%s", shipment_id, shipment_no) return out def delete_shipment( session: Session, shipment_id: int, *, operator: User, meta: RequestMeta | None = None, ) -> None: """删除发货单(物理删除,连带明细行)+ 业务级汇总回写,一个事务。 被回款批次指定(receipts.shipment_id 引用)→ 400 引导先解除回款的 批次指定;业务须状态=1 执行中(终态业务的发货单禁删,与回款写准入 同口径)—— 发货单的运费/金额是业务级汇总与 M3 核算的源数据,终态后 不得变动。 :raises NotFoundError: 发货单不存在 / 业务不存在 :raises BadRequestError: 业务已完成或已终止 / 已被回款批次指定 """ shipment = shipment_repository.get_by_id(session, shipment_id) with audit_service.audit( session, operator=operator, record=AuditRecord( module=MODULE_SHIPMENT, action=ACTION_DELETE, summary=f"删除发货单 {shipment.shipment_no if shipment else f'#{shipment_id}'}", ), meta=meta, ): if shipment is None: raise NotFoundError("发货单不存在") business = _ensure_business_writable(session, shipment.business_id, "删除发货单") if shipment_repository.count_receipt_refs(session, shipment_id): raise BadRequestError("已被回款批次指定,请先解除回款的批次指定") shipment_no = shipment.shipment_no shipment_repository.delete_with_items(session, shipment_id) _recalc_totals(session, business) session.commit() # commit 后不再触碰 ORM 属性(audit_service 约定):单号已固化 logger.info("删除发货单 | shipment_id=%s | no=%s", shipment_id, shipment_no)