Files
KCGL/inventory-backend/app/services/scrap_approval_service.py

427 lines
18 KiB
Python
Raw Normal View History

import logging
from datetime import datetime, timezone, timedelta
from sqlalchemy import func
from app.extensions import db, beijing_time
from app.models.scrap_approval import ScrapApproval
from app.models.transaction import TransScrap
logger = logging.getLogger(__name__)
def _beijing():
"""
统一时间口径:naive 北京时间。
★ scrap_approval 的时间列已统一为 timestamp without time zone
(见 db_migrations/unify_approval_timezone.sql),必须返回 naive 值,
否则 aware 值会被驱动转成 UTC 存库,比 created_at 早 8 小时。
"""
return beijing_time()
# =============================================================================
# ★ 业务规则(单一事实来源):报废一律需审批
#
# 与出库/借库不同,报废不过滤 is_approval_required —— 无论物料是否命中该标记,
# 所有报废申请都必须由指定审批人审批通过后才能执行。
# 前端 apply/index.vue 的「审批人」必填项与此规则保持一致。
# =============================================================================
SCRAP_ALWAYS_REQUIRES_APPROVAL = True
STOCK_MODELS = {}
def _stock_models():
"""延迟导入三张实物库存表,避免循环依赖"""
if not STOCK_MODELS:
from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct
STOCK_MODELS.update({
'stock_buy': StockBuy,
'stock_semi': StockSemi,
'stock_product': StockProduct,
})
return STOCK_MODELS
class ScrapApprovalService:
@staticmethod
def generate_request_no():
now = _beijing()
prefix = f"APR-SCRAP-{now.strftime('%Y%m%d-%H%M')}-"
n = db.session.query(func.count(func.distinct(ScrapApproval.request_no))) \
.filter(ScrapApproval.request_no.like(f"{prefix}%")).scalar() or 0
return f"{prefix}{(n + 1):04d}"
# ------------------------------------------------------------------
# 提交申请
# ------------------------------------------------------------------
@staticmethod
def submit_approval(applicant_id, items, allowed_approvers=None, remark=None,
approver_id=None, force_approval=False):
"""
提交报废申请(仅锁定“意向”,不扣库存;扣减在库管执行时进行)
items 每项必须包含 source_table + stock_id(精准实物),可带 scrap_qty / 快照字段。
"""
if not items:
raise ValueError("报废明细不能为空")
models = _stock_models()
normalized = []
for idx, it in enumerate(items):
st = (it.get('source_table') or '').strip()
model = models.get(st)
sid = it.get('stock_id')
if not model or not sid:
raise ValueError(f"第 {idx + 1} 条报废明细必须指定 source_table 与 stock_id(精准库存行)")
try:
sid = int(sid)
except (TypeError, ValueError):
raise ValueError(f"第 {idx + 1} 条 stock_id 无效")
row = model.query.get(sid)
if not row:
raise ValueError(f"第 {idx + 1} 条对应的库存记录不存在")
try:
qty = float(it.get('scrap_qty') or 0)
except (TypeError, ValueError):
raise ValueError(f"第 {idx + 1} 条报废数量无效")
if qty <= 0:
raise ValueError(f"第 {idx + 1} 条报废数量必须大于 0")
avail = float(getattr(row, 'available_quantity', 0) or 0)
if qty > avail:
raise ValueError(f"第 {idx + 1} 条报废数量({qty})超过可用库存({avail})")
base = getattr(row, 'base', None)
normalized.append({
'source_table': st,
'stock_id': sid,
'base_id': getattr(row, 'base_id', None),
'sku': getattr(row, 'sku', '') or '',
'name': (base.name if base else '') or it.get('name') or '',
'spec_model': (base.spec_model if base else '') or it.get('spec_model') or '',
'location': getattr(row, 'warehouse_location', '') or '',
'batch_number': getattr(row, 'batch_number', '') or getattr(row, 'serial_number', '') or '',
'scrap_qty': qty,
'available_at_apply': avail,
})
# ★ 报废一律需审批(见 SCRAP_ALWAYS_REQUIRES_APPROVAL)。
# resolve_approval_control 仍调用,但仅用于生成「哪些物料命中需审批」的提示文案,
# 不再用它决定是否需要审批。
from app.services.approval_control import resolve_approval_control
_, flagged_materials = resolve_approval_control(normalized)
if not approver_id:
if flagged_materials:
_names = ";".join(f"{m['name']}({m['spec_model'] or '-'})" for m in flagged_materials)
raise ValueError(f"以下物料需审批报废:{_names}。请选择审批人后再提交")
raise ValueError("报废申请必须选择审批人后再提交")
allowed_approvers = [{"type": "user", "value": int(approver_id)}]
req = ScrapApproval(
request_no=ScrapApprovalService.generate_request_no(),
applicant_id=applicant_id,
remark=remark,
)
req.set_items(normalized)
req.set_allowed_approvers(allowed_approvers)
# ★ 恒为「待审批」,不再走免审批自动通过分支
req.status = 0
db.session.add(req)
db.session.commit()
logger.info(f"[ScrapApproval] 提交成功 {req.request_no} approver={approver_id}")
return req
# ------------------------------------------------------------------
# 列表
# ------------------------------------------------------------------
@staticmethod
def get_list(page=1, limit=10, status=None, applicant_id=None, approver_id=None):
query = ScrapApproval.query
if status is not None:
query = query.filter(ScrapApproval.status == status)
if applicant_id is not None:
query = query.filter(ScrapApproval.applicant_id == applicant_id)
if approver_id is not None:
query = query.filter(ScrapApproval.allowed_approvers.like(f'%"value": {approver_id}%'))
query = query.order_by(ScrapApproval.created_at.desc())
pg = query.paginate(page=page, per_page=limit, error_out=False)
return {
'items': [r.to_dict() for r in pg.items],
'total': pg.total,
'pages': pg.pages,
'current_page': page,
}
# ------------------------------------------------------------------
# 审批
# ------------------------------------------------------------------
@staticmethod
def approve(request_id, operator_id, action, reject_reason=None):
req = db.session.get(ScrapApproval, request_id)
if not req:
raise ValueError("报废申请不存在")
if req.status != 0:
raise ValueError("当前状态不允许审批(仅待审批可操作)")
# 仅被指定的审批人可操作
allowed = req.get_allowed_approvers() or []
user_entries = [str(a.get('value')) for a in allowed if a.get('type') == 'user']
if user_entries and str(operator_id) not in user_entries:
raise ValueError("只有被指定的审批人可以审批该申请")
if action == 'approve':
req.status = 1
req.actual_approver_id = operator_id
req.approved_at = _beijing()
req.reject_reason = None
elif action == 'reject':
req.status = 2
req.actual_approver_id = operator_id
req.reject_reason = reject_reason or '未说明原因'
else:
raise ValueError("无效的审批动作")
db.session.commit()
logger.info(f"[ScrapApproval] {req.request_no} 审批 {action} by {operator_id}")
return req
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 已被「已驳回」占用)。
2026-09-10 15:08:32 +08:00
# ------------------------------------------------------------------
# 撤回 / 作废(已通过但未执行 → 4-已撤回)
#
# 报废模块当前**尚未接入库存预占**(提交时不锁库存,扣减发生在执行阶段),
# 因此这里没有可释放的预占。仍显式调用 release_reserved():
# · 对存量单据无害(无 reserved 标记会被跳过);
# · 若将来报废接入预占,此处无需再改,自动生效。
# 状态守卫「仅 status==1 可撤回」同时充当执行守卫:
# 已执行(status=3)的单无法再次撤回。
# ------------------------------------------------------------------
feat(borrow,scrap): 补齐申请人撤回端点,与出库对齐 背景 ---- 出库已有独立的申请人撤回端点(仅 @jwt_required + 服务层归属断言), 但借库/报废没有: · 借库撤回复用 close 端点,权限是 op_borrow_approval(管理路径), 普通员工调用返回 403; · 报废撤回带 @permission_required('scrap_apply'),同样挡住普通申请人 (scrap_apply 只授予 INBOUND/OUTBOUND/SUPERVISOR/SUPER_ADMIN)。 结果是「我的申请单」页面里,借库/报废的撤回按钮对普通员工点了报错。 借库 ---- 服务层拆成与管理路径并列的两条入口(与出库同构): mark_completed —— 管理路径,需 op_borrow_approval,仅 status==1 withdraw_request —— 申请人路径,仅校验单据归属,status 0 或 1 两者共用 _release_and_close(),释放逻辑只有一份实现。 新增 POST /transactions/borrow/request/<id>/withdraw(仅 @jwt_required)。 报废 ---- withdraw() 增加 require_owner 参数区分两条路径: require_owner=True (默认,申请人路径)→ 断言 applicant_id == operator_id require_owner=False(管理路径) → 由调用方权限装饰器鉴权 WITHDRAWABLE_STATUS 从 (1,) 放宽到 (0, 1),与出库/借库对齐。 移除端点上的 @permission_required('scrap_apply'),改走归属校验。 安全实测 -------- 借库 B 撤 A 的单 → 403,库存仍是 27(未被释放) 借库 A 撤自己的单 → 200,30 完全释放 报废 B 撤 A 的单 → 403 报废 A 撤自己的单 → 200 主管代撤员工的单(特权路径)→ 200
2026-09-10 15:46:55 +08:00
WITHDRAWABLE_STATUS = (0, 1)
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 已被「已驳回」占用)。
2026-09-10 15:08:32 +08:00
@staticmethod
feat(borrow,scrap): 补齐申请人撤回端点,与出库对齐 背景 ---- 出库已有独立的申请人撤回端点(仅 @jwt_required + 服务层归属断言), 但借库/报废没有: · 借库撤回复用 close 端点,权限是 op_borrow_approval(管理路径), 普通员工调用返回 403; · 报废撤回带 @permission_required('scrap_apply'),同样挡住普通申请人 (scrap_apply 只授予 INBOUND/OUTBOUND/SUPERVISOR/SUPER_ADMIN)。 结果是「我的申请单」页面里,借库/报废的撤回按钮对普通员工点了报错。 借库 ---- 服务层拆成与管理路径并列的两条入口(与出库同构): mark_completed —— 管理路径,需 op_borrow_approval,仅 status==1 withdraw_request —— 申请人路径,仅校验单据归属,status 0 或 1 两者共用 _release_and_close(),释放逻辑只有一份实现。 新增 POST /transactions/borrow/request/<id>/withdraw(仅 @jwt_required)。 报废 ---- withdraw() 增加 require_owner 参数区分两条路径: require_owner=True (默认,申请人路径)→ 断言 applicant_id == operator_id require_owner=False(管理路径) → 由调用方权限装饰器鉴权 WITHDRAWABLE_STATUS 从 (1,) 放宽到 (0, 1),与出库/借库对齐。 移除端点上的 @permission_required('scrap_apply'),改走归属校验。 安全实测 -------- 借库 B 撤 A 的单 → 403,库存仍是 27(未被释放) 借库 A 撤自己的单 → 200,30 完全释放 报废 B 撤 A 的单 → 403 报废 A 撤自己的单 → 200 主管代撤员工的单(特权路径)→ 200
2026-09-10 15:46:55 +08:00
def withdraw(request_id, operator_id, require_owner=True):
"""
撤回报废申请单(待审批 或 已通过但未执行)。
require_owner=True(默认,申请人路径):
断言 applicant_id == operator_id,否则拒绝(特权角色可代撤)。
require_owner=False(管理路径,审批页调用):
由调用方的权限装饰器(scrap_approval)负责鉴权。
与出库/借库保持一致的两条独立入口 + 共用释放底层。
"""
from app.utils.decorators import is_privileged_viewer
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 已被「已驳回」占用)。
2026-09-10 15:08:32 +08:00
req = db.session.get(ScrapApproval, request_id)
if not req:
raise ValueError("报废申请不存在")
feat(borrow,scrap): 补齐申请人撤回端点,与出库对齐 背景 ---- 出库已有独立的申请人撤回端点(仅 @jwt_required + 服务层归属断言), 但借库/报废没有: · 借库撤回复用 close 端点,权限是 op_borrow_approval(管理路径), 普通员工调用返回 403; · 报废撤回带 @permission_required('scrap_apply'),同样挡住普通申请人 (scrap_apply 只授予 INBOUND/OUTBOUND/SUPERVISOR/SUPER_ADMIN)。 结果是「我的申请单」页面里,借库/报废的撤回按钮对普通员工点了报错。 借库 ---- 服务层拆成与管理路径并列的两条入口(与出库同构): mark_completed —— 管理路径,需 op_borrow_approval,仅 status==1 withdraw_request —— 申请人路径,仅校验单据归属,status 0 或 1 两者共用 _release_and_close(),释放逻辑只有一份实现。 新增 POST /transactions/borrow/request/<id>/withdraw(仅 @jwt_required)。 报废 ---- withdraw() 增加 require_owner 参数区分两条路径: require_owner=True (默认,申请人路径)→ 断言 applicant_id == operator_id require_owner=False(管理路径) → 由调用方权限装饰器鉴权 WITHDRAWABLE_STATUS 从 (1,) 放宽到 (0, 1),与出库/借库对齐。 移除端点上的 @permission_required('scrap_apply'),改走归属校验。 安全实测 -------- 借库 B 撤 A 的单 → 403,库存仍是 27(未被释放) 借库 A 撤自己的单 → 200,30 完全释放 报废 B 撤 A 的单 → 403 报废 A 撤自己的单 → 200 主管代撤员工的单(特权路径)→ 200
2026-09-10 15:46:55 +08:00
# ★ 归属校验(仅申请人路径)
if require_owner and not is_privileged_viewer():
if int(req.applicant_id) != int(operator_id):
raise PermissionError("无权撤回他人的申请单")
if req.status not in ScrapApprovalService.WITHDRAWABLE_STATUS:
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 已被「已驳回」占用)。
2026-09-10 15:08:32 +08:00
status_map = {0: '待审批', 1: '已通过(待执行)', 2: '已驳回', 3: '已执行', 4: '已撤回'}
raise ValueError(
feat(borrow,scrap): 补齐申请人撤回端点,与出库对齐 背景 ---- 出库已有独立的申请人撤回端点(仅 @jwt_required + 服务层归属断言), 但借库/报废没有: · 借库撤回复用 close 端点,权限是 op_borrow_approval(管理路径), 普通员工调用返回 403; · 报废撤回带 @permission_required('scrap_apply'),同样挡住普通申请人 (scrap_apply 只授予 INBOUND/OUTBOUND/SUPERVISOR/SUPER_ADMIN)。 结果是「我的申请单」页面里,借库/报废的撤回按钮对普通员工点了报错。 借库 ---- 服务层拆成与管理路径并列的两条入口(与出库同构): mark_completed —— 管理路径,需 op_borrow_approval,仅 status==1 withdraw_request —— 申请人路径,仅校验单据归属,status 0 或 1 两者共用 _release_and_close(),释放逻辑只有一份实现。 新增 POST /transactions/borrow/request/<id>/withdraw(仅 @jwt_required)。 报废 ---- withdraw() 增加 require_owner 参数区分两条路径: require_owner=True (默认,申请人路径)→ 断言 applicant_id == operator_id require_owner=False(管理路径) → 由调用方权限装饰器鉴权 WITHDRAWABLE_STATUS 从 (1,) 放宽到 (0, 1),与出库/借库对齐。 移除端点上的 @permission_required('scrap_apply'),改走归属校验。 安全实测 -------- 借库 B 撤 A 的单 → 403,库存仍是 27(未被释放) 借库 A 撤自己的单 → 200,30 完全释放 报废 B 撤 A 的单 → 403 报废 A 撤自己的单 → 200 主管代撤员工的单(特权路径)→ 200
2026-09-10 15:46:55 +08:00
f"当前状态不可撤回:{status_map.get(req.status, req.status)}"
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 已被「已驳回」占用)。
2026-09-10 15:08:32 +08:00
)
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
# ------------------------------------------------------------------
# 执行(按单报废:扣减实物库存 + 写报废流水)
# ------------------------------------------------------------------
@staticmethod
def _to_int(v):
try:
return int(v)
except (TypeError, ValueError):
return None
@staticmethod
def _norm_sku(sku):
"""SKU 归一化:去首尾空白。空 SKU 返回 '',由调用方回退到行级匹配键。"""
return str(sku or '').strip()
@staticmethod
def _match_key(source_table, stock_id, sku):
"""
★ 扫码匹配键 —— SKU 优先。
已核验:SKU 在同一库存表内唯一,且不存在跨表重名(stock_buy /
stock_semi / stock_product 三表交叉无冲突),故 SKU 可安全作为
跨来源的稳定标识。
例外:个别历史库存行的 SKU 为空,这类行无法用 SKU 标识,回退为
source_table + stock_id 复合键。前缀区分('sku:' / 'row:')保证
空 SKU 行绝不会与任何正常 SKU 串键。
"""
s = ScrapApprovalService._norm_sku(sku)
if s:
return ('sku', s)
return ('row', f"{source_table}#{stock_id}")
@staticmethod
def _build_approved_index(items):
"""
申请单明细 → {匹配键: 批准信息}
每项保留其 source_table + stock_id 清单(rows),执行时据此定位到
批准时指定的那条库存记录做加锁扣减。同一 SKU 若在批准单中出现多行,
数量累加、rows 按批准顺序保留。
"""
index = {}
for it in items:
sid = ScrapApprovalService._to_int(it.get('stock_id'))
st = str(it.get('source_table') or '').strip()
if not st or sid is None:
continue
key = ScrapApprovalService._match_key(st, sid, it.get('sku'))
entry = index.setdefault(key, {
'qty': 0.0,
'label': ScrapApprovalService._norm_sku(it.get('sku'))
or it.get('name') or f"{st}#{sid}",
'rows': [],
})
qty = float(it.get('scrap_qty') or 0)
entry['qty'] += qty
entry['rows'].append({'source_table': st, 'stock_id': sid, 'qty': qty})
return index
@staticmethod
def _build_scanned_index(scanned_items, models):
"""前端实扫明细 → {匹配键: 累计扫码数量}"""
index = {}
for idx, s in enumerate(scanned_items):
sku = ScrapApprovalService._norm_sku(s.get('sku'))
st = str(s.get('source_table') or '').strip()
sid = ScrapApprovalService._to_int(s.get('stock_id'))
if not sku and (not st or sid is None):
raise ValueError(f"第 {idx + 1} 条扫码明细缺少 SKU,且无有效的 source_table / stock_id")
if st and st not in models:
raise ValueError(f"第 {idx + 1} 条扫码来源不支持:{st}")
qty = float(s.get('quantity') or 0)
if qty <= 0:
raise ValueError(f"第 {idx + 1} 条扫码数量必须大于 0")
key = ScrapApprovalService._match_key(st, sid, sku)
entry = index.setdefault(key, {
'qty': 0.0,
'label': sku or s.get('name') or f"{st}#{sid}",
})
entry['qty'] += qty
return index
@staticmethod
def execute(request_id, operator_name='System', scanned_items=None):
"""
按单执行报废:以「实际扫码明细」为准,按 SKU 匹配批准明细后扣减库存。
scanned_items: [{'sku', 'quantity', 'source_table', 'stock_id'(可选,空 SKU 时必填)}]
· 以 SKU 为主校验键:扫码 SKU 必须在申请单 items_json 中存在;
· 同一 SKU 多次扫码累加,累计不得超过该 SKU 的批准总量;
· 允许合法子集(少扫 = 本次不报废该行);
· 扣减时以批准单配对的 source_table + stock_id 定位库存行加锁。
"""
models = _stock_models()
req = db.session.get(ScrapApproval, request_id)
if not req:
raise ValueError("报废申请不存在")
if req.status != 1:
raise ValueError("仅“已通过(待执行)”的报废单可执行")
approved_items = req.get_items()
if not approved_items:
raise ValueError("报废明细为空,无法执行")
# ★ 强制按单扫码:未提交实扫明细不允许执行
if not scanned_items:
raise ValueError("请先扫码并提交实际报废物料,再执行报废")
approved = ScrapApprovalService._build_approved_index(approved_items)
scanned = ScrapApprovalService._build_scanned_index(scanned_items, models)
# ★ 校验一:扫码 SKU 必须在批准明细内
for key, acc in scanned.items():
if key not in approved:
raise ValueError(
f"SKU【{acc['label']}】不在该报废申请单的批准明细中(SKU 不匹配),禁止报废"
)
# ★ 校验二:同一 SKU 的累计扫码量不得超过批准总量
for key, acc in scanned.items():
appr = approved[key]
if acc['qty'] > appr['qty']:
raise ValueError(
f"SKU【{acc['label']}】扫码数量({acc['qty']})超出批准数量({appr['qty']}),禁止报废"
)
# ★ 扣减:按批准单配对的 source_table + stock_id 定位库存行,逐行加锁扣减
for key, acc in scanned.items():
appr = approved[key]
remaining = acc['qty']
for ref in appr['rows']:
if remaining <= 0:
break
take = min(remaining, ref['qty'])
if take <= 0:
continue
st, sid = ref['source_table'], ref['stock_id']
row = models[st].query.with_for_update().get(sid)
if not row:
raise ValueError(f"库存记录已不存在({acc['label']})")
avail = float(getattr(row, 'available_quantity', 0) or 0)
stock = float(getattr(row, 'stock_quantity', 0) or 0)
if take > avail:
raise ValueError(f"库存 SKU【{acc['label']}】可用不足(剩 {avail}),无法报废 {take}")
if take > stock:
raise ValueError(f"库存 SKU【{acc['label']}】实物不足(剩 {stock}),无法报废 {take}")
# ★ 真正扣减:报废 = 实物销毁,实物库存与可用库存需同时扣减
row.available_quantity = avail - take
row.stock_quantity = stock - take
db.session.flush()
# 写报废流水(台账)
db.session.add(TransScrap(
sku=getattr(row, 'sku', '') or acc['label'],
source_table=st,
stock_id=sid,
quantity=take,
reason=req.remark or '',
operator_name=operator_name,
approver_name=ScrapApproval._user_name(req.actual_approver_id),
approval_status='executed',
scrap_request_no=req.request_no,
))
remaining -= take
req.status = 3
req.executed_at = _beijing()
req.executor_name = operator_name
db.session.commit()
logger.info(
f"[ScrapApproval] {req.request_no} 执行报废完成 by {operator_name} "
f"(批准 {len(approved)} 项 / 实扫 {len(scanned)} 项)"
)
return req