From 4aea2be1031a5582b41d2f8d95b12f14bdef6f6b Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Tue, 13 Oct 2026 12:25:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(business):=20=E5=8F=91=E8=B4=A7=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E6=8E=A5=E5=8F=A3=EF=BC=88=E5=8F=91=E8=B4=A7=E5=8D=95?= =?UTF-8?q?/=E6=98=8E=E7=BB=86/=E8=BF=90=E8=B4=B9/=E6=B1=87=E6=80=BB?= =?UTF-8?q?=E5=9B=9E=E5=86=99/=E5=88=A0=E9=99=A4=E4=BF=9D=E6=8A=A4?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FOR UPDATE 锁业务行防发货/回款并发丢失更新;终态业务禁新发货 - 行金额服务层按数量×单价固化;明细行 remark 按设计定稿移除(冒烟修复) Co-Authored-By: Claude Code --- backend/app/api/v1/business/shipments.py | 91 +++++ .../app/repositories/shipment_repository.py | 145 +++++++ backend/app/schemas/shipment.py | 125 ++++++ backend/app/services/shipment_service.py | 362 ++++++++++++++++++ 4 files changed, 723 insertions(+) create mode 100644 backend/app/api/v1/business/shipments.py create mode 100644 backend/app/repositories/shipment_repository.py create mode 100644 backend/app/schemas/shipment.py create mode 100644 backend/app/services/shipment_service.py diff --git a/backend/app/api/v1/business/shipments.py b/backend/app/api/v1/business/shipments.py new file mode 100644 index 0000000..ff62dee --- /dev/null +++ b/backend/app/api/v1/business/shipments.py @@ -0,0 +1,91 @@ +"""发货管理接口(需求 2.2.2)。 + +校验规则(状态门槛、行金额固化、双重汇总回写、删除保护)见 +`shipment_service` 模块 docstring;业务数据属业务操作,登录即可访问。 +""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, Path, Query + +from app.api.deps import DbSession, RequestMetaDep, get_current_user +from app.core.response import ApiResponse, PageResult, ok +from app.models import User +from app.schemas.shipment import ( + ShipmentCreateIn, + ShipmentDetailOut, + ShipmentOut, + ShipmentQuery, + ShipmentUpdateIn, +) +from app.services import shipment_service + +# 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser) +CurrentUser = Annotated[User, Depends(get_current_user)] + +router = APIRouter(prefix="/shipments", tags=["发货管理"]) + + +@router.get("", response_model=ApiResponse[PageResult[ShipmentOut]], summary="发货单列表") +def list_shipments( + session: DbSession, + _: CurrentUser, + query: Annotated[ShipmentQuery, Query()], +) -> ApiResponse[PageResult[ShipmentOut]]: + """分页查询发货单(附业务编号),可按业务/发货日期范围筛选。""" + return ok(shipment_service.list_shipments(session, query)) + + +@router.post("", response_model=ApiResponse[ShipmentOut], summary="创建发货单") +def create_shipment( + session: DbSession, + current_user: CurrentUser, + payload: ShipmentCreateIn, + meta: RequestMetaDep, +) -> ApiResponse[ShipmentOut]: + """创建发货单(明细 ≥1)。业务须处于执行中;锁业务行后生成 SH 编号。""" + return ok( + shipment_service.create_shipment(session, payload, operator=current_user, meta=meta), + message="发货单已创建", + ) + + +@router.get( + "/{shipment_id}", response_model=ApiResponse[ShipmentDetailOut], summary="发货单详情" +) +def get_shipment( + session: DbSession, + _: CurrentUser, + shipment_id: Annotated[int, Path(ge=1)], +) -> ApiResponse[ShipmentDetailOut]: + """发货单详情,附产品明细行,供编辑抽屉一次回显。""" + return ok(shipment_service.get_detail(session, shipment_id)) + + +@router.put("/{shipment_id}", response_model=ApiResponse[ShipmentOut], summary="编辑发货单") +def update_shipment( + session: DbSession, + current_user: CurrentUser, + shipment_id: Annotated[int, Path(ge=1)], + payload: ShipmentUpdateIn, + meta: RequestMetaDep, +) -> ApiResponse[ShipmentOut]: + """编辑发货单(明细全量替换并重算汇总)。终态业务的发货单禁改。""" + return ok( + shipment_service.update_shipment( + session, shipment_id, payload, operator=current_user, meta=meta + ), + message="已保存", + ) + + +@router.delete("/{shipment_id}", response_model=ApiResponse[None], summary="删除发货单") +def delete_shipment( + session: DbSession, + current_user: CurrentUser, + shipment_id: Annotated[int, Path(ge=1)], + meta: RequestMetaDep, +) -> ApiResponse[None]: + """删除发货单(连带明细行并回写业务汇总)。被回款批次指定时禁删。""" + shipment_service.delete_shipment(session, shipment_id, operator=current_user, meta=meta) + return ok(message="发货单已删除") diff --git a/backend/app/repositories/shipment_repository.py b/backend/app/repositories/shipment_repository.py new file mode 100644 index 0000000..971f357 --- /dev/null +++ b/backend/app/repositories/shipment_repository.py @@ -0,0 +1,145 @@ +"""发货数据访问:发货单行、明细行与业务号关联引用。 + +只读写数据并 flush;commit/rollback 与业务异常一律在 service 层。 + +- 列表与详情统一 join 出 ``business_no``(业务主表 inner join,FK NOT + NULL,业务与发货单同存亡) +- 明细行汇总(sum_item_amount)是发货单 total_amount 重算的唯一数据源, + 业务级汇总仍收口 business_service._recalc_totals +""" + +from datetime import date +from decimal import Decimal + +from sqlalchemy import Select, delete, func, select +from sqlalchemy.orm import Session + +from app.models import Business, Receipt, Shipment, ShipmentItem + + +def get_by_id(session: Session, shipment_id: int) -> Shipment | None: + """按主键取发货单行(供摘要预取与编辑赋值;不锁行,写保护锚在业务行)。""" + return session.get(Shipment, shipment_id) + + +def get_with_business_no(session: Session, shipment_id: int) -> tuple[Shipment, str] | None: + """发货单行 + 业务号(DTO 与审计摘要共用)。""" + return session.execute( + select(Shipment, Business.business_no) + .join(Business, Business.id == Shipment.business_id) + .where(Shipment.id == shipment_id) + ).first() + + +def _apply_filters( + stmt: Select, + *, + business_id: int | None, + ship_date_from: date | None, + ship_date_to: date | None, +) -> Select: + """发货列表的筛选条件(business_id 空=全量,一期按业务维度查看为主)。""" + if business_id is not None: + stmt = stmt.where(Shipment.business_id == business_id) + if ship_date_from is not None: + stmt = stmt.where(Shipment.ship_date >= ship_date_from) + if ship_date_to is not None: + stmt = stmt.where(Shipment.ship_date <= ship_date_to) + return stmt + + +def list_shipments( + session: Session, + *, + offset: int, + limit: int, + business_id: int | None, + ship_date_from: date | None, + ship_date_to: date | None, +) -> list[tuple[Shipment, str]]: + """分页查询发货单(附业务号),按 id 倒序。""" + stmt = _apply_filters( + select(Shipment, Business.business_no).join( + Business, Business.id == Shipment.business_id + ), + business_id=business_id, + ship_date_from=ship_date_from, + ship_date_to=ship_date_to, + ) + return list( + session.execute(stmt.order_by(Shipment.id.desc()).offset(offset).limit(limit)).all() + ) + + +def count_shipments( + session: Session, + *, + business_id: int | None, + ship_date_from: date | None, + ship_date_to: date | None, +) -> int: + """发货单总数(筛选条件与列表共用同一 apply_filters,防两侧口径漂移)。""" + stmt = _apply_filters( + select(func.count()) + .select_from(Shipment) + .join(Business, Business.id == Shipment.business_id), + business_id=business_id, + ship_date_from=ship_date_from, + ship_date_to=ship_date_to, + ) + return int(session.execute(stmt).scalar_one()) + + +def list_items(session: Session, shipment_id: int) -> list[ShipmentItem]: + """发货明细行(详情回显与总金额重算共用)。""" + return list( + session.execute( + select(ShipmentItem) + .where(ShipmentItem.shipment_id == shipment_id) + .order_by(ShipmentItem.id) + ).scalars() + ) + + +def sum_item_amount(session: Session, shipment_id: int) -> Decimal | None: + """明细行金额合计(发货单 total_amount 重算的唯一数据源)。""" + return session.execute( + select(func.sum(ShipmentItem.amount)).where(ShipmentItem.shipment_id == shipment_id) + ).scalar_one() + + +def count_receipt_refs(session: Session, shipment_id: int) -> int: + """被回款批次指定的引用数(删除保护依据,receipts.shipment_id)。""" + return int( + session.execute( + select(func.count()).select_from(Receipt).where(Receipt.shipment_id == shipment_id) + ).scalar_one() + ) + + +def create(session: Session, shipment: Shipment) -> Shipment: + """新增发货单行。只 flush 以取得自增 id,单号唯一键冲突由 service 兜底。""" + session.add(shipment) + session.flush() + return shipment + + +def replace_items(session: Session, shipment_id: int, rows: list[ShipmentItem]) -> None: + """全量替换明细:先删、立即 flush、再插入(business_repository 同款防御写法)。 + + 同一 flush 中 SQLAlchemy 先处理 INSERT 后处理 DELETE,先删后插不落库 + 会在有唯一键的表上撞键;shipment_items 虽无唯一键,统一按此写法防御。 + """ + session.execute(delete(ShipmentItem).where(ShipmentItem.shipment_id == shipment_id)) + session.flush() + for row in rows: + row.shipment_id = shipment_id + session.add(row) + session.flush() + + +def delete_with_items(session: Session, shipment_id: int) -> None: + """删除发货单及明细行(按依赖序:明细 → 主表,FK RESTRICT)。""" + session.execute(delete(ShipmentItem).where(ShipmentItem.shipment_id == shipment_id)) + session.execute(delete(Shipment).where(Shipment.id == shipment_id)) + session.flush() diff --git a/backend/app/schemas/shipment.py b/backend/app/schemas/shipment.py new file mode 100644 index 0000000..61253f1 --- /dev/null +++ b/backend/app/schemas/shipment.py @@ -0,0 +1,125 @@ +"""发货管理的请求/响应模型(需求 2.2.2)。 + +金额/数量入参出参一律 str(服务层 Decimal 运算,全程禁 float),容量与 +小数位校验复用 business.py 的 ``_check_amount``(同一套口径,防两处漂移); +字段契约全线蛇形直出(2026-10-13 定稿:无驼峰 alias)。 + +amount(行金额)与 total_amount(发货总金额)不在入参内 —— 由服务层按 +数量×单价 计算固化,不信任前端传值;运费仅记录展示,不计入毛利润口径。 +""" + +from datetime import date + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from app.core.response import PageQuery +from app.schemas.business import AMOUNT_MAX, PRICE_MAX, _check_amount + + +class ShipmentItemIn(BaseModel): + """发货产品明细行(product_id 空=自由录入;价格口径两可,可空人工补)。""" + + model_config = ConfigDict(str_strip_whitespace=True) + + product_id: int | None = Field(default=None, ge=1, description="产品ID;空=自由录入") + product_name: str = Field(min_length=1, max_length=128, description="产品名称") + spec_model: str | None = Field( + default=None, max_length=128, description="规格型号,空=空串归一" + ) + unit: str | None = Field(default=None, max_length=16, description="计量单位") + quantity: str = Field(description="数量,>0") + sale_price: str | None = Field( + default=None, description="发货单价(元),可空=待人工补价,补价前行金额记 0" + ) + + @field_validator("quantity") + @classmethod + def _check_quantity(cls, value: str) -> str: + return _check_amount(value, "数量", AMOUNT_MAX, allow_positive_only=True) or value + + @field_validator("sale_price") + @classmethod + def _check_price(cls, value: str | None) -> str | None: + return _check_amount(value, "金额", PRICE_MAX) + + +class ShipmentItemOut(BaseModel): + """发货产品明细行响应。金额/数量一律 str。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + product_id: int | None = None + product_name: str + spec_model: str | None = None + unit: str | None = None + quantity: str + sale_price: str | None = None + amount: str + + +class ShipmentOut(BaseModel): + """发货单响应(列表与写操作回包共用);business_no 为 join 附带列。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + shipment_no: str + business_id: int + business_no: str | None = None + ship_date: date + freight_amount: str + total_amount: str + remark: str | None = None + + +class ShipmentDetailOut(ShipmentOut): + """发货单详情:附产品明细行。""" + + items: list[ShipmentItemOut] = Field(default_factory=list) + + +class ShipmentCreateIn(BaseModel): + """创建发货单:业务维度 + 明细行数组(明细 ≥1 行)。 + + 字段名全线蛇形直出(同 business.py 契约,仅分页信封 list/pageNum 驼峰)。 + """ + + model_config = ConfigDict(str_strip_whitespace=True) + + business_id: int = Field(ge=1, description="业务ID") + ship_date: date = Field(description="发货日期(回款天数基准日,发货日首日=第 1 天)") + freight_amount: str | None = Field( + default=None, description="运费金额(元),空记 0;仅记录展示,不计入毛利润口径" + ) + items: list[ShipmentItemIn] = Field(min_length=1, description="发货明细行,至少 1 行") + remark: str | None = Field(default=None, max_length=255) + + +class ShipmentUpdateIn(BaseModel): + """编辑发货单(明细全量替换)。 + + business_id 不在编辑入参内 —— 发货单挂靠的业务创建时定死,改挂会让 + 已指定本发货单的回款语义断裂。 + """ + + model_config = ConfigDict(str_strip_whitespace=True) + + ship_date: date = Field(description="发货日期(回款天数基准日,发货日首日=第 1 天)") + freight_amount: str | None = Field(default=None, description="运费金额(元),空记 0") + items: list[ShipmentItemIn] = Field( + min_length=1, description="发货明细行,至少 1 行(全量替换)" + ) + remark: str | None = Field(default=None, max_length=255) + + +class ShipmentQuery(PageQuery): + """发货单列表查询条件(字段名蛇形直出,与前端 api 封装同名对应)。 + + business_id 可空=全量:发货单以业务维度查看为主(一期),但单号可 + 独立检索,不强制挂业务条件。 + """ + + business_id: int | None = Field(default=None, ge=1, description="业务筛选,空=全部") + ship_date_from: date | None = Field(default=None, description="发货日期起(含)") + ship_date_to: date | None = Field(default=None, description="发货日期止(含)") diff --git a/backend/app/services/shipment_service.py b/backend/app/services/shipment_service.py new file mode 100644 index 0000000..7ed085c --- /dev/null +++ b/backend/app/services/shipment_service.py @@ -0,0 +1,362 @@ +"""发货管理业务编排(需求 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 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.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__) + + +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 统一回填。 + """ + 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) if sale_price is not None else Decimal("0") + 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 同口径。""" + args = getattr(exc.orig, "args", ()) + return bool(args) and args[0] == 1062 + + +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, + ): + if business is None: + raise NotFoundError("业务不存在") + if business.status != BUSINESS_STATUS_ACTIVE: + raise BadRequestError("业务已完成或已终止,禁止新发货") + + try: + 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 = shipment.id + session.commit() + except IntegrityError as exc: + session.rollback() + if _is_duplicate_entry(exc): + # 单号撞唯一键的残余竞态:兜底重试一次(防无限重试掩盖死锁) + return _retry_create(session, payload, operator=operator, meta=meta) + raise + + logger.info( + "新增发货单 | shipment_id=%s | no=%s | business_id=%s", + new_id, + shipment_no, + payload.business_id, + ) + return out + + +def _retry_create( + session: Session, + payload: ShipmentCreateIn, + *, + operator: User, + meta: RequestMeta | None, +) -> ShipmentOut: + """单号撞唯一键后的单次重试(重新锁行与生成编号,防死锁类缺陷掩盖)。""" + return create_shipment(session, payload, operator=operator, meta=meta) + + +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) + out = _to_out(shipment_repository.get_with_business_no(session, shipment_id)) + 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)