From 9eb4792d4a6070e33fca53f393e3731cb6b50abf Mon Sep 17 00:00:00 2001 From: yueli Date: Wed, 16 Sep 2026 16:45:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(return):=20=E9=80=80=E5=9B=9E=E6=B5=81?= =?UTF-8?q?=E6=B0=B4=E7=9C=8B=E6=9D=BF=E6=8E=A5=E5=8F=A3=E4=B8=8E=E6=9D=83?= =?UTF-8?q?=E9=99=90=E6=94=B6=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增只读台账接口: - GET /api/v1/outbound/returns 退回流水(分页 + 关键词 + 类型 + 时间过滤) 返回 原出库单号 / 物料名称 / 规格 / SKU / 退回类型 / 退回数量 / 原因 / 操作人 / 退回时间 / 公司。出库单号经 trans_outbound 批量补齐,物料名按 多态来源批量解析,均为批量查询无 N+1。 权限收口(配合 db_migrations 里的三个权限码): - return-from-outbound inventory_stocktake:operation -> outbound_return - GET /stock/defective inventory_stocktake -> defective_list - restock inventory_stocktake:operation -> defective_restock - scrap inventory_stocktake:operation -> defective_scrap - change-status inventory_stocktake:operation -> stock_change_status 原先这四个接口搭的是「盲盘作业」权限的便车,职责错配、审计不合规。 实测 SALES(销售)角色持有 inventory_stocktake,意味着销售人员能读整份 不良品台账——与业务对台账可见性的要求不符。全部改用无冒号专用码后, 实测「只授予 inventory_stocktake:operation」对四个接口均返回 403,便车已封。 trans_return 补 company_name 快照: 退回流水的隔离判定原先只能靠 join 链推,而库存行会被入库模块物理删除 (实测 1077 条出库记录中已有 7 条悬空),链路一断记录就会对普通用户 静默消失。改由退回时落快照,隔离不再依赖任何 join。 --- inventory-backend/app/api/v1/inbound/stock.py | 33 +++- inventory-backend/app/api/v1/outbound.py | 149 ++++++++++++++++++ inventory-backend/app/models/transaction.py | 10 ++ 3 files changed, 187 insertions(+), 5 deletions(-) diff --git a/inventory-backend/app/api/v1/inbound/stock.py b/inventory-backend/app/api/v1/inbound/stock.py index 8db7092..3b13713 100644 --- a/inventory-backend/app/api/v1/inbound/stock.py +++ b/inventory-backend/app/api/v1/inbound/stock.py @@ -2479,7 +2479,11 @@ def update_stocktake_quantity(): # 不可丢给后台任务,否则会静默失去审计痕迹。 @bp.route('//change-status', methods=['POST']) -@permission_required('inventory_stocktake:operation') +# ★ 专用权限码。原先搭 inventory_stocktake:operation(盲盘作业)的便车, +# 但「冻结/标不良」是库存状态治理动作,与盘点作业职责不同,审计上不合规。 +# 无冒号形式,不触发 _expand_operation_perms 的前缀桥接。 +# 权限注册见 db_migrations/add_defective_operation_perms.sql +@permission_required('stock_change_status') def change_stock_status(stock_id): """ 变更单条库存行的状态(在库 / 冻结 / 不良品)。 @@ -2646,7 +2650,10 @@ def _assert_company_owns(row): @bp.route('/defective', methods=['GET']) -@permission_required('inventory_stocktake') +# ★ 专用查看权限。原先复用 inventory_stocktake(盲盘作业)—— 实测 SALES(销售) +# 角色持有该权限,意味着销售人员能读整份不良品台账,与业务对台账可见性的 +# 要求不符。无冒号形式不触发前缀桥接。注册见 add_return_view_support.sql +@permission_required('defective_list') def list_defective_goods(): """ 不良品在管台账分页查询(供「不良品在管台账」看板页使用)。 @@ -2762,7 +2769,13 @@ def _defective_unit_cost(goods): @bp.route('/return-from-outbound', methods=['POST']) -@permission_required('inventory_stocktake:operation') +# ★ 库管 SOP 专用权限码:退回是实物交接动作,只应由具备库管职责的人员执行。 +# 刻意使用**无冒号**的 'outbound_return' 而非 'outbound_list:return': +# 后者会命中 _expand_operation_perms() 的前缀桥接(outbound_list 下存在 +# outbound_list:operation),导致持有该权限的角色被一并放行,授权面失控。 +# 无冒号码不触发桥接,判定与前端 hasPermission 的精确匹配完全一致。 +# DDL/授权见 db_migrations/add_outbound_return_perm.sql +@permission_required('outbound_return') # ★ 幂等锁置于 permission_required 内层(理由见 restock_defective_goods) @prevent_double_submit(lock_timeout=5) def return_from_outbound(): @@ -2826,6 +2839,11 @@ def return_from_outbound(): stock_row = _lock_source_stock_row(outbound.source_table, outbound.stock_id) _assert_company_owns(stock_row) + # 公司快照:退回看板的隔离判定不能依赖 join 链 —— 源库存行会被入库模块 + # 物理删除,届时链路断裂会让记录对普通用户静默消失。见 TransReturn 注释。 + _base = getattr(stock_row, 'base', None) + snapshot_company = ((_base.company_name if _base else '') or '').strip() or None + goods = None if is_defective: # ================= 不良品分支 ================= @@ -2876,6 +2894,7 @@ def return_from_outbound(): return_type=RETURN_TYPE_DEFECTIVE if is_defective else RETURN_TYPE_GOOD, reason=reason, operator=operator_name, + company_name=snapshot_company, ) db.session.add(ledger) db.session.flush() # 先拿到 ledger.id,供在管台账回填 @@ -2913,7 +2932,9 @@ def return_from_outbound(): @bp.route('/defective//restock', methods=['POST']) -@permission_required('inventory_stocktake:operation') +# ★ 专用权限码(原先搭 inventory_stocktake:operation 的便车)。 +# 无冒号形式,不触发前缀桥接。注册见 add_defective_operation_perms.sql +@permission_required('defective_restock') # ★ 幂等锁必须置于 permission_required **内层**:prevent_double_submit 依赖 # get_jwt_identity(),若放在外层则 JWT 尚未验证 → 抛错 → 被其 except 捕获 # 后 fail-open 降级放行,锁形同虚设。 @@ -3027,7 +3048,9 @@ def restock_defective_goods(goods_id): @bp.route('/defective//scrap', methods=['POST']) -@permission_required('inventory_stocktake:operation') +# ★ 专用权限码(原先搭 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): diff --git a/inventory-backend/app/api/v1/outbound.py b/inventory-backend/app/api/v1/outbound.py index 73962f2..7b37368 100644 --- a/inventory-backend/app/api/v1/outbound.py +++ b/inventory-backend/app/api/v1/outbound.py @@ -278,6 +278,155 @@ def get_outbound_list(): return jsonify({'code': 500, 'msg': str(e)}), 500 +def _resolve_return_materials(rows): + """ + 批量解析退回流水对应的物料名称/规格。 + + trans_return 只存 (source_table, stock_id) 多态指针,需回查三张库存表。 + ★ 源库存行可能已被物理删除(实测出库记录中已有悬空行),取不到时返回 + 空字符串由前端显示占位 —— 刻意**不**因此丢弃该行:退回台账的完整性 + 优先于展示美观,缺名字总比少一条记录好。 + """ + from app.models.inbound.buy import StockBuy + from app.models.inbound.semi import StockSemi + from app.models.inbound.product import StockProduct + from sqlalchemy.orm import joinedload + + model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, + 'stock_product': StockProduct} + resolved = {} + for table, model in model_map.items(): + ids = {r.stock_id for r in rows if r.source_table == table and r.stock_id} + if not ids: + continue + for obj in model.query.options(joinedload(model.base)).filter( + model.id.in_(ids)).all(): + base = getattr(obj, 'base', None) + resolved[(table, obj.id)] = { + 'material_name': (base.name if base else '') or '', + 'spec_model': (base.spec_model if base else '') or '', + } + return resolved + + +# -------------------------------------------------------- +# 退回流水(只读台账) +# GET /api/v1/outbound/returns +# -------------------------------------------------------- +@outbound_bp.route('/returns', methods=['GET']) +# ★ 专用查看权限,仅授予超管/主管/库管三个核心角色。 +# 无冒号形式不触发 _expand_operation_perms 的前缀桥接, +# 注册见 db_migrations/add_return_view_support.sql +@permission_required('outbound_return_list') +def list_returns(): + """ + 原单退回流水台账(只读,无任何写操作)。 + + Query: + page / page_size 分页,默认 1 / 20 + keyword 模糊匹配 出库单号 / SKU / 操作人 + return_type '良品' / '不良品';'全部' 或留空 = 不过滤 + start_date / end_date 按退回时间过滤(10 位日期自动补时分秒) + + ★ 行级隔离直接按 trans_return.company_name **快照**过滤,不走 join 链: + 源库存行会被入库模块物理删除,链路一断该记录就会对普通用户静默消失。 + """ + from app.extensions import db + from app.utils.decorators import get_current_company_filter + from app.models.transaction import TransReturn, VALID_RETURN_TYPES + from app.models.outbound import TransOutbound + + page = request.args.get('page', 1, type=int) or 1 + page_size = request.args.get('page_size', 20, type=int) or 20 + page_size = min(max(page_size, 1), 200) # 防超大分页拖垮库 + + keyword = (request.args.get('keyword') or '').strip() + return_type = (request.args.get('return_type') or '').strip() + start_date = (request.args.get('start_date') or '').strip() + end_date = (request.args.get('end_date') or '').strip() + + try: + query = TransReturn.query + + # 行级隔离(超管/跨域 company_limit 为 None,不受限) + company_limit = get_current_company_filter() + if company_limit is not None: + query = query.filter(TransReturn.company_name == company_limit) + + if return_type and return_type not in ('全部', 'all'): + if return_type not in VALID_RETURN_TYPES: + return jsonify({ + 'code': 400, + 'msg': f'不支持的退回类型:{return_type},' + f'仅支持 {"、".join(VALID_RETURN_TYPES)}', + }), 400 + query = query.filter(TransReturn.return_type == return_type) + + # 日期边界补全时分秒,避免 10 位日期被当成零点截断(与全系统口径一致) + if start_date and len(start_date) == 10: + start_date = f'{start_date} 00:00:00' + if end_date and len(end_date) == 10: + end_date = f'{end_date} 23:59:59' + if start_date: + query = query.filter(TransReturn.return_time >= start_date) + if end_date: + query = query.filter(TransReturn.return_time <= end_date) + + if keyword: + like = f'%{keyword}%' + # 出库单号不在本表,先经 trans_outbound 求出命中的 outbound_id 集合 + matched = db.session.query(TransOutbound.id).filter( + TransOutbound.outbound_no.ilike(like) + ).subquery() + query = query.filter(db.or_( + TransReturn.sku.ilike(like), + TransReturn.operator.ilike(like), + TransReturn.outbound_id.in_(db.session.query(matched.c.id)), + )) + + # 默认按退回时间倒序:最新退回的最需要核对 + query = query.order_by(TransReturn.return_time.desc(), + TransReturn.id.desc()) + pg = query.paginate(page=page, per_page=page_size, error_out=False) + + rows = pg.items + + # ---- 批量补出库单号(避免 N+1)---- + outbound_ids = {r.outbound_id for r in rows if r.outbound_id} + outbound_map = {} + if outbound_ids: + for o in TransOutbound.query.filter( + TransOutbound.id.in_(outbound_ids)).all(): + outbound_map[o.id] = o.outbound_no + + # ---- 批量补物料名 ---- + mat_map = _resolve_return_materials(rows) + + items = [] + for r in rows: + d = r.to_dict() + d['outbound_no'] = outbound_map.get(r.outbound_id, '') + info = mat_map.get((r.source_table, r.stock_id)) or {} + d['material_name'] = info.get('material_name', '') + d['spec_model'] = info.get('spec_model', '') + items.append(d) + + return jsonify({ + 'code': 200, + 'msg': 'success', + 'data': { + 'list': items, + 'total': pg.total, + 'page': page, + 'page_size': page_size, + }, + }), 200 + + except Exception as e: + traceback.print_exc() + return jsonify({'code': 500, 'msg': f'查询失败: {str(e)}'}), 500 + + def _allocate_bom_requirements(requirements, company_limit, StockBuy, StockSemi, StockProduct, MaterialBase): """ diff --git a/inventory-backend/app/models/transaction.py b/inventory-backend/app/models/transaction.py index d853c58..2da42f9 100644 --- a/inventory-backend/app/models/transaction.py +++ b/inventory-backend/app/models/transaction.py @@ -268,6 +268,15 @@ class TransReturn(db.Model): operator = db.Column(db.String(100)) return_time = db.Column(db.DateTime, default=beijing_time) + # ★ 公司快照:退回发生时的所属公司。 + # 不靠 join 推 —— 隔离判定的链路是 + # trans_return → trans_outbound → (source_table, stock_id) → 库存表 → 物料主表 + # 而库存行会被入库模块**物理删除**(实测 1077 条出库记录中已有 7 条悬空), + # 链路一断,记录就会对普通用户静默消失。审计视图静默丢数据不可接受。 + # 与 trans_defective_goods.company_name 同一处理方式。 + # DDL 见 db_migrations/add_return_view_support.sql + company_name = db.Column(db.String(255), index=True) + def to_dict(self): return { 'id': self.id, @@ -280,6 +289,7 @@ class TransReturn(db.Model): 'reason': self.reason, 'operator': self.operator, 'return_time': self.return_time.strftime('%Y-%m-%d %H:%M:%S') if self.return_time else None, + 'company_name': self.company_name, }