feat(scrap): 报废全链路收口到审批流

系统自陈的规则是「报废一律需审批」(SCRAP_ALWAYS_REQUIRES_APPROVAL = True),
但实际有 4 条写 TransScrap 的路径,其中 3 条绕过审批。本提交把三条旁路
全部收口,只保留「申请 → 审批 → 执行」一条写入路径。

【删除】直接报废 POST /api/v1/scrap
  同时移除 ScrapService.process_scrap()。该路径的一个连带影响是
  「维修件报废」能力随之消失 —— process_scrap 的 trans_repair 分支是
  repair_status='报废转出' 的唯一写入点。实测该能力零使用(报废转出 0 条、
  trans_scrap 来源 0 条),且早已半死:审批流的扫码校验会拒绝 trans_*
  来源。repair_service.py 的误导文案(原文指引操作员「前往报废管理进行
  扫码操作」,而那条路根本不通)已改为「维修件报废暂未开放」。
  权限元素 scrap_create:operation 保留不删 —— add_scrap_perm.sql 以它为
  scrap_apply/scrap_execute 的授权来源。

【改造】借库转报废 POST /borrow/scrap → POST /borrow/scrap-request
  TransService.scrap_borrow() 删除,逻辑迁入 BorrowScrapAdapter。
  沿用 op_return:operation 权限(零授权变更)。已归还/已报废的记录改为
  **直接报错**,不再静默 continue 返回 count=0(原缺陷:用户以为成功)。

【改造】不良品报废 POST /defective/<id>/scrap → POST /defective/<id>/scrap-request
  申请时**不预占** remaining_qty(与报废模块「仅锁定意向,不扣库存」的
  既有哲学一致)。副作用:同一批坏件可重复提交多张申请单,执行期由适配器
  按 Fail-Closed 拒绝超额的那几张。已在该接口注释与前端提示中写明。

【服务层】ScrapApprovalService 接入来源适配层
  - submit_approval 改由 get_adapter() 分派,并新增整单 scrap_mode 一致性
    校验(混合「需扫码/免扫码」两类来源直接拒绝,让 execute 分流保持简单)
  - _build_scanned_index 的来源校验改为 is_scan_source():语义上是**收窄**
    而非放宽,扫码通道永远不接纳 trans_* 来源
  - execute 按模式分流:scan 项走原有扫码匹配(索引只由 scan 项构建),
    auto 项按批准量执行。签名与调用契约不变。
  - _match_key 加入来源表:不良品 SKU 是从原库存行复制的,不带来源时
    同一张单里的两者会**必然串键**,扫码量算到错误对象上。纯库存单两侧
    同源、键仍匹配,对既有流程零行为变更。

【修复】approve() 的 fail-open
  原实现 `if user_entries and str(operator_id) not in user_entries` ——
  allowed_approvers 为空时条件短路为假,**任何登录用户都能审批**。
  这与「报废一律需审批」直接矛盾,留这扇门等于没有审批。改为无名单即拒绝。
  已实测存量「无审批人」的在途单为 0 张,不会卡死历史数据。

【扫码通道】scrap/scan 的 trans_repair 分支改为 trans_defective_goods
  在管不良品按 SKU 匹配,在管量回填到既有字段形状,前端无需按来源分支。

【清理】scrap_approval_service 的 _stock_models 与 TransScrap 导入已移除
  (来源差异全部收敛到适配层);trans_service / inventory_reservation 中
  指向已删方法的注释已更新指向 BorrowScrapAdapter。
This commit is contained in:
yueli
2026-09-16 17:14:17 +08:00
parent e4d2b2ec68
commit 58fa42bff3
7 changed files with 332 additions and 426 deletions

View File

