Files
KCGL/inventory-backend/app/services/scrap_approval_service.py
yueli 9925bf2b99 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

409 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
# ------------------------------------------------------------------
# 撤回 / 作废(已通过但未执行 → 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
# ------------------------------------------------------------------
# 执行(按单报废:扣减实物库存 + 写报废流水)
# ------------------------------------------------------------------
@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