From 58fa42bff3d8f3c66cccace90466bdb98484771d Mon Sep 17 00:00:00 2001 From: yueli Date: Wed, 16 Sep 2026 17:14:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(scrap):=20=E6=8A=A5=E5=BA=9F=E5=85=A8?= =?UTF-8?q?=E9=93=BE=E8=B7=AF=E6=94=B6=E5=8F=A3=E5=88=B0=E5=AE=A1=E6=89=B9?= =?UTF-8?q?=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 系统自陈的规则是「报废一律需审批」(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//scrap → POST /defective//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。 --- inventory-backend/app/api/v1/inbound/stock.py | 125 ++++------ inventory-backend/app/api/v1/scrap.py | 188 ++++---------- inventory-backend/app/api/v1/transactions.py | 99 +++++++- .../app/services/inbound/repair_service.py | 14 +- .../app/services/inventory_reservation.py | 7 +- .../app/services/scrap_approval_service.py | 233 ++++++++++-------- .../app/services/trans_service.py | 92 +------ 7 files changed, 332 insertions(+), 426 deletions(-) diff --git a/inventory-backend/app/api/v1/inbound/stock.py b/inventory-backend/app/api/v1/inbound/stock.py index 3b13713..9e24a23 100644 --- a/inventory-backend/app/api/v1/inbound/stock.py +++ b/inventory-backend/app/api/v1/inbound/stock.py @@ -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//scrap', methods=['POST']) +@bp.route('/defective//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 diff --git a/inventory-backend/app/api/v1/scrap.py b/inventory-backend/app/api/v1/scrap.py index 8f1d656..4032317 100644 --- a/inventory-backend/app/api/v1/scrap.py +++ b/inventory-backend/app/api/v1/scrap.py @@ -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//approve +# 执行 POST /api/v1/scrap/request//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' 格式时取斜杠前的姓名) # ------------------------------------------------------------------ diff --git a/inventory-backend/app/api/v1/transactions.py b/inventory-backend/app/api/v1/transactions.py index 46e65be..883e246 100644 --- a/inventory-backend/app/api/v1/transactions.py +++ b/inventory-backend/app/api/v1/transactions.py @@ -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 # --- 记录列表 --- diff --git a/inventory-backend/app/services/inbound/repair_service.py b/inventory-backend/app/services/inbound/repair_service.py index df91da1..261a602 100644 --- a/inventory-backend/app/services/inbound/repair_service.py +++ b/inventory-backend/app/services/inbound/repair_service.py @@ -233,9 +233,19 @@ class RepairInboundService: if status == '已出库': raise ValueError("禁止手动变更为已出库状态,请通过扫码出库模块进行操作") - # 禁止手动变更为报废转出状态,必须通过扫码报废模块进行 + # 禁止手动变更为报废转出状态。 + # + # ★ 原文案是「请前往报废管理进行扫码操作」,但那条路已不存在: + # 维修件报废原本由「直接报废」接口(POST /api/v1/scrap)的 + # trans_repair 分支实现,而该接口绕过审批、与系统自陈的 + # 「报废一律需审批」规则冲突,已整体移除。审批流的扫码校验 + # 也从来拒绝 trans_* 来源,故这条链路早已不通。 + # + # 维修件报废的正式支持(含数量语义、审批接入)需单独排期。 if status == '报废转出': - raise ValueError("禁止手动变更为报废状态,请前往报废管理进行扫码操作") + raise ValueError( + "禁止手动变更为报废状态。维修件报废暂未开放,请联系管理员" + ) repair = TransRepair.query.get(id) if not repair: diff --git a/inventory-backend/app/services/inventory_reservation.py b/inventory-backend/app/services/inventory_reservation.py index 4b8d629..2ef7d73 100644 --- a/inventory-backend/app/services/inventory_reservation.py +++ b/inventory-backend/app/services/inventory_reservation.py @@ -498,9 +498,10 @@ def restore_then_deduct(scanned_items, approved_items, deduct_stock=True): 实物数不动。理由: · 归还只加 available(现有实现),若借出扣了 stock, 一借一还后 stock 会永久少一份; - · 借库转报废(scrap_borrow)在确认损失时扣 stock, - 其注释明确假设「可用库存已在借出时冻结」, - 若借出已扣 stock 会重复扣减。 + · 借库转报废在确认损失时扣 stock,其实现明确假设 + 「可用库存已在借出时冻结」,若借出已扣 stock + 会重复扣减。(该实现现已迁入 + app/services/scrap_sources.py 的 BorrowScrapAdapter) 语义:stock = 账面实物(含借出未还), available = 实际可取用。 diff --git a/inventory-backend/app/services/scrap_approval_service.py b/inventory-backend/app/services/scrap_approval_service.py index 926b1c8..b6c7f71 100644 --- a/inventory-backend/app/services/scrap_approval_service.py +++ b/inventory-backend/app/services/scrap_approval_service.py @@ -5,7 +5,6 @@ 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__) @@ -30,22 +29,10 @@ def _beijing(): # ============================================================================= 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 +# 注:原先此处有 _stock_models() 硬编码三张库存表。来源差异已全部收敛到 +# app/services/scrap_sources.py 的来源适配层(它复用 +# inventory_reservation.stock_model_map(),避免第四份重复定义), +# 本模块不再直接持有库存表映射。 class ScrapApprovalService: @@ -72,44 +59,54 @@ class ScrapApprovalService: if not items: raise ValueError("报废明细不能为空") - models = _stock_models() + # ★ 来源适配:三类来源(库存行 / 在管不良品 / 借出未还)各有不同的 + # 可报废上限、扣减行为与快照字段,差异全部收敛在 scrap_sources 里。 + # 原先此处硬编码「只认三张库存表」,导致借出未还与在管不良品只能 + # 各走直报接口绕过审批。详见 app/services/scrap_sources.py 模块头。 + from app.services.scrap_sources import get_adapter + 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(精准库存行)") + adapter = get_adapter(st) + if not adapter 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) + row = adapter.load(sid) if not row: - raise ValueError(f"第 {idx + 1} 条对应的库存记录不存在") + raise ValueError(f"第 {idx + 1} 条对应的{adapter.label}记录不存在") 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, - }) + cap = adapter.cap(row) + if qty > cap: + raise ValueError( + f"第 {idx + 1} 条报废数量({qty})超过{adapter.cap_label}({cap})" + ) + # 提交期的额外约束(如已归还的借用记录不得再报废) + adapter.submit_guard(row, qty) + + normalized.append(adapter.snapshot(row, qty, it)) + + # ★ 整单执行模式必须一致。 + # 现有三个提交入口(报废申请页 / 借还记录页 / 不良品看板)各自只提交 + # 单一来源,UI 上产不出混合单;此处显式拒绝非法构造,让 execute() 的 + # 分流逻辑保持简单可验证。 + modes = {n.get('scrap_mode') for n in normalized} + if len(modes) > 1: + raise ValueError( + "同一张报废申请单不能混合「需扫码」与「免扫码」两类来源,请分开提交" + ) # ★ 报废一律需审批(见 SCRAP_ALWAYS_REQUIRES_APPROVAL)。 # resolve_approval_control 仍调用,但仅用于生成「哪些物料命中需审批」的提示文案, @@ -173,10 +170,19 @@ class ScrapApprovalService: if req.status != 0: raise ValueError("当前状态不允许审批(仅待审批可操作)") - # 仅被指定的审批人可操作 + # 仅被指定的审批人可操作。 + # + # ★ Fail-Closed:原实现是 `if user_entries and str(operator_id) not in ...` + # —— 当 allowed_approvers 为空(或条目里没有 type='user')时 user_entries + # 为空列表,条件短路为假,**任何登录用户都能审批**。这与本模块自陈的 + # 「报废一律需审批」直接矛盾:留一扇「无审批人则人人可审」的门, + # 等于没有审批。现改为无名单即拒绝。 + # 已实测存量「无审批人」的在途单为 0 张,不会卡死历史数据。 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: + if not user_entries: + raise ValueError("该申请单未指定审批人,无法审批,请联系管理员处理") + if str(operator_id) not in user_entries: raise ValueError("只有被指定的审批人可以审批该申请") if action == 'approve': @@ -262,20 +268,25 @@ class ScrapApprovalService: @staticmethod def _match_key(source_table, stock_id, sku): """ - ★ 扫码匹配键 —— SKU 优先。 + ★ 扫码匹配键 —— SKU 优先,**且必须带来源表**。 - 已核验:SKU 在同一库存表内唯一,且不存在跨表重名(stock_buy / - stock_semi / stock_product 三表交叉无冲突),故 SKU 可安全作为 - 跨来源的稳定标识。 + 原实现返回 ('sku', s),其前提是「SKU 在三张库存表内唯一且跨表无重名」。 + 引入逆向物流来源后这个前提不再成立:在管不良品台账的 SKU 是从 + 原库存行**复制**的(退回时 sku=getattr(stock_row, 'sku', '')), + 故 `trans_defective_goods#N` 与其源 `stock_buy#M` 的 SKU **必然相同**。 + 若同一张报废单同时含两者,不带来源就会串键 —— 扫码量会算到错误对象上。 - 例外:个别历史库存行的 SKU 为空,这类行无法用 SKU 标识,回退为 - source_table + stock_id 复合键。前缀区分('sku:' / 'row:')保证 - 空 SKU 行绝不会与任何正常 SKU 串键。 + 加入 source_table 后,纯库存单两侧同源、键仍匹配(对既有流程零行为 + 变更),而跨来源的同 SKU 行彻底隔离。 + + 空 SKU 的历史行仍回退为 row 复合键,两种键的首元素不同 + ('sku' / 'row'),不会互相串键。 """ + st = (source_table or '').strip() s = ScrapApprovalService._norm_sku(sku) if s: - return ('sku', s) - return ('row', f"{source_table}#{stock_id}") + return ('sku', st, s) + return ('row', f"{st}#{stock_id}") @staticmethod def _build_approved_index(items): @@ -305,8 +316,10 @@ class ScrapApprovalService: return index @staticmethod - def _build_scanned_index(scanned_items, models): + def _build_scanned_index(scanned_items): """前端实扫明细 → {匹配键: 累计扫码数量}""" + from app.services.scrap_sources import is_scan_source + index = {} for idx, s in enumerate(scanned_items): sku = ScrapApprovalService._norm_sku(s.get('sku')) @@ -314,8 +327,14 @@ class ScrapApprovalService: 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}") + # ★ 语义收窄而非放宽:扫码通道只接纳明确声明为 scan 的来源。 + # 免扫码来源(借出未还)与未知来源一律拒绝 —— 后者能挡住 + # 「扫码扫得到、执行却拒绝」的历史错配(trans_repair 曾如此)。 + if st and not is_scan_source(st): + raise ValueError( + f"第 {idx + 1} 条扫码来源不支持扫码提交:{st}" + f"(该来源为免扫码执行来源,或来源非法)" + ) qty = float(s.get('quantity') or 0) if qty <= 0: raise ValueError(f"第 {idx + 1} 条扫码数量必须大于 0") @@ -330,15 +349,24 @@ class ScrapApprovalService: @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 定位库存行加锁。 + 仅承载「实扫到的实物」。免扫码来源(借出未还)**不经过**这里, + 由后端按批准量直接执行 —— 调用契约与改造前完全一致。 + + scan 来源(库存行 / 在管不良品): + · 以 SKU + 来源表为主校验键(来源表用于隔离同 SKU 的跨来源行); + · 同一键多次扫码累加,累计不得超过批准总量; + · 允许合法子集(少扫 = 本次不报废该行)。 + + auto 来源(借出未还): + · 按 items_json 中的 scrap_qty 全量执行,不参与扫码匹配。 """ - models = _stock_models() + from app.services.scrap_sources import ( + get_adapter, SCRAP_MODE_AUTO, SCRAP_MODE_SCAN, + ) + req = db.session.get(ScrapApproval, request_id) if not req: raise ValueError("报废申请不存在") @@ -349,29 +377,54 @@ class ScrapApprovalService: if not approved_items: raise ValueError("报废明细为空,无法执行") - # ★ 强制按单扫码:未提交实扫明细不允许执行 - if not scanned_items: + # ★ 按执行模式分流。存量单据(全部是库存来源)没有 scrap_mode 字段, + # 回落到适配器声明的模式 = scan,行为与改造前逐字一致。 + def _mode_of(item): + mode = (item.get('scrap_mode') or '').strip() + if mode in (SCRAP_MODE_SCAN, SCRAP_MODE_AUTO): + return mode + adapter = get_adapter(item.get('source_table')) + return adapter.scrap_mode if adapter else SCRAP_MODE_SCAN + + scan_items = [it for it in approved_items if _mode_of(it) == SCRAP_MODE_SCAN] + auto_items = [it for it in approved_items if _mode_of(it) == SCRAP_MODE_AUTO] + + # ★ 扫码仅在**本单存在需扫码明细**时强制。 + # 纯免扫码单(借出未还)允许 scanned_items 为空 —— 实物在借用人 + # 手上,要求扫码在物理上不可能。 + if scan_items and not scanned_items: raise ValueError("请先扫码并提交实际报废物料,再执行报废") - approved = ScrapApprovalService._build_approved_index(approved_items) - scanned = ScrapApprovalService._build_scanned_index(scanned_items, models) + approved = {} + scanned = {} + if scan_items: + # 索引只由 scan 项构建:auto 项不参与匹配,避免同 SKU 串键 + approved = ScrapApprovalService._build_approved_index(scan_items) + scanned = ScrapApprovalService._build_scanned_index(scanned_items) - # ★ 校验一:扫码 SKU 必须在批准明细内 + def _adapter_for(st): + adapter = get_adapter(st) + if adapter is None: + raise ValueError(f"报废来源不支持:{st or '(空)'}") + return adapter + + # ★ 校验一:扫码物料必须在批准明细内 for key, acc in scanned.items(): if key not in approved: raise ValueError( - f"SKU【{acc['label']}】不在该报废申请单的批准明细中(SKU 不匹配),禁止报废" + f"物料【{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']}),禁止报废" + f"物料【{acc['label']}】扫码数量({acc['qty']})超出批准数量({appr['qty']}),禁止报废" ) - # ★ 扣减:按批准单配对的 source_table + stock_id 定位库存行,逐行加锁扣减 + # ★ scan 项扣减:按批准单配对的 source_table + stock_id 定位实物,逐行加锁扣减。 + # 扣减行为由来源适配器自持(库存行双扣 / 在管不良品只动台账)。 for key, acc in scanned.items(): appr = approved[key] remaining = acc['qty'] @@ -383,38 +436,22 @@ class ScrapApprovalService: 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, - )) - + _adapter_for(ref['source_table']).deduct( + ref['stock_id'], take, req, operator_name, + ) remaining -= take + # ★ auto 项扣减:免扫码来源按批准量全量执行。 + # 不参与上面的扫码匹配 —— 若把它塞进同一个索引,同 SKU 会与 + # scan 项串键,扫码量被算到错误对象上。 + for it in auto_items: + qty = float(it.get('scrap_qty') or 0) + if qty <= 0: + continue + _adapter_for(it.get('source_table')).deduct( + it.get('stock_id'), qty, req, operator_name, + ) + req.status = 3 req.executed_at = _beijing() req.executor_name = operator_name diff --git a/inventory-backend/app/services/trans_service.py b/inventory-backend/app/services/trans_service.py index c4f0076..82dd1c0 100644 --- a/inventory-backend/app/services/trans_service.py +++ b/inventory-backend/app/services/trans_service.py @@ -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,