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:
@ -890,6 +890,29 @@ def approve_scrap_request(request_id):
|
|||||||
return jsonify({'code': 500, 'msg': f'审批报废申请失败: {str(e)}'}), 500
|
return jsonify({'code': 500, 'msg': f'审批报废申请失败: {str(e)}'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@scrap_bp.route('/request/<int:request_id>/withdraw', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
@permission_required('scrap_apply')
|
||||||
|
def withdraw_scrap_request(request_id):
|
||||||
|
"""
|
||||||
|
撤回已通过但尚未执行的报废申请(status 1 → 4)。
|
||||||
|
|
||||||
|
与出库/借库的撤回语义一致:作废该单并释放其预占库存
|
||||||
|
(报废模块若未接入预占,release_reserved 会安全跳过)。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from app.services.scrap_approval_service import ScrapApprovalService
|
||||||
|
req = ScrapApprovalService.withdraw(
|
||||||
|
request_id, operator_id=int(get_jwt_identity())
|
||||||
|
)
|
||||||
|
return jsonify({'code': 200, 'msg': '报废申请已撤回', 'data': req.to_dict()}), 200
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({'code': 400, 'msg': str(e)}), 400
|
||||||
|
except Exception as e:
|
||||||
|
traceback.print_exc()
|
||||||
|
return jsonify({'code': 500, 'msg': f'撤回报废申请失败: {str(e)}'}), 500
|
||||||
|
|
||||||
|
|
||||||
@scrap_bp.route('/request/<int:request_id>/execute', methods=['POST'])
|
@scrap_bp.route('/request/<int:request_id>/execute', methods=['POST'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
@permission_required('scrap_execute')
|
@permission_required('scrap_execute')
|
||||||
|
|||||||
@ -483,20 +483,37 @@ class BorrowApprovalService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def mark_completed(request_id):
|
def mark_completed(request_id):
|
||||||
"""
|
"""
|
||||||
手动完结已通过的借库审批单(status 1-已通过 → 4-已完结,对齐出库审批的完结语义)。
|
撤回 / 作废已通过的借库审批单(status 1-已通过 → 4-已完结)。
|
||||||
注意:真正“扫码借出执行完成”走 trans_service.execute_dispatch,置 status=3(已完成)。
|
|
||||||
|
注意:真正「扫码借出执行完成」走 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)
|
approval = BorrowApproval.query.get(request_id)
|
||||||
if not approval:
|
if not approval:
|
||||||
return False, "审批单不存在", None
|
return False, "审批单不存在", None
|
||||||
|
|
||||||
if approval.status != 1:
|
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:
|
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()
|
db.session.commit()
|
||||||
return True, "审批单已完结", approval
|
return True, f"审批单已撤回,释放 {restored} 项预占库存", approval
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
return False, f"操作失败: {str(e)}", None
|
return False, f"撤回归还失败: {str(e)}", None
|
||||||
@ -1216,10 +1216,18 @@ class OutboundApprovalService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def close_request(request_id, user_id, user_role):
|
def close_request(request_id, user_id, user_role):
|
||||||
"""
|
"""
|
||||||
手动完结/作废审批单(状态 1-已通过 → 4-已完结)
|
撤回 / 作废审批单(状态 1-已通过 → 4-已完结)
|
||||||
|
|
||||||
适用场景:已通过但无法出库/作废的单据,库管手动清理,
|
适用场景:已通过但尚未(或未完整)执行的单据,申请人或管理者撤回,
|
||||||
使其从"已审批通过"列表中消失。
|
使其从"已审批通过"列表中消失,并**释放预占的库存**。
|
||||||
|
|
||||||
|
★ 修复说明(两处严重缺陷):
|
||||||
|
1. 原先只改状态、不释放预占 —— 申请阶段 reserve_for_items() 扣掉的
|
||||||
|
available_quantity 就此永久泄漏,货被一张作废单锁死,谁也领不走。
|
||||||
|
现调用 release_reserved() 按 items_json 原样归还。
|
||||||
|
2. 原先无执行守卫 —— status=1 的单无论是否已出过货都能被"完结",
|
||||||
|
会把一张已部分执行的单标成"已完结"。
|
||||||
|
现明确只允许「已通过且未执行」的单据撤回。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request_id: 审批单ID
|
request_id: 审批单ID
|
||||||
@ -1235,30 +1243,45 @@ class OutboundApprovalService:
|
|||||||
if not approval:
|
if not approval:
|
||||||
return False, "审批单不存在", None
|
return False, "审批单不存在", None
|
||||||
|
|
||||||
|
# ★ 执行守卫:执行成功后 create_outbound_batch 会把 status 置为 3,
|
||||||
|
# 故此处的「仅 status==1 可撤回」本身就是执行守卫 ——
|
||||||
|
# 已执行(3)或已撤回(4)的单都无法再次撤回。
|
||||||
|
# 不额外查流水表:request_no(APR-OUT-…) 与 outbound_no(OUT-…) 格式不同、
|
||||||
|
# 无关联字段,按单号比对是无效的。
|
||||||
if approval.status != 1:
|
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
|
||||||
|
|
||||||
# 权限检查:超级管理员、审批人 或 拥有出库操作权限(库管/主管)
|
# 权限检查:超级管理员、审批人 或 拥有出库操作权限(库管/主管)
|
||||||
if not OutboundApprovalService.can_approve(approval, user_id, user_role):
|
if not OutboundApprovalService.can_approve(approval, user_id, user_role):
|
||||||
# 放宽:拥有 outbound_create:operation 的用户(库管)也可完结
|
# 放宽:拥有 outbound_create:operation 的用户(库管)也可撤回
|
||||||
from app.models.system import SysRolePermission
|
from app.models.system import SysRolePermission
|
||||||
has_outbound_op = SysRolePermission.query.filter(
|
has_outbound_op = SysRolePermission.query.filter(
|
||||||
SysRolePermission.role_code == user_role,
|
SysRolePermission.role_code == user_role,
|
||||||
SysRolePermission.target_code.in_(['outbound_create:operation', 'outbound_create:*'])
|
SysRolePermission.target_code.in_(['outbound_create:operation', 'outbound_create:*'])
|
||||||
).first() is not None
|
).first() is not None
|
||||||
if not has_outbound_op:
|
if not has_outbound_op:
|
||||||
return False, "您没有完结此单的权限", None
|
return False, "您没有撤回此单的权限", None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
approval.status = 4 # 4-已完结(手动作废)
|
# ★ 释放全部预占,把 available_quantity 还回池子
|
||||||
|
from app.services.inventory_reservation import release_reserved
|
||||||
|
restored = release_reserved(approval.get_items())
|
||||||
|
|
||||||
|
approval.status = 4 # 4-已完结(撤回/作废)
|
||||||
approval.actual_approver_id = user_id
|
approval.actual_approver_id = user_id
|
||||||
approval.approved_at = None
|
approval.approved_at = None
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return True, "审批单已完结", approval
|
|
||||||
|
msg = f"申请单已撤回,释放 {restored} 项预占库存"
|
||||||
|
return True, msg, approval
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
return False, f"完结失败: {str(e)}", None
|
return False, f"撤回归还失败: {str(e)}", None
|
||||||
|
|
||||||
def get_request_list(page=1, per_page=10, applicant_id=None, status=None):
|
def get_request_list(page=1, per_page=10, applicant_id=None, status=None):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -194,6 +194,38 @@ class ScrapApprovalService:
|
|||||||
logger.info(f"[ScrapApproval] {req.request_no} 审批 {action} by {operator_id}")
|
logger.info(f"[ScrapApproval] {req.request_no} 审批 {action} by {operator_id}")
|
||||||
return req
|
return req
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 撤回 / 作废(已通过但未执行 → 4-已撤回)
|
||||||
|
#
|
||||||
|
# 报废模块当前**尚未接入库存预占**(提交时不锁库存,扣减发生在执行阶段),
|
||||||
|
# 因此这里没有可释放的预占。仍显式调用 release_reserved():
|
||||||
|
# · 对存量单据无害(无 reserved 标记会被跳过);
|
||||||
|
# · 若将来报废接入预占,此处无需再改,自动生效。
|
||||||
|
# 状态守卫「仅 status==1 可撤回」同时充当执行守卫:
|
||||||
|
# 已执行(status=3)的单无法再次撤回。
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@staticmethod
|
||||||
|
def withdraw(request_id, operator_id):
|
||||||
|
"""撤回已通过但尚未执行的报废申请单"""
|
||||||
|
req = db.session.get(ScrapApproval, request_id)
|
||||||
|
if not req:
|
||||||
|
raise ValueError("报废申请不存在")
|
||||||
|
if req.status != 1:
|
||||||
|
status_map = {0: '待审批', 1: '已通过(待执行)', 2: '已驳回', 3: '已执行', 4: '已撤回'}
|
||||||
|
raise ValueError(
|
||||||
|
f"仅「已通过(待执行)」的报废申请可撤回 "
|
||||||
|
f"(当前状态:{status_map.get(req.status, req.status)})"
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.services.inventory_reservation import release_reserved
|
||||||
|
restored = release_reserved(req.get_items())
|
||||||
|
|
||||||
|
req.status = 4 # 4-已撤回
|
||||||
|
req.reject_reason = '申请人撤回'
|
||||||
|
db.session.commit()
|
||||||
|
logger.info(f"[ScrapApproval] {req.request_no} 已撤回 by {operator_id},释放 {restored} 项")
|
||||||
|
return req
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# 执行(按单报废:扣减实物库存 + 写报废流水)
|
# 执行(按单报废:扣减实物库存 + 写报废流水)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user