fix(inventory): 撤回/作废单据时释放预占库存,消除库存泄漏

问题
----
系统原本已有「完结」功能(出库/借库),但它只改状态、不释放预占:

    approval.status = 4   # 已完结
    db.session.commit()   # ← 库存没还回去

申请阶段 reserve_for_items() 扣掉的 available_quantity 就此永久泄漏 ——
货被一张永不执行的作废单锁死,谁也领不走。

核查存量 23 张 status=4 的单,所幸均为预占改造前提交(items_json 无
stock_id),尚未造成实际损失。但缺陷本身是真实的。

改动(三个模块统一)
--------------------
出库 outbound_service.close_request
借库 borrow_service.mark_completed
  · 调用 release_reserved(approval.get_items()) 按 items_json 原样归还;
  · 明确「仅 status==1(已通过待执行)可撤回」—— 执行成功后
    create_outbound_batch / execute_dispatch 会把 status 置为 3,
    故该状态判断本身即执行守卫,已执行或已撤回的单都进不来;
  · 返回消息带上释放条数,便于操作者确认。

报废 scrap_approval_service.withdraw(新增能力)
  · 报废原先只有 approve/reject,没有撤回入口,补齐;
  · 复用同一个 release_reserved():报废当前尚未接入预占,调用它会安全
    跳过(无 reserved 标记),但将来报废接入预占时该段代码自动生效;
  · 新增端点 POST /api/v1/scrap/request/<id>/withdraw(权限 scrap_apply)。

未采用按流水表二次校验:request_no(APR-OUT-…) 与 outbound_no(OUT-…)
格式不同、无关联字段,按单号比对是无效的,状态判断已足够。

实测
----
决定性用例(证明释放真实生效,非账面功夫):
  A单预占5 → available=1
  B单要5   → 400 拒绝(被A占住)
  撤回A    → available=6
  B单再要5 → 200 成功,available=1     ★ 释放的库存真的可被复用

三模块:
  出库 撤回后 4→10 完全恢复,stock 未变(货没动)
  借库 撤回后 6→10 完全恢复
  报废 撤回成功,重复撤回被正确拒绝

状态码说明:报废复用出库/借库已有的 4=已完结 作为「已撤回」,
而非引入 -1,避免同一系统出现两套编号(其 2 已被「已驳回」占用)。
This commit is contained in:
yueli
2026-09-10 15:08:32 +08:00
parent 8c18586a94
commit 9925bf2b99
4 changed files with 110 additions and 15 deletions

View File

@ -483,20 +483,37 @@ class BorrowApprovalService:
@staticmethod
def mark_completed(request_id):
"""
手动完结已通过的借库审批单status 1-已通过 → 4-已完结,对齐出库审批的完结语义)。
注意:真正“扫码借出执行完成”走 trans_service.execute_dispatch置 status=3(已完成)。
撤回 / 作废已通过的借库审批单status 1-已通过 → 4-已完结)。
注意:真正「扫码借出执行完成」走 trans_service.execute_dispatch
会将 status 置为 3(已完成);本方法只处理「已通过但尚未执行」的撤回。
★ 修复:原先只改状态、不释放预占 —— 申请阶段 reserve_for_items()
扣掉的 available_quantity 会永久泄漏,货被一张作废单锁死。
现调用 release_reserved() 按 items_json 原样归还。
执行守卫:执行成功后 status 会被置为 3故「仅 status==1 可撤回」
本身就是执行守卫,已执行或已撤回的单都无法再次操作。
"""
approval = BorrowApproval.query.get(request_id)
if not approval:
return False, "审批单不存在", None
if approval.status != 1:
return False, f"只有已通过的审批单才能标记为完结 (当前状态: {approval.status})", None
status_map = {0: '待审批', 1: '已通过', 2: '已驳回', 3: '已完成', 4: '已完结'}
return False, (
f"只有已通过(待执行)的审批单才能撤回 "
f"(当前状态: {status_map.get(approval.status, approval.status)})"
), None
try:
approval.status = 4 # 已完结
# ★ 释放全部预占,把 available_quantity 还回池子
from app.services.inventory_reservation import release_reserved
restored = release_reserved(approval.get_items())
approval.status = 4 # 已完结(撤回/作废)
db.session.commit()
return True, "审批单已完结", approval
return True, f"审批单已撤回,释放 {restored} 项预占库存", approval
except Exception as e:
db.session.rollback()
return False, f"操作失败: {str(e)}", None
return False, f"撤回归还失败: {str(e)}", None