PayrollManagementSystem/backend/app/services/receipt_service.py
liuwenxin b3de35c2ec fix(核算): 对齐抵顶口径并完善界面细节
回款确认时间收敛到秒级;抵顶回款不再自动折算并保留标记。补齐公式模板角色标签与提示、指导成本登记入口及基础布局、分页、设置等响应式细节。同步忽略本地排查产物。
2026-11-06 16:27:45 +08:00

416 lines
17 KiB
Python
Raw 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.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().replace(microsecond=0)
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("已核算回款禁止修改")
# 月结锁定拦截:原月与新月都不得落入已锁定月份(改到账日=跨月搬数,
# 只查新月会让「从锁定月改到未锁定月」绕过拦截)
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, "删除")
# 月结锁定拦截:已锁定月份的回款是月结快照的源数据,不得删除
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)