仓储层的 list_*/count_* 此前以 keyword/status/offset/limit 四五个散参数 传参,调用点重复铺开、易漏传且不可扩展;按项目「参数 >4 封装 DTO」的 约定统一收敛为各模块的 Query 对象(如 RoleQuery、BusinessQuery), 服务层与测试调用同步调整,行为不变。 - repositories 层查询方法签名改为 (session, query),分页与筛选条件 由 Query 承载,_apply_filters 同步收口 - schemas 层补齐各模块 Query 模型(approval/business_role/report 等) - services 层调用点同步改为传 Query - tests 随之调整;新增用例:待办角色流转(财务通过后领导出现待办)、 参数预览顺序稳定 验证:全量 pytest 900 passed(11:08);单元 526 passed;ruff check 绿。 Co-Authored-By: Claude Code <noreply@anthropic.com>
372 lines
16 KiB
Python
372 lines
16 KiB
Python
"""发货管理业务编排(需求 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, query)
|
||
total = shipment_repository.count_shipments(session, query)
|
||
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("业务已完成或已终止,禁止新发货")
|
||
# 月结锁定拦截:发货日所在月已审定 → 源数据不可改(调整走调整单)
|
||
from app.services import settlement_service
|
||
|
||
settlement_service.ensure_month_not_locked(
|
||
session, payload.ship_date.strftime("%Y-%m")
|
||
)
|
||
|
||
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, "编辑发货单")
|
||
# 月结锁定拦截:原发货日与新月都不得落入已锁定月份(改日期=跨月搬数)
|
||
from app.services import settlement_service
|
||
|
||
settlement_service.ensure_month_not_locked(session, shipment.ship_date.strftime("%Y-%m"))
|
||
settlement_service.ensure_month_not_locked(session, payload.ship_date.strftime("%Y-%m"))
|
||
|
||
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, "删除发货单")
|
||
# 月结锁定拦截:已锁定月份的发货单是月结快照的源数据,不得删除
|
||
from app.services import settlement_service
|
||
|
||
settlement_service.ensure_month_not_locked(session, shipment.ship_date.strftime("%Y-%m"))
|
||
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)
|