feat(business): 发货管理接口(发货单/明细/运费/汇总回写/删除保护)
- FOR UPDATE 锁业务行防发货/回款并发丢失更新;终态业务禁新发货 - 行金额服务层按数量×单价固化;明细行 remark 按设计定稿移除(冒烟修复) Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
0cdc6246c4
commit
4aea2be103
91
backend/app/api/v1/business/shipments.py
Normal file
91
backend/app/api/v1/business/shipments.py
Normal file
@ -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="发货单已删除")
|
||||
145
backend/app/repositories/shipment_repository.py
Normal file
145
backend/app/repositories/shipment_repository.py
Normal file
@ -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()
|
||||
125
backend/app/schemas/shipment.py
Normal file
125
backend/app/schemas/shipment.py
Normal file
@ -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="发货日期止(含)")
|
||||
362
backend/app/services/shipment_service.py
Normal file
362
backend/app/services/shipment_service.py
Normal file
@ -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)
|
||||
Loading…
Reference in New Issue
Block a user