@ -25,7 +25,6 @@ from app.models.inbound.stocktake import (
from app.models.transaction import (
TransBorrow,
TransReturn,
TransScrap,
TransDefectiveGoods,
RETURN_TYPE_GOOD,
RETURN_TYPE_DEFECTIVE,
@ -2743,29 +2742,9 @@ def list_defective_goods():
return jsonify({'code': 500, 'msg': f'查询失败: {str(e)}'}), 500
def _defective_unit_cost(goods):
"""
取坏件单价,用于报废台账的 cost_at_scrap / total_lossbest-effort
取价口径与既有报废模块一致:成品取 sale_price采购件取 pre_tax_unit_price
半成品无价(返回 0
★ 原库存行可能已被删除(入库模块会物理删除库存行),故取不到时返回 0 ——
与借库转报废TransService.scrap_borrow 里 cost_at_scrap=0/total_loss=0
口径一致。刻意**不**因缺行而中断报废:实物已经销毁,台账必须先记上,
成本缺失是可接受的降级,记录丢失不是。
"""
model = get_stock_model(goods.source_table)
if model is None or not goods.stock_id:
return 0.0
row = model.query.get(goods.stock_id)
if not row:
return 0.0
if goods.source_table == 'stock_product':
return float(getattr(row, 'sale_price', 0) or 0)
if goods.source_table == 'stock_buy':
return float(getattr(row, 'pre_tax_unit_price', 0) or 0)
return 0.0
# 注:原此处的 _defective_unit_cost() 已迁至 app/services/scrap_sources.py 的
# defective_unit_cost() —— 服务层不得反向 import API 层,而报废扣减逻辑
# (含成本取价)现由来源适配器自持
@bp.route('/return-from-outbound', methods=['POST'])
@ -3047,47 +3026,48 @@ def restock_defective_goods(goods_id):
return jsonify({'code': 500, 'msg': f'回库失败: {str(e)}'}), 500
@bp.route('/defective/<int:goods_id>/scrap', methods=['POST'])
@bp.route('/defective/<int:goods_id>/scrap-request', methods=['POST'])
# ★ 专用权限码(原先搭 inventory_stocktake:operation 的便车)。
# 无冒号形式,不触发前缀桥接。注册见 add_defective_operation_perms.sql
@permission_required('defective_scrap')
# ★ 幂等锁置于 permission_required 内层(理由见 restock_defective_goods
@prevent_double_submit(lock_timeout=5)
def scrap_defective_goods(goods_id):
def submit_defective_scrap_request(goods_id):
"""
在管坏件报废(鉴定后确认无法维修,直接销毁)。
提交在管坏件的**报废申请**(需审批人审批,通过后由库管执行报废)。
Body(JSON):
{
"scrap_qty": 2, # 可选,缺省 = 全部剩余在管量
"reason": "主板烧毁无法修复" # 必填
"scrap_qty": 2, # 可选,缺省 = 全部剩余在管量
"reason": "主板烧毁", # 可选,写入申请单备注
"approver_id": 7 # 必填,指定审批人
}
与库存表的关系:坏件从未进入库存表(二期设计),因此本接口**不动任何
库存行**——它只做两件事:
1. 递减在管台账的 remaining_qty、累加 scrapped_qty、推进状态机
2. 往 trans_scrap 写一条报废台账,保证报废报表口径完整。
这与「库存行报废」(扣 stock_quantity / available_quantity是两条
互不重叠的路径,不会重复扣减。
为什么不在这里扣减:
报废一律需审批SCRAP_ALWAYS_REQUIRES_APPROVAL。本接口只创建申请单
**不预占 remaining_qty**,扣减发生在审批通过后的执行阶段(由
ScrapApprovalService 经来源适配器调用)。这与报废模块既有的
「仅锁定意向,不扣库存」哲学一致。
副作用:同一批坏件可重复提交多张申请单;执行期由适配器按 Fail-Closed
拒绝超额的那几张(整单回滚、单据保持可撤回),不会出现超报废。
★ 与库存表的关系:坏件从未进入库存表,执行时也**不动任何库存行**
只改在管台账并写 trans_scrap。与「库存行报废」互不重叠不会重复扣减。
"""
data = request.get_json(silent=True) or {}
operator_name = _normalize_user_id()
reason = (data.get('reason') or '').strip()
if not reason:
return jsonify({'code': 400, 'msg': '报废原因必填'}), 400
operator_id = get_jwt_identity()
try:
# ---- 1. 锁定在管记录(并发下防超报废----
goods = TransDefectiveGoods.query.with_for_update().get(goods_id)
# ---- 1. 在管记录并做前置校验(早失败,避免生成必然执行不了的申请单----
goods = TransDefectiveGoods.query.get(goods_id)
if not goods:
raise ValueError(f'不良品在管记录不存在ID: {goods_id}')
# ---- 2. 状态守门Fail-Closed----
if goods.status not in SCRAPPABLE_DEFECTIVE_STATUSES:
raise ValueError(
f'当前状态为「{goods.status}」,不可报废'
f'(仅 {"".join(SCRAPPABLE_DEFECTIVE_STATUSES)}报废'
f'当前状态为「{goods.status}」,不可申请报废'
f'(仅 {"".join(SCRAPPABLE_DEFECTIVE_STATUSES)}申请'
)
remaining = float(goods.remaining_qty or 0)
@ -3096,7 +3076,7 @@ def scrap_defective_goods(goods_id):
raw = data.get('scrap_qty')
if raw is None or raw == '':
scrap_qty = remaining # 缺省:整批剩余一次报废
scrap_qty = remaining # 缺省:整批剩余一次申请
else:
try:
scrap_qty = float(raw)
@ -3108,7 +3088,7 @@ def scrap_defective_goods(goods_id):
if scrap_qty > remaining:
raise ValueError(f'报废数量({scrap_qty})超过在管数量({remaining})')
# ---- 3. 多租户隔离 ----
# ---- 2. 多租户隔离 ----
# 直接比对台账自身的 company_name 快照 —— 坏件的原库存行可能已被删除,
# 不能依赖联表取公司(那会让这类记录绕过隔离)。
company_limit = get_current_company_filter()
@ -3117,45 +3097,30 @@ def scrap_defective_goods(goods_id):
or (goods.company_name or '') != company_limit):
raise PermissionError('无权操作其他公司的不良品')
# ---- 4. 递减在管量、累加报废量、推进状态机 ----
new_remaining = remaining - scrap_qty
goods.remaining_qty = new_remaining
goods.scrapped_qty = float(goods.scrapped_qty or 0) + scrap_qty
goods.status = (
defective_close_status(goods.restocked_qty, goods.scrapped_qty)
if new_remaining <= 0 else DEFECTIVE_STATUS_IN_PROGRESS
# ---- 3. 创建报废申请单(走统一审批流)----
from app.services.scrap_approval_service import ScrapApprovalService
req = ScrapApprovalService.submit_approval(
applicant_id=operator_id,
items=[{
'source_table': 'trans_defective_goods',
'stock_id': goods.id,
'scrap_qty': scrap_qty,
}],
remark=(data.get('reason') or '').strip() or None,
approver_id=data.get('approver_id'),
)
# ---- 5. 写报废台账 ----
# ★ source_table 用 'trans_defective_goods'、stock_id 存台账自身主键,
# 与既有约定一致trans_borrow / trans_repair 作为来源时同样存各自主键)。
# 刻意**不**写原始库存表名 —— 那批坏件从未计入原库存行,若冒充库存
# 来源会让报废台账与库存表对不上账。
unit_price = _defective_unit_cost(goods)
db.session.add(TransScrap(
sku=goods.sku or '',
source_table='trans_defective_goods',
stock_id=goods.id,
quantity=scrap_qty,
reason=f"[不良品在管报废] {reason}",
operator_name=operator_name,
approval_status='approved', # 在管坏件鉴定后直接销毁,不走审批流
cost_at_scrap=unit_price,
total_loss=round(unit_price * scrap_qty, 2),
))
db.session.commit()
return jsonify({
'code': 200,
'msg': '报废成功',
'msg': '报废申请已提交,待审批人审批',
'data': {
'id': goods.id,
'request_id': req.id,
'request_no': req.request_no,
'scrap_qty': scrap_qty,
'remaining_qty': float(goods.remaining_qty),
'restocked_qty': float(goods.restocked_qty),
'scrapped_qty': float(goods.scrapped_qty),
'status': goods.status,
# ★ 明确回传「在管量未变」—— 前端据此提示用户,避免误以为已报废
'remaining_qty': float(goods.remaining_qty or 0),
},
}), 200
@ -3168,4 +3133,4 @@ def scrap_defective_goods(goods_id):
except Exception as e:
db.session.rollback()
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'报废失败: {str(e)}'}), 500
return jsonify({'code': 500, 'msg': f'提交报废申请失败: {str(e)}'}), 500

