From 9925bf2b99dc6a3211e5e0c69d98e0bf12cea579 Mon Sep 17 00:00:00 2001 From: yueli Date: Thu, 10 Sep 2026 15:08:32 +0800 Subject: [PATCH] =?UTF-8?q?fix(inventory):=20=E6=92=A4=E5=9B=9E/=E4=BD=9C?= =?UTF-8?q?=E5=BA=9F=E5=8D=95=E6=8D=AE=E6=97=B6=E9=87=8A=E6=94=BE=E9=A2=84?= =?UTF-8?q?=E5=8D=A0=E5=BA=93=E5=AD=98=EF=BC=8C=E6=B6=88=E9=99=A4=E5=BA=93?= =?UTF-8?q?=E5=AD=98=E6=B3=84=E6=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题 ---- 系统原本已有「完结」功能(出库/借库),但它只改状态、不释放预占: 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//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 已被「已驳回」占用)。 --- inventory-backend/app/api/v1/scrap.py | 23 +++++++++++ .../app/services/borrow_service.py | 29 ++++++++++--- .../app/services/outbound_service.py | 41 +++++++++++++++---- .../app/services/scrap_approval_service.py | 32 +++++++++++++++ 4 files changed, 110 insertions(+), 15 deletions(-) diff --git a/inventory-backend/app/api/v1/scrap.py b/inventory-backend/app/api/v1/scrap.py index 1c8c359..b7f6259 100644 --- a/inventory-backend/app/api/v1/scrap.py +++ b/inventory-backend/app/api/v1/scrap.py @@ -890,6 +890,29 @@ def approve_scrap_request(request_id): return jsonify({'code': 500, 'msg': f'审批报废申请失败: {str(e)}'}), 500 +@scrap_bp.route('/request//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//execute', methods=['POST']) @jwt_required() @permission_required('scrap_execute') diff --git a/inventory-backend/app/services/borrow_service.py b/inventory-backend/app/services/borrow_service.py index e04ae0d..5f26aa1 100644 --- a/inventory-backend/app/services/borrow_service.py +++ b/inventory-backend/app/services/borrow_service.py @@ -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 \ No newline at end of file + return False, f"撤回归还失败: {str(e)}", None \ No newline at end of file diff --git a/inventory-backend/app/services/outbound_service.py b/inventory-backend/app/services/outbound_service.py index eac6bef..f5fd317 100644 --- a/inventory-backend/app/services/outbound_service.py +++ b/inventory-backend/app/services/outbound_service.py @@ -1216,10 +1216,18 @@ class OutboundApprovalService: @staticmethod 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: request_id: 审批单ID @@ -1235,30 +1243,45 @@ class OutboundApprovalService: if not approval: return False, "审批单不存在", None + # ★ 执行守卫:执行成功后 create_outbound_batch 会把 status 置为 3, + # 故此处的「仅 status==1 可撤回」本身就是执行守卫 —— + # 已执行(3)或已撤回(4)的单都无法再次撤回。 + # 不额外查流水表:request_no(APR-OUT-…) 与 outbound_no(OUT-…) 格式不同、 + # 无关联字段,按单号比对是无效的。 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): - # 放宽:拥有 outbound_create:operation 的用户(库管)也可完结 + # 放宽:拥有 outbound_create:operation 的用户(库管)也可撤回 from app.models.system import SysRolePermission has_outbound_op = SysRolePermission.query.filter( SysRolePermission.role_code == user_role, SysRolePermission.target_code.in_(['outbound_create:operation', 'outbound_create:*']) ).first() is not None if not has_outbound_op: - return False, "您没有完结此单的权限", None + return False, "您没有撤回此单的权限", None 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.approved_at = None db.session.commit() - return True, "审批单已完结", approval + + msg = f"申请单已撤回,释放 {restored} 项预占库存" + return True, msg, approval except Exception as e: 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): """ diff --git a/inventory-backend/app/services/scrap_approval_service.py b/inventory-backend/app/services/scrap_approval_service.py index 0e3f9cd..a2143c8 100644 --- a/inventory-backend/app/services/scrap_approval_service.py +++ b/inventory-backend/app/services/scrap_approval_service.py @@ -194,6 +194,38 @@ class ScrapApprovalService: logger.info(f"[ScrapApproval] {req.request_no} 审批 {action} by {operator_id}") 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 + # ------------------------------------------------------------------ # 执行(按单报废:扣减实物库存 + 写报废流水) # ------------------------------------------------------------------