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:
@ -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' 格式时取斜杠前的姓名)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user