PayrollManagementSystem/backend/app/services/receipt_service.py

416 lines
17 KiB
Python
Raw Normal View History

"""回款管理业务编排(需求 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 dataclasses import dataclass
from datetime import date, datetime
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, ConflictError, NotFoundError
from app.core.response import PageResult
from app.models import Business, 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, receipt_confirmation
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,
confirmation_status=1 if receipt_confirmation.is_confirmed(receipt) else 0,
confirmed_by=receipt.confirmed_by,
confirmed_at=receipt.confirmed_at,
confirmation_revision=receipt_confirmation.revision(receipt),
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, query)
total = receipt_repository.count_receipts(session, query)
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 confirm_receipt(session, receipt_id, payload, *, operator, meta=None) -> ReceiptOut:
"""独立确认已核对内容;锁内复核指纹,重复确认不改首次确认人/时间。"""
from app.services.authorization_service import require_permission
require_permission(session, operator, "business:receipts:confirm")
with audit_service.audit(
session, operator=operator,
record=AuditRecord(module=MODULE_RECEIPT, action="人工确认", summary=f"人工确认回款 #{receipt_id}"),
meta=meta,
):
receipt = receipt_repository.get_by_id(session, receipt_id)
if receipt is None:
raise NotFoundError("回款不存在")
_ensure_business_writable(session, receipt.business_id, "确认")
session.refresh(receipt, with_for_update=True)
if payload.expected_revision != receipt_confirmation.revision(receipt):
raise ConflictError("回款内容已变化,请重新查看并核对后确认")
if not receipt_confirmation.is_confirmed(receipt):
receipt.confirmation_status = 1
receipt.confirmed_by = operator.username
receipt.confirmed_at = datetime.now()
receipt.confirmed_revision = payload.expected_revision
out = get_detail(session, receipt_id)
session.commit()
return out
@dataclass(frozen=True, slots=True)
class _CreatedReceipt:
"""建行结果(出参 + 固化的 ID/批次号,commit 后不再触碰 ORM 属性)。"""
out: ReceiptOut
receipt_id: int
batch_no: str
def _create_receipt_once(
session: Session,
payload: ReceiptCreateIn,
business: Business | None,
batch_no: str,
) -> _CreatedReceipt:
"""一次建行尝试:准入校验 → 建行 → 重算合计 → 出参。
**不 commit**:提交与回滚由调用方(重试循环)统一控制,保证「一次尝试
= 一个事务边界」。
"""
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))
return _CreatedReceipt(out=out, receipt_id=receipt.id, batch_no=batch_no)
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:
created = _create_receipt_once(session, payload, business, batch_no)
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",
created.receipt_id,
created.batch_no,
payload.business_id,
)
return created.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("回款不存在")
from app.services.business_source import ensure_source_editable
ensure_source_editable(session, "receipts", receipt_id)
business = _ensure_business_writable(session, receipt.business_id, "修改")
session.refresh(receipt, with_for_update=True)
if receipt.settlement_status == SETTLEMENT_DONE:
raise BadRequestError("已核算回款禁止修改")
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-18 23:59:49 -04:00
# 月结锁定拦截:原月与新月都不得落入已锁定月份(改到账日=跨月搬数,
# 只查新月会让「从锁定月改到未锁定月」绕过拦截)
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
)
old_revision = receipt_confirmation.revision(receipt)
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
if receipt_confirmation.revision(receipt) != old_revision:
receipt_confirmation.invalidate(receipt)
_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("回款不存在")
from app.services.business_source import ensure_source_editable
ensure_source_editable(session, "receipts", receipt_id)
if receipt.settlement_status == SETTLEMENT_DONE:
raise BadRequestError("已核算回款禁止删除")
business = _ensure_business_writable(session, receipt.business_id, "删除")
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-18 23:59:49 -04:00
# 月结锁定拦截:已锁定月份的回款是月结快照的源数据,不得删除
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)