2026-10-13 00:25:34 -04:00
|
|
|
|
"""回款管理业务编排(需求 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
|
2026-10-22 07:43:20 -04:00
|
|
|
|
from dataclasses import dataclass
|
2026-11-05 05:47:13 -05:00
|
|
|
|
from datetime import date, datetime
|
2026-10-13 00:25:34 -04:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
2026-10-14 03:17:00 -04:00
|
|
|
|
from app.core.db_errors import is_duplicate_entry
|
2026-11-05 05:47:13 -05:00
|
|
|
|
from app.core.exceptions import BadRequestError, ConflictError, NotFoundError
|
2026-10-13 00:25:34 -04:00
|
|
|
|
from app.core.response import PageResult
|
2026-10-22 07:43:20 -04:00
|
|
|
|
from app.models import Business, Receipt
|
2026-10-13 00:25:34 -04:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
2026-11-05 05:47:13 -05:00
|
|
|
|
from app.services import audit_service, receipt_confirmation
|
2026-10-13 00:25:34 -04:00
|
|
|
|
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__)
|
|
|
|
|
|
|
2026-10-14 03:09:05 -04:00
|
|
|
|
# 编号唯一键冲突后的重试次数(模式同 business_service._NO_RETRY)
|
|
|
|
|
|
_NO_RETRY = 1
|
|
|
|
|
|
|
2026-10-13 00:25:34 -04:00
|
|
|
|
|
|
|
|
|
|
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,
|
2026-11-05 05:47:13 -05:00
|
|
|
|
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),
|
2026-10-13 00:25:34 -04:00
|
|
|
|
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)。"""
|
2026-10-14 03:09:05 -04:00
|
|
|
|
return next_no(session, model=Receipt, column_name="receipt_batch_no", prefix=NO_PREFIX_RECEIPT)
|
2026-10-13 00:25:34 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 派生值)。"""
|
2026-10-22 07:43:20 -04:00
|
|
|
|
rows = receipt_repository.list_receipts(session, query)
|
|
|
|
|
|
total = receipt_repository.count_receipts(session, query)
|
2026-10-13 00:25:34 -04:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-11-05 05:47:13 -05:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-22 07:43:20 -04:00
|
|
|
|
@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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-13 00:25:34 -04:00
|
|
|
|
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,
|
|
|
|
|
|
):
|
2026-10-14 03:09:05 -04:00
|
|
|
|
# 批次号唯一键冲突的重试整体收在审计上下文内(模式同
|
|
|
|
|
|
# business_service.create_business):rollback 释放旧锁,重试前重新
|
|
|
|
|
|
# 锁业务行与生成批次号——成功/最终失败各只记一条审计,无递归
|
|
|
|
|
|
for attempt in range(_NO_RETRY + 1):
|
|
|
|
|
|
try:
|
2026-10-22 07:43:20 -04:00
|
|
|
|
created = _create_receipt_once(session, payload, business, batch_no)
|
2026-10-14 03:09:05 -04:00
|
|
|
|
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
|
2026-10-13 00:25:34 -04:00
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
"新增回款 | receipt_id=%s | no=%s | business_id=%s",
|
2026-10-22 07:43:20 -04:00
|
|
|
|
created.receipt_id,
|
|
|
|
|
|
created.batch_no,
|
2026-10-13 00:25:34 -04:00
|
|
|
|
payload.business_id,
|
|
|
|
|
|
)
|
2026-10-22 07:43:20 -04:00
|
|
|
|
return created.out
|
2026-10-13 00:25:34 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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("回款不存在")
|
2026-11-05 05:47:13 -05:00
|
|
|
|
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)
|
2026-10-13 00:25:34 -04:00
|
|
|
|
if receipt.settlement_status == SETTLEMENT_DONE:
|
|
|
|
|
|
raise BadRequestError("已核算回款禁止修改")
|
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"))
|
2026-10-14 21:32:35 -04:00
|
|
|
|
# 校验返回的 (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
|
|
|
|
|
|
)
|
2026-10-13 00:25:34 -04:00
|
|
|
|
|
2026-11-05 05:47:13 -05:00
|
|
|
|
old_revision = receipt_confirmation.revision(receipt)
|
2026-10-13 00:25:34 -04:00
|
|
|
|
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
|
2026-11-05 05:47:13 -05:00
|
|
|
|
if receipt_confirmation.revision(receipt) != old_revision:
|
|
|
|
|
|
receipt_confirmation.invalidate(receipt)
|
2026-10-13 00:25:34 -04:00
|
|
|
|
_recalc_totals(session, business)
|
2026-10-14 21:32:35 -04:00
|
|
|
|
out = _to_out((receipt, business.business_no, shipment_no, ship_date))
|
2026-10-13 00:25:34 -04:00
|
|
|
|
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("回款不存在")
|
2026-11-05 05:47:13 -05:00
|
|
|
|
from app.services.business_source import ensure_source_editable
|
|
|
|
|
|
|
|
|
|
|
|
ensure_source_editable(session, "receipts", receipt_id)
|
2026-10-13 00:25:34 -04:00
|
|
|
|
if receipt.settlement_status == SETTLEMENT_DONE:
|
|
|
|
|
|
raise BadRequestError("已核算回款禁止删除")
|
|
|
|
|
|
business = _ensure_business_writable(session, receipt.business_id, "删除")
|
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"))
|
2026-10-13 00:25:34 -04:00
|
|
|
|
|
|
|
|
|
|
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 同口径。"""
|
2026-10-14 03:17:00 -04:00
|
|
|
|
return is_duplicate_entry(exc)
|