PayrollManagementSystem/backend/app/services/shipment_service.py
liuwenxin 76ded6d5ec fix(m3): code-review P1——锁定不变量、入结回填与双轨漂移
- 月结锁定拦截补全:原仅新增回款一处,现覆盖回款编辑(原月+新月,防跨月
  搬数)/删除、发货新增(按发货日)/编辑(原月+新月)/删除、核算与重算
  (已审定月份金额只走调整单);拦截先于「被引用/已核算」判定
- 台账 settlement_id 回填落地:generate 在 replace_items 后回填纳入行、
  重生成前先解绑(clear_ledger_settlement_id)——不回填/不解绑会让
  「未入结」判据失效:重生成漏算整月或把已固化行反复计入
- 经理聚合行落库:原只进内存参与 items 汇总,台账表永远无 calc_type=2
  行、报表下钻落空;重生成先作废旧聚合行(deactivate_month_aggregates),
  _confirm_scope 限定计算类型避免聚合行混入单笔池
- 月结取参锚定所属月月末(_month_end):原用 date.today(),补生成历史
  月份时分成/月工资口径随生成日漂移;聚合行 receipt_date 同步为月末
- generate/submit 出参固化前置到 commit 之前(commit 后 SELECT 失败会把
  已生成的月结记成失败审计,与库中事实相反)
- V9 迁移:business_secondary_allocations 唯一键条件式补齐(幂等裸 SQL,
  临时库验证「无键→补」「有键→跳过」两分支),修复旧 V6 升级库拿不到
  V6 就地加入的约束;V8 迁移 revision 声明补类型注解(原格式漂移使
  schema 同步测试的 head 推断静默漏检 V8);init-dev 版本号推进至 V9
- V7 留存 SQL 约束/索引名单数改复数(与 alembic op.f 与 init-dev 对齐,
  消除 SHOW CREATE TABLE 双轨漂移)
- 测试:显式控制审批开关(不依赖 V8 翻转后的种子默认值);新增回归 8 例
  (回填落库/聚合不重复/月末取参/月末纯函数 5 例/锁定拦截回款与发货与
  核算);全量 773 passed

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 11:59:49 +08:00

384 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""发货管理业务编排(需求 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("业务已完成或已终止,禁止新发货")
# 月结锁定拦截:发货日所在月已审定 → 源数据不可改(调整走调整单)
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)