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:
@ -96,10 +96,12 @@ class TransService:
|
||||
# 下方主循环只写 TransBorrow 流水,不再重复扣库存。
|
||||
#
|
||||
# ★ deduct_stock=False:借出是**可逆的**(会归还),只冻结可用数。
|
||||
# 若此处扣了实物,会与两处现有实现冲突:
|
||||
# 若此处扣了实物,会与两处冲突:
|
||||
# · 归还(process_return)只加 available —— 一借一还后 stock 永久少一份;
|
||||
# · 借库转报废(scrap_borrow)在确认损失时扣 stock,其注释明确假设
|
||||
# 「可用库存已在借出时冻结」—— 若借出已扣会重复扣减。
|
||||
# · 借库转报废在确认损失时扣 stock,其实现明确假设「可用库存已在
|
||||
# 借出时冻结」—— 若借出已扣会重复扣减。
|
||||
# (该实现原为 TransService.scrap_borrow,现已迁入
|
||||
# app/services/scrap_sources.py 的 BorrowScrapAdapter.deduct)
|
||||
# ==============================================================
|
||||
# ★ 防线 2.5(Fail-Closed):实扫明细的 source_table 必须全部可识别。
|
||||
#
|
||||
@ -357,90 +359,6 @@ class TransService:
|
||||
db.session.rollback()
|
||||
raise e
|
||||
|
||||
@staticmethod
|
||||
def scrap_borrow(record_ids, operator_name='System', reason=''):
|
||||
"""
|
||||
借库未归还直接报废(关联报废单流程)
|
||||
|
||||
适用场景:借出的物品确认丢失/损坏/无法归还,库管/主管手动报废。
|
||||
- 该笔借用不再追讨归还,从借还记录中标记为已报废
|
||||
- 生成一条 TransScrap 报废记录(关联 trans_borrow)
|
||||
- 总库存 stock_quantity 扣减(该物品确认损失);可用库存已在借出时冻结,无需重复扣
|
||||
|
||||
Args:
|
||||
record_ids: TransBorrow 记录ID列表
|
||||
operator_name: 操作人
|
||||
reason: 报废原因
|
||||
|
||||
Returns:
|
||||
{count: 报废条数}
|
||||
"""
|
||||
from app.models.transaction import TransScrap
|
||||
|
||||
if not record_ids:
|
||||
raise ValueError('请选择要报废的借出记录')
|
||||
if not reason:
|
||||
raise ValueError('请填写报废原因')
|
||||
|
||||
model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct}
|
||||
created_records = []
|
||||
scraped_count = 0
|
||||
|
||||
try:
|
||||
for rid in record_ids:
|
||||
record = TransBorrow.query.with_for_update().get(rid)
|
||||
if not record:
|
||||
continue
|
||||
# 仅未归还的借用可报废
|
||||
if record.is_returned:
|
||||
continue
|
||||
pending_qty = float(record.quantity) - float(record.returned_quantity or 0)
|
||||
if pending_qty <= 0:
|
||||
continue
|
||||
|
||||
# 1. 标记借用记录为已报废(不再追讨归还)
|
||||
record.is_returned = True
|
||||
record.status = 'scrapped'
|
||||
record.return_time = datetime.now()
|
||||
record.return_operator = operator_name
|
||||
|
||||
# 2. 扣减总库存(该物品确认损失);可用库存已在借出时冻结
|
||||
ModelClass = model_map.get(record.source_table)
|
||||
if ModelClass:
|
||||
stock = ModelClass.query.with_for_update().get(record.stock_id)
|
||||
if stock:
|
||||
stock_qty = float(stock.stock_quantity or 0)
|
||||
# 不足则报错整单回滚:直接夹到 0 会让 available_quantity > stock_quantity,
|
||||
# 凭空多出可用库存
|
||||
if pending_qty > stock_qty:
|
||||
raise ValueError(
|
||||
f"SKU {record.sku} 实物库存不足(剩 {stock_qty}),无法报废 {pending_qty}"
|
||||
)
|
||||
stock.stock_quantity = stock_qty - pending_qty
|
||||
|
||||
# 3. 创建报废记录
|
||||
scrap_record = TransScrap(
|
||||
sku=record.sku,
|
||||
source_table='trans_borrow', # 标识来源为借出转报废
|
||||
stock_id=record.id,
|
||||
quantity=pending_qty,
|
||||
reason=f"[借库报废] {reason}",
|
||||
operator_name=operator_name,
|
||||
approval_status='approved',
|
||||
cost_at_scrap=0,
|
||||
total_loss=0
|
||||
)
|
||||
db.session.add(scrap_record)
|
||||
created_records.append(scrap_record)
|
||||
scraped_count += 1
|
||||
|
||||
db.session.commit()
|
||||
return {'count': scraped_count}
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
raise e
|
||||
|
||||
@staticmethod
|
||||
def get_records(page=1, limit=10, status='all', keyword=None, search_type='all',
|
||||
borrower_name=None, start_date=None, end_date=None,
|
||||
|
||||
Reference in New Issue
Block a user