View File

@ -4,7 +4,9 @@ from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt
from app.utils.decorators import permission_required, get_current_company_filter
from app.services.auth_service import AuthService
from app.extensions import db
from app.models.transaction import TransScrap, TransRepair
from app.models.transaction import (
TransScrap, TransRepair, TransDefectiveGoods, OPEN_DEFECTIVE_STATUSES,
)
from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct
@ -84,42 +86,25 @@ def scan_barcode():
# --------------------------------------------------------
# 2. 提交报废接口
# POST /api/v1/scrap
# [已移除] 直接报废接口 POST /api/v1/scrap
#
# 该接口绕过审批流直接写 trans_scrap 并扣减库存,与系统自陈的规则
# 「报废一律需审批」services/scrap_approval_service.py 的
# SCRAP_ALWAYS_REQUIRES_APPROVAL直接冲突且构成职责分离漏洞 ——
# 同一个库管可自行宣告实物销毁而无人复核。
#
# 现全部报废必须走:申请 → 审批 → 执行
# 提交 POST /api/v1/scrap/request
# 审批 PATCH /api/v1/scrap/request/<id>/approve
# 执行 POST /api/v1/scrap/request/<id>/execute
#
# 前端 api/scrap.ts 的 createScrap() 已一并删除(此前已是死代码,
# 页面早已切换到申请-审批-按单执行流程)。
#
# 注:权限元素 scrap_create:operation 保留在库中不删 ——
# db_migrations/add_scrap_perm.sql 以它作为 scrap_apply / scrap_execute
# 的授权来源,删掉会让该迁移重跑时授不出权限。
# --------------------------------------------------------
@scrap_bp.route('', methods=['POST'])
@jwt_required()
def create_scrap():
claims = get_jwt()
user_role = claims.get('role')
user_company = claims.get('company_name', '')
if not user_role:
return jsonify({'code': 403, 'msg': '未授权'}), 403
# 超级管理员直接放行
if user_role.upper() != 'SUPER_ADMIN':
perm_dict = AuthService.get_user_permissions(user_role, company_name=user_company)
perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
if 'scrap_create:operation' not in perms:
return jsonify({'code': 403, 'msg': '权限不足'}), 403
data = request.get_json()
if not data:
return jsonify({'code': 400, 'msg': '无有效数据'}), 400
current_user_name = get_jwt_identity() or 'Unknown'
# items 必填
if 'items' not in data or not data['items']:
return jsonify({'code': 400, 'msg': '报废商品列表不能为空'}), 400
try:
result = ScrapService.process_scrap(data, operator_name=current_user_name)
return jsonify({'code': 200, 'msg': '报废成功', 'data': result})
except Exception as e:
traceback.print_exc()
db.session.rollback()
return jsonify({'code': 400, 'msg': str(e)}), 400
# --------------------------------------------------------
@ -229,26 +214,35 @@ class ScrapService:
res['price'] = get_price(buy, 'stock_buy')
return res
# 4. 查询维修单 (TransRepair)
repair = TransRepair.query.filter(
db.or_(TransRepair.sku == clean_code, TransRepair.serial_number == clean_code)
# 4. 查询在管不良品台账(逆向物流:坏件不入库存表,由独立台账承载)
#
# ★ 本分支取代原先的「维修单」分支。维修件报废能力已随直接报废接口
# 一并移除(见文件头部 [已移除] 注释),且该来源早已被审批流的扫码
# 校验判死 —— _build_scanned_index 会拒绝 trans_* 来源。
#
# ★ 物料名/规格直接取自台账的冗余快照字段,**不联表 MaterialBase** ——
# 坏件的原库存行可能已被入库模块物理删除,联表会取到空值。
defective = TransDefectiveGoods.query.filter(
TransDefectiveGoods.sku == clean_code
).filter(
TransRepair.repair_status.notin_(['已出库', '报废转出'])
# 仅未结案的可扫:终态(已回库/已报废/已闭环)在管量已归零
TransDefectiveGoods.status.in_(OPEN_DEFECTIVE_STATUSES)
).first()
if repair:
if defective:
# 在管量即「可报废量」,回填到既有字段形状,前端无需按来源分支处理
remain = float(defective.remaining_qty or 0)
return {
'id': repair.id,
'sku': repair.sku,
'barcode': repair.sku,
'name': repair.material_name or '维修件',
'spec': '',
'id': defective.id,
'sku': defective.sku,
'barcode': defective.sku,
'name': defective.material_name or '在管不良品',
'spec': defective.spec_model or '',
'category': '',
'material_type': '',
'warehouse_loc': repair.customer_location or '',
'stock_quantity': 1,
'available_quantity': 1,
'source_table': 'trans_repair',
'price': float(repair.sale_price) if repair.sale_price else 0
'warehouse_loc': '', # 台账无库位字段
'stock_quantity': remain,
'available_quantity': remain,
'source_table': 'trans_defective_goods',
}
return None
@ -270,98 +264,6 @@ class ScrapService:
'source_table': table_type,
}
@staticmethod
def process_scrap(data, operator_name='System'):
"""处理报废:扣减库存并记录报废单"""
items = data.get('items', [])
reason = data.get('reason', '')
if not reason:
raise ValueError('请填写报废原因')
created_records = []
for item in items:
stock_id = item.get('id')
source_table = item.get('source_table')
scrap_qty = float(item.get('quantity', 0))
if not stock_id or not source_table or scrap_qty <= 0:
continue
# 处理维修单报废
if source_table == 'trans_repair':
repair = TransRepair.query.get(stock_id)
if not repair:
raise ValueError(f'维修单不存在: ID={stock_id}')
# 更新维修单状态为报废转出
repair.repair_status = '报废转出'
# 创建报废记录
scrap_record = TransScrap(
sku=repair.sku,
source_table='trans_repair',
stock_id=stock_id,
quantity=1,
reason=reason,
operator_name=operator_name,
approval_status='approved',
cost_at_scrap=float(repair.cost_price) if repair.cost_price else 0,
total_loss=float(repair.cost_price) if repair.cost_price else 0
)
db.session.add(scrap_record)
created_records.append(scrap_record)
continue
# 获取库存记录 — ★ 修复并发:使用悲观锁防止超卖/负库存
stock_record = None
if source_table == 'stock_product':
stock_record = StockProduct.query.with_for_update().get(stock_id)
elif source_table == 'stock_semi':
stock_record = StockSemi.query.with_for_update().get(stock_id)
elif source_table == 'stock_buy':
stock_record = StockBuy.query.with_for_update().get(stock_id)
if not stock_record:
raise ValueError(f'库存记录不存在: ID={stock_id}')
# 检查可用数量锁已持有TOCTOU 窗口已消除)
avail_qty = float(stock_record.available_quantity) if stock_record.available_quantity else 0
if avail_qty < scrap_qty:
raise ValueError(f"SKU {stock_record.sku} 可用库存不足,当前可用: {avail_qty}")
# 计算损失金额
unit_price = 0.0
if source_table == 'stock_product':
unit_price = float(stock_record.sale_price) if stock_record.sale_price else 0
elif source_table == 'stock_buy':
unit_price = float(stock_record.pre_tax_unit_price) if stock_record.pre_tax_unit_price else 0
total_loss = round(unit_price * scrap_qty, 2)
# 扣减库存
stock_record.stock_quantity = float(stock_record.stock_quantity) - scrap_qty
stock_record.available_quantity = float(stock_record.available_quantity) - scrap_qty
# 创建报废记录
scrap_record = TransScrap(
sku=stock_record.sku,
source_table=source_table,
stock_id=stock_id,
quantity=scrap_qty,
reason=reason,
operator_name=operator_name,
approval_status='approved',
cost_at_scrap=unit_price,
total_loss=total_loss
)
db.session.add(scrap_record)
created_records.append(scrap_record)
db.session.commit()
return {'count': len(created_records)}
# ------------------------------------------------------------------
# 辅助:用户名解析(库里存 '张三/zhangsan' 格式时取斜杠前的姓名)
# ------------------------------------------------------------------

