- 月结锁定拦截补全:原仅新增回款一处,现覆盖回款编辑(原月+新月,防跨月 搬数)/删除、发货新增(按发货日)/编辑(原月+新月)/删除、核算与重算 (已审定月份金额只走调整单);拦截先于「被引用/已核算」判定 - 台账 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>
367 lines
15 KiB
Python
367 lines
15 KiB
Python
"""回款管理业务编排(需求 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 datetime import date
|
||
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.db_errors import is_duplicate_entry
|
||
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("业务已完成或已终止,禁止新增回款")
|
||
# 月结锁定拦截(2026-10-15 补充需求):比需求原文「提示」取硬,
|
||
# 一期安全侧(策略点注释);未指定日期时以当天月近似
|
||
from app.services import settlement_service
|
||
|
||
_lock_month = (payload.receipt_date or date.today()).strftime("%Y-%m")
|
||
settlement_service.ensure_month_not_locked(session, _lock_month)
|
||
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, "修改")
|
||
# 月结锁定拦截:原月与新月都不得落入已锁定月份(改到账日=跨月搬数,
|
||
# 只查新月会让「从锁定月改到未锁定月」绕过拦截)
|
||
from app.services import settlement_service
|
||
|
||
settlement_service.ensure_month_not_locked(session, receipt.receipt_date.strftime("%Y-%m"))
|
||
settlement_service.ensure_month_not_locked(session, payload.receipt_date.strftime("%Y-%m"))
|
||
# 校验返回的 (shipment_no, ship_date) 即新批次 DTO 所需 —— shipment_id
|
||
# 已被赋为 payload 新值,与重查 get_with_refs 同源,省一次三表 join
|
||
shipment_no, ship_date = _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, business.business_no, shipment_no, ship_date))
|
||
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, "删除")
|
||
# 月结锁定拦截:已锁定月份的回款是月结快照的源数据,不得删除
|
||
from app.services import settlement_service
|
||
|
||
settlement_service.ensure_month_not_locked(session, receipt.receipt_date.strftime("%Y-%m"))
|
||
|
||
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 同口径。"""
|
||
return is_duplicate_entry(exc)
|