"""回款管理业务编排(需求 2.2.3)。 事务边界在本层:repository 只 flush,commit/rollback 只写在这里。 - **写操作前 FOR UPDATE 锁业务行**(delete_business 先例):同业务的 发货/回款/编辑相互串行化,防止并发写后 ``_recalc_totals`` 重算 total_received_amount 互相覆盖(丢失更新);批次号生成(MAX+FOR UPDATE 锁当日序号区间)与唯一键(1062)冲突重试一次兜底,模式同 business_service - **shipment_id 同业务强校验**(数据完整性红线):指定的发货单必须存在 且与本回款同属一个业务 —— 跨业务指定会让回款天数与台账归属漂移; 存在性与归属两类失败统一同一文案(防通过响应差异探测发货单存在性, 与登录判定的防枚举同哲学),FK RESTRICT 是最终防线 - **settlement_status 是 M3 核算防重锚点**(需规 2.3.3「每笔回款仅核算 一次」):已核算回款禁改禁删 —— 改金额/删行会让 M3 台账与源数据漂移; M2 只写入 0(未核算),置 1 由 M3 核算服务负责 - 回款合计回写统一收口 ``business_service._recalc_totals``(增删改后 都重算,禁各处手写 SUM) - payment_days 为**只读派生值**:shipment_id 非空且发货单在库时按 ``formula.payment_days(ship_date, receipt_date)`` 实时计算(发货日首日 计第 1 天),不落库;未指定批次为 None - 一期禁负数金额(≥0):schema 拒绝;红字/退款冲销需求出现时先补设计 """ import logging from decimal import Decimal from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.core.constants import ( ACTION_CREATE, ACTION_DELETE, ACTION_UPDATE, MODULE_RECEIPT, NO_PREFIX_RECEIPT, ) from app.core.exceptions import BadRequestError, NotFoundError from app.core.response import PageResult from app.models import Receipt from app.models.business import BUSINESS_STATUS_ACTIVE from app.models.receipt import SETTLEMENT_DONE, SETTLEMENT_PENDING from app.repositories import business_repository, receipt_repository from app.schemas.receipt import ( ReceiptCreateIn, ReceiptOut, ReceiptQuery, ReceiptUpdateIn, ) 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.formula import payment_days from app.services.no_generator import next_no logger = logging.getLogger(__name__) # 编号唯一键冲突后的重试次数(模式同 business_service._NO_RETRY) _NO_RETRY = 1 def _to_out(row: tuple) -> ReceiptOut: """(receipt, business_no, shipment_no, ship_date) → 响应模型。 金额出参 str;payment_days 为派生值 —— ship_date 为 None(未指定批次) 时输出 None;回款日早于发货日时按公式照实输出负值,忠实反映源数据 (钳制会掩盖录入异常,M3 台账同口径)。 """ receipt, business_no, shipment_no, ship_date = row return ReceiptOut( id=receipt.id, receipt_batch_no=receipt.receipt_batch_no, business_id=receipt.business_id, business_no=business_no, shipment_id=receipt.shipment_id, shipment_no=shipment_no, receipt_date=receipt.receipt_date, amount=str(receipt.amount), is_barter=receipt.is_barter, settlement_status=receipt.settlement_status, payment_days=( payment_days(ship_date, receipt.receipt_date) if ship_date is not None else None ), remark=receipt.remark, ) def _generate_no(session: Session) -> str: """生成回款批次号(HK 前缀;MAX+FOR UPDATE 与 1062 兜底见 no_generator)。""" return next_no(session, model=Receipt, column_name="receipt_batch_no", prefix=NO_PREFIX_RECEIPT) def _ensure_shipment_of_business( session: Session, shipment_id: int | None, business_id: int ) -> tuple[str | None, object | None]: """发货批次同业务强校验,返回 (shipment_no, ship_date) 供 DTO 组装。 「必须存在且 business_id 相同」两类失败统一同一文案(防探测发货单 存在性);shipment_id 为 None = 暂未指定,直接放行。 :raises BadRequestError: 发货批次不存在或不属于该业务 """ if shipment_id is None: return None, None shipment = receipt_repository.get_shipment(session, shipment_id) if shipment is None or shipment.business_id != business_id: raise BadRequestError("发货批次不属于该业务,无法指定") return shipment.shipment_no, shipment.ship_date def _ensure_business_writable(session: Session, business_id: int, action: str): """锁业务行并要求状态=1 执行中(新增/编辑/删除共用的写准入)。 锁在判定之前取:判定结果基于锁定后的行,配合 _recalc_totals 的重算 同事务提交,并发写不会丢失更新。 :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 list_receipts(session: Session, query: ReceiptQuery) -> PageResult[ReceiptOut]: """分页查询回款(附业务号/发货单号与 payment_days 派生值)。""" rows = receipt_repository.list_receipts( session, offset=query.offset, limit=query.page_size, business_id=query.business_id, date_from=query.date_from, date_to=query.date_to, is_barter=query.is_barter, settlement_status=query.settlement_status, ) total = receipt_repository.count_receipts( session, business_id=query.business_id, date_from=query.date_from, date_to=query.date_to, is_barter=query.is_barter, settlement_status=query.settlement_status, ) return PageResult[ReceiptOut]( 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, receipt_id: int) -> ReceiptOut: """回款详情(同列表 DTO,含 payment_days 派生值)。 :raises NotFoundError: 回款不存在 """ row = receipt_repository.get_with_refs(session, receipt_id) if row is None: raise NotFoundError("回款不存在") return _to_out(row) def create_receipt( session: Session, payload: ReceiptCreateIn, *, operator, meta: RequestMeta | None = None, ) -> ReceiptOut: """新增回款:锁业务行 → 校验 → 建行 + 重算合计,一个事务。 摘要三要素(批次号/业务号/金额)在进入审计前就绪:业务号取自锁行 结果、批次号先行生成(SELECT MAX FOR UPDATE,不产生未提交变更)、 金额来自入参;引用校验(业务状态/发货批次同业务)在审计上下文内, 失败也留痕 —— 失败路径回滚释放已取序号,当日序号出现空洞无害 (唯一性不依赖连续)。 :raises NotFoundError: 业务不存在 :raises BadRequestError: 业务已完成或已终止 / 发货批次不属于该业务 """ business = business_repository.get_by_id(session, payload.business_id, for_update=True) batch_no = _generate_no(session) with audit_service.audit( session, operator=operator, record=AuditRecord( module=MODULE_RECEIPT, action=ACTION_CREATE, summary=( f"新增回款 {batch_no}" f"(业务 {business.business_no if business else f'#{payload.business_id}'}," f"金额 {payload.amount})" ), 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_no, ship_date = _ensure_shipment_of_business( session, payload.shipment_id, payload.business_id ) receipt = receipt_repository.create( session, Receipt( receipt_batch_no=batch_no, business_id=payload.business_id, shipment_id=payload.shipment_id, receipt_date=payload.receipt_date, amount=Decimal(payload.amount), is_barter=payload.is_barter, settlement_status=SETTLEMENT_PENDING, remark=payload.remark, ), ) _recalc_totals(session, business) out = _to_out((receipt, business.business_no, shipment_no, ship_date)) new_id, new_no = receipt.id, batch_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 ) batch_no = _generate_no(session) continue raise logger.info( "新增回款 | receipt_id=%s | no=%s | business_id=%s", new_id, new_no, payload.business_id, ) return out def update_receipt( session: Session, receipt_id: int, payload: ReceiptUpdateIn, *, operator, meta: RequestMeta | None = None, ) -> ReceiptOut: """编辑回款(全量替换)+ 重算合计,一个事务。 **已核算回款禁止修改**(M3 防重锚点):M3 台账已按该行固化核算, 改金额/到账日/批次指定会让台账与源数据漂移。shipment_id 变更后 重新做同业务强校验(全量替换语义下每次编辑都校验,无「未变即免检」 的旁路)。 :raises NotFoundError: 回款不存在 / 业务不存在 :raises BadRequestError: 已核算禁改 / 业务已完成或已终止 / 发货批次不属于该业务 """ receipt = receipt_repository.get_by_id(session, receipt_id) with audit_service.audit( session, operator=operator, record=AuditRecord( module=MODULE_RECEIPT, action=ACTION_UPDATE, summary=f"修改回款 {receipt.receipt_batch_no if receipt else f'#{receipt_id}'}", request_data=payload.model_dump(exclude_none=True), ), meta=meta, ): if receipt is None: raise NotFoundError("回款不存在") if receipt.settlement_status == SETTLEMENT_DONE: raise BadRequestError("已核算回款禁止修改") business = _ensure_business_writable(session, receipt.business_id, "修改") # 返回值不取用:DTO 组装以 get_with_refs 重查为准(shipment 变更后同源) _ensure_shipment_of_business(session, payload.shipment_id, receipt.business_id) receipt.receipt_date = payload.receipt_date receipt.shipment_id = payload.shipment_id receipt.amount = Decimal(payload.amount) receipt.is_barter = payload.is_barter receipt.remark = payload.remark _recalc_totals(session, business) out = _to_out(receipt_repository.get_with_refs(session, receipt_id)) batch_no = receipt.receipt_batch_no session.commit() # commit 后不再触碰 ORM 属性(audit_service 约定):批次号已固化 logger.info("修改回款 | receipt_id=%s | no=%s", receipt_id, batch_no) return out def delete_receipt( session: Session, receipt_id: int, *, operator, meta: RequestMeta | None = None, ) -> None: """删除回款(物理删除)+ 重算合计,一个事务。 **已核算回款禁止删除**(M3 防重锚点,理由同编辑);业务须状态=1 执行中 —— 终态业务的回款是 M3 核算与报表的既成事实,不得删改。 :raises NotFoundError: 回款不存在 / 业务不存在 :raises BadRequestError: 已核算禁删 / 业务已完成或已终止 """ receipt = receipt_repository.get_by_id(session, receipt_id) with audit_service.audit( session, operator=operator, record=AuditRecord( module=MODULE_RECEIPT, action=ACTION_DELETE, summary=f"删除回款 {receipt.receipt_batch_no if receipt else f'#{receipt_id}'}", ), meta=meta, ): if receipt is None: raise NotFoundError("回款不存在") if receipt.settlement_status == SETTLEMENT_DONE: raise BadRequestError("已核算回款禁止删除") business = _ensure_business_writable(session, receipt.business_id, "删除") batch_no = receipt.receipt_batch_no receipt_repository.delete(session, receipt) _recalc_totals(session, business) session.commit() # commit 后不再触碰 ORM 属性(audit_service 约定):批次号已固化 logger.info("删除回款 | receipt_id=%s | no=%s", receipt_id, batch_no) def _is_duplicate_entry(exc: IntegrityError) -> bool: """识别 MySQL 唯一键冲突(1062),与 business_service 同口径。""" args = getattr(exc.orig, "args", ()) return bool(args) and args[0] == 1062