View File

@ -111,31 +111,104 @@ def submit_return():
return jsonify({'code': 400, 'msg': str(e)}), 400
# --- 借库报废(未归还直接报废,关联报废单流程---
@trans_bp.route('/borrow/scrap', methods=['POST'])
# --- 借库报废申请(未归还 → 提交报废申请,需审批---
@trans_bp.route('/borrow/scrap-request', methods=['POST'])
@jwt_required()
@permission_required('op_return:operation') # 复用归还权限:能归还的库管即可报废
def scrap_borrow():
@permission_required('op_return:operation') # 复用归还权限:能归还的库管即可申请报废
# ★ 幂等锁置于 permission_required 内层prevent_double_submit 依赖
# get_jwt_identity(),放外层会因 JWT 未验证而抛错、被自身 except 捕获后降级放行
@prevent_double_submit(lock_timeout=5)
def submit_borrow_scrap_request():
"""
借库未归还直接报废(库管/主管操作)
请求体: { "record_ids": [1, 2, 3], "reason": "物品丢失" }
提交「借出未归还」的**报废申请**(需审批人审批,通过后由库管执行报废)。
请求体:
{
"record_ids": [1, 2, 3], # 必填trans_borrow.id 列表(按整条待还量报废)
"reason": "物品丢失", # 可选,写入申请单备注
"approver_id": 7 # 必填,指定审批人
}
★ 为什么改走审批:
原先 POST /borrow/scrap 直接写 trans_scrap 并扣总库存,绕过审批,与系统
自陈的「报废一律需审批」冲突,构成职责分离漏洞 —— 同一个库管可自行宣告
实物损失而无人复核。现统一走:申请 → 审批 → 执行。
★ 执行方式:本来源为「免扫码」—— 东西在借用人手上,物理上不可能扫码;
且执行只改台账与总库存,不产生任何可被挪用的可用库存。
"""
data = request.get_json() or {}
record_ids = data.get('record_ids', [])
reason = data.get('reason', '')
record_ids = data.get('record_ids') or []
reason = (data.get('reason') or '').strip()
approver_id = data.get('approver_id')
if not record_ids:
return jsonify({'code': 400, 'msg': '请选择要报废的借出记录'}), 400
return jsonify({'code': 400, 'msg': '请选择要申请报废的借出记录'}), 400
if not approver_id:
return jsonify({'code': 400, 'msg': '请选择审批人'}), 400
operator_name = _current_username() or 'Unknown'
try:
result = TransService.scrap_borrow(record_ids, operator_name=operator_name, reason=reason)
return jsonify({'code': 200, 'msg': f'已报废 {result["count"]} 条借出记录', 'data': result})
from app.models.transaction import TransBorrow
# 逐条载入并校验。
# ★ 已归还/已报废的**直接报错**,不静默跳过 —— 旧实现是 `continue`
# 然后返回 count=0用户以为成功实则什么都没发生缺陷
items = []
missing = []
for rid in record_ids:
try:
rid = int(rid)
except (TypeError, ValueError):
raise ValueError(f'借出记录 ID 无效:{rid}')
record = TransBorrow.query.get(rid)
if not record:
missing.append(str(rid))
continue
if record.is_returned:
raise ValueError(
f"借用记录【{record.borrow_no or rid}】已归还或已报废,不可再申请报废"
)
pending = (float(record.quantity or 0)
- float(record.returned_quantity or 0))
if pending <= 0:
raise ValueError(
f"借用记录【{record.borrow_no or rid}】无待还数量,无需报废"
)
items.append({
'source_table': 'trans_borrow',
'stock_id': record.id,
'scrap_qty': pending,
})
if missing:
raise ValueError(f'以下借出记录不存在:{"".join(missing)}')
if not items:
raise ValueError('所选借出记录均无待还数量,无需报废')
from app.services.scrap_approval_service import ScrapApprovalService
req = ScrapApprovalService.submit_approval(
applicant_id=get_jwt_identity(),
items=items,
remark=reason or None,
approver_id=approver_id,
)
return jsonify({
'code': 200,
'msg': f'报废申请已提交({len(items)} 条明细),待审批人审批',
'data': {
'request_id': req.id,
'request_no': req.request_no,
'count': len(items),
},
}), 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
return jsonify({'code': 500, 'msg': f'提交报废申请失败: {str(e)}'}), 500
# --- 记录列表 ---