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:
@ -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_loss(best-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
|
||||
|
||||
Reference in New Issue
Block a user