from flask import Blueprint, request, jsonify, current_app from app.services.outbound_service import OutboundService from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt from app.utils.decorators import permission_required, prevent_double_submit, is_privileged_viewer from app.services.auth_service import AuthService import traceback outbound_bp = Blueprint('outbound', __name__, url_prefix='/outbound') # ============================================================================== # 辅助函数:获取当前用户的完整权限列表(基于角色查询) # ============================================================================== def get_current_user_permissions(): """ 返回当前用户拥有的所有权限码列表(包括菜单和元素) 此函数根据角色查询数据库得到权限。 """ from flask_jwt_extended import get_jwt from app.services.auth_service import AuthService claims = get_jwt() user_role = claims.get('role') user_company = claims.get('company_name', '') if not user_role: return [] # 超级管理员返回所有字段权限 (忽略大小写) if user_role.upper() == 'SUPER_ADMIN': return ['outbound_list:*'] perm_dict = AuthService.get_user_permissions(user_role, company_name=user_company) # 合并菜单和元素权限 perms = perm_dict.get('menus', []) + perm_dict.get('elements', []) return perms def filter_item_by_permissions(item_dict, user_permissions): """ 根据用户权限过滤 item 字典,无权限的字段值置为 None 与前端 permissionMap 对齐:仅 total_amount/unit_price/subtotal 需权限 """ field_to_perm = { 'total_amount': 'outbound_list:total_amount', 'unit_price': 'outbound_list:unit_price', 'subtotal': 'outbound_list:subtotal', } if 'outbound_list:*' in user_permissions: return item_dict for field, perm_code in field_to_perm.items(): if field in item_dict and perm_code not in user_permissions: item_dict[field] = None # 递归处理明细子项 if 'items' in item_dict and isinstance(item_dict['items'], list): for sub_item in item_dict['items']: filter_item_by_permissions(sub_item, user_permissions) return item_dict # ============================================================================== # 辅助函数:本单预占索引(扫码阶段回加「本单自己锁掉的货」) # ============================================================================== # 扫码页面上工人应当能以「实时可用量 + 本单预占量」为上限 —— 本单自己锁定 # 的货当然扫得进去。若直接用实时 available_quantity,被本单占满的行会显示 0, # 工人根本扫不进去(见 create.vue / borrow.vue 的扫码校验)。 # # ★ 两道必须守住的门禁 # 1) biz_type 区分单据表。outbound_approval 与 borrow_approval 是两张独立 # 表、ID 空间独立,而 /scan 与 /alternatives 被出库页与借库页**共用**。 # 只传 request_id 会让借库单 ID 命中另一张出库单,把别人的预占回加到 # 本行 —— 后端最终仍会拦住(不会真超卖),但表现为「扫完提交被拒」, # 比现状更难排查。 # 2) status ∈ {0, 1}。set_items() 只在创建时调用,执行(3)/驳回(2)/完结(4) # 后 items_json 里的 reserved=True / allocated_qty 原样保留,而 # release_reserved() 早已把库存归还 —— 此时再回加就是凭空多出一份 # 可用量,且后端也会放行(该 available 真实存在)→ 真超卖。 # 反之也不能写死 ==1:预占在**提交申请时**就发生(status=0),而两个 # 审批页默认筛选的就是待审批单。 # # 任何异常一律静默降级为「不回加」:这是读侧辅助接口,报错会直接阻断现场 # 作业;降级方向是 fail-closed(有效量偏小),最坏提示「库存不足」,绝不超卖。 def _own_reserved_index(biz_type, request_id): """ 本单预占索引 {(source_table, stock_id): 预占量}。 不满足状态门禁 / 单据不存在 / 参数非法时返回 {}(降级为不回加)。 注意返回的是**实时重算**的结果,不做任何累加,因此草稿反复刷新幂等。 """ if not request_id: return {} if (biz_type or '').strip().lower() == 'borrow': from app.models.borrow import BorrowApproval as _Approval else: # 缺省按出库兜底,兼容未传 biz_type 的旧调用方 from app.models.outbound import OutboundApproval as _Approval try: approval = _Approval.query.get(int(request_id)) except (TypeError, ValueError): return {} if not approval: current_app.logger.warning( f"[reservation] 预占回加降级:单据不存在 biz_type={biz_type} id={request_id}" ) return {} # ★ 二次回加门禁:仅待审批(0)/已通过(1)的单据,其预占才真实存在 if approval.status not in (0, 1): return {} from app.services.inventory_reservation import reserved_index return reserved_index(approval.get_items()) # -------------------------------------------------------- # 1. 扫码查询库存接口 (关联三个库存表) # GET /api/v1/outbound/scan?barcode=... # -------------------------------------------------------- @outbound_bp.route('/scan', methods=['GET']) @jwt_required() @permission_required('outbound_selection') def scan_barcode(): barcode = request.args.get('barcode') if not barcode: return jsonify({'code': 400, 'msg': '请提供条码'}), 400 # ★ 本单预占回加:biz_type 区分出库/借库两张审批单,缺一不可 biz_type = (request.args.get('biz_type') or 'outbound').strip() request_id = request.args.get('request_id', type=int) reserved_map = _own_reserved_index(biz_type, request_id) try: # 调用 Service 层去三个表中查找 (Service已更新,会返回价格) result = OutboundService.get_stock_by_barcode(barcode, reserved_map) if result: # ★ Fail-Closed: 扫码响应剥离价格字段 result.pop('price', None) # 预占是否生效:false 表示已降级为实时可用量(前端可据此提示) result['reservation_applied'] = bool(reserved_map) return jsonify({ 'code': 200, 'msg': '扫描成功', 'data': result }) else: return jsonify({ 'code': 404, 'msg': '未找到对应的库存记录,请确认条码是否正确' }), 404 except ValueError as e: # ★ 业务性拒绝(物料状态异常等):属于「扫到了但按规定不能出」, # 不是系统故障 —— 返回 400 + 明确文案,便于前端红字提示工人。 # 若落到下方 500 分支,前端只会显示「服务器错误」,工人无从判断。 return jsonify({'code': 400, 'msg': str(e)}), 400 except Exception as e: traceback.print_exc() return jsonify({'code': 500, 'msg': f'扫描查询出错: {str(e)}'}), 500 # -------------------------------------------------------- # 2. 提交出库单接口 (批量) # POST /api/v1/outbound # -------------------------------------------------------- @outbound_bp.route('', methods=['POST']) @jwt_required() @prevent_double_submit(lock_timeout=5) def create_outbound(): # 权限检查:有 outbound_selection 菜单或操作权限即可提交 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', []) outbound_perms = [p for p in perms if 'outbound' in p.lower() or 'selection' in p.lower() or 'create' in p.lower()] current_app.logger.warning( f"[出库权限调试] role={user_role}, company={user_company}, " f"出库相关权限={outbound_perms}, 全部权限数={len(perms)}" ) if 'outbound_selection' not in perms and not any( p.startswith('outbound_selection:') or p.startswith('outbound_create:') for p in perms ): return jsonify({'code': 403, 'msg': '权限不足'}), 403 data = request.get_json() if not data: return jsonify({'code': 400, 'msg': '无有效数据'}), 400 # 获取当前登录用户名 (JWT identity) current_user_name = get_jwt_identity() if not current_user_name: current_user_name = 'Unknown' # 获取最终的操作员名称 final_operator = data.get('operator_name') if not final_operator: final_operator = current_user_name # 必填校验 (针对整个单据) # items 必须是列表且不为空,consumer_name 和 signature_path 必填 if 'items' not in data or not data['items']: return jsonify({'code': 400, 'msg': '出库商品列表不能为空'}), 400 if not data.get('consumer_name') or not data.get('signature_path'): return jsonify({'code': 400, 'msg': '领用人及签名信息缺失'}), 400 try: # ★ [修改] 调用批量创建服务 outbound_no = OutboundService.create_outbound_batch(data, operator_name=final_operator) return jsonify({ 'code': 200, 'msg': '出库成功', 'data': {'outbound_no': outbound_no} }) 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 # -------------------------------------------------------- # 3. 获取出库记录列表 (分组展示) # GET /api/v1/outbound # -------------------------------------------------------- @outbound_bp.route('', methods=['GET']) @jwt_required() @permission_required('outbound_list') def get_outbound_list(): try: page = int(request.args.get('page', 1)) limit = int(request.args.get('limit', 10)) keyword = request.args.get('keyword', '') search_type = request.args.get('search_type', 'all') company = request.args.get('company', '') # ★ 高级筛选:JSON 字符串 → 条件列表(解析失败退化为空,不影响主查询) from app.utils.advanced_filter import parse_advanced_filters advanced_filters = parse_advanced_filters( request.args.get('advancedFilters', '') ) # ★ 数据权限:普通用户只看“领用人=本人姓名(不含账号前缀)”的出库记录;管理者看全部 consumer_name = None if not is_privileged_viewer(): _identity = get_jwt_identity() if _identity: from app.models.system import SysUser _u = SysUser.query.get(int(_identity)) # username 形如 “中文名/xiaolongxia” → 取“/”前的领用人姓名 _uname = _u.username if _u else '' consumer_name = _uname.split('/')[0].strip() if _uname else None # ★ [修改] 调用分组查询服务,支持搜索类型 result = OutboundService.get_grouped_list( page, limit, keyword, search_type=search_type, company=company, consumer_name=consumer_name, advanced_filters=advanced_filters, ) # 字段级脱敏 user_permissions = get_current_user_permissions() if result.get('items'): result['items'] = [filter_item_by_permissions(item, user_permissions) for item in result['items']] return jsonify({ 'code': 200, 'msg': '获取成功', 'data': result }) except Exception as e: traceback.print_exc() 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): """ ★ BOM 需求分配核心 对每个 base_id: 1. 直接查库取该物料的**全部可用库存行**(available_quantity > 0); 2. 按库位优先、库存量降序排序(大行优先,减少拆分行数); 3. 依次扣减 required_qty,为每一行产出 (stock_id, source_table, allocated_qty); 4. 分配不足时记录缺口,供前端提示,但不阻断其它物料的分配。 返回的每一行都携带真实 stock_id 与 source_table,可直接入购物车; 因为这些数字直接来自 DB,前端无需(也不应)再做任何分配运算。 并发说明:此处只读取快照用于装配购物车,真正扣减在提交出库时由 create_outbound_batch 以 with_for_update 加锁并二次校验可用量。 """ from flask import jsonify from sqlalchemy.orm import joinedload # ★ 必须在此导入:本函数模块级作用域不可见 # 归一化需求,容忍字符串数字 # # ★ 必须按 base_id 合并。同一物料会以多行进入本函数,来源都是正常业务: # · 购物车里同一物料的多批次就是多行(Selection.vue 提交时只带 # base_id + quantity,stock_id 被丢弃); # · BOM 明细里同一子件被多处引用(前端 requirements 按 child_id 不去重); # · 调拨 / 补发等拆行场景。 # 而下方分配是「按 base_id 拉全量批次行、降序分配」,且候选快照在整轮 # for req 循环里**不更新** —— 同一个 base_id 出现 N 行,每行都会从同一份 # 快照重新分配一遍,把同一个 stock_id 重复分配 N 次。 # 超配在分配阶段不会暴露,直到 reserve_for_items 的二次校验 # (take > avail)才炸,报「可用库存不足」而实际库存充足。 # 合并后 required_qty 求和,分配语义不变(输入里的 stock_id 本就被忽略)。 # ★ 不采用「合并入参 items」的方案:那是症状侧,_allocate_bom_requirements # 才是全系统库存分配的唯一权威入口,在此收口可同时覆盖出库与借库。 reqs = [] merged = {} for r in requirements: try: bid = int(r.get('base_id')) except (TypeError, ValueError): continue try: need = float(r.get('required_qty') or 0) except (TypeError, ValueError): need = 0.0 if bid <= 0 or need <= 0: continue hit = merged.get(bid) if hit is not None: hit['required_qty'] += need else: merged[bid] = {'base_id': bid, 'required_qty': need, 'name': r.get('name') or '', 'spec_model': r.get('spec_model') or ''} reqs = list(merged.values()) if not reqs: return jsonify({'code': 400, 'msg': 'requirements 中无有效的 base_id/required_qty'}), 400 base_ids = list({r['base_id'] for r in reqs}) # ---- 一次性拉取全部候选库存行(三表)---- # 按 (base_id, source_table) 归集 rows_by_base = {} # ★ 状态门槛:本函数是**全系统库存分配的唯一权威入口** —— # 出库申请与借库申请都经 reserve_for_items() 走到这里拿候选行, # 故在此处加一条即对两者同时生效(报废不走分配器,见 scrap_approval_service)。 # 规则见 inventory_reservation.allocatable_filter:仅「在库」可被分配, # 「冻结」「不良品」以及 status 为 NULL 的行一律不进入候选集。 # # Fail-Closed 说明:若该条件导致查询异常,下方 except 会 continue 掉整张表, # 表现为「该物料无库存」而非「放行坏件」,方向上是安全的。 from app.services.inventory_reservation import allocatable_filter for model, source_table, type_label, type_key in ( (StockBuy, 'stock_buy', '采购件', 'material'), (StockSemi, 'stock_semi', '半成品', 'semi'), (StockProduct, 'stock_product', '成品', 'product'), ): try: q = model.query.filter( model.base_id.in_(base_ids), model.available_quantity > 0, # ★ 只取真正可用的行 allocatable_filter(model), # ★ 硬隔离:非「在库」一律不出货 ) if company_limit is not None: q = q.filter(model.base.has(MaterialBase.company_name == company_limit)) rows = q.options(joinedload(model.base)).all() except Exception as e: # 不静默:某张表查询失败会直接表现为"该物料无库存",极难排查 current_app.logger.error( f"[bom-allocate] {source_table} 查询失败: {type(e).__name__}: {e}" ) continue for s in rows: bid = int(s.base_id) rows_by_base.setdefault(bid, []).append( (float(s.available_quantity or 0), source_table, type_key, type_label, s) ) # ---- 逐物料分配 ---- allocated_items = [] shortages = [] for req in reqs: bid = req['base_id'] remaining = req['required_qty'] # 可用量降序:优先进大行,减少购物车拆分行数 candidates = sorted(rows_by_base.get(bid, []), key=lambda x: -x[0]) if not candidates: shortages.append({ 'base_id': bid, 'name': req['name'], 'spec_model': req['spec_model'], 'required_qty': remaining, 'allocated_qty': 0, 'missing': remaining, }) continue for avail, source_table, type_key, type_label, s in candidates: if remaining <= 0: break take = min(remaining, avail) if take <= 0: continue d = s.to_dict() d['stock_id'] = s.id d['source_table'] = source_table d['type'] = type_key d['stock_type'] = type_key d['typeLabel'] = type_label d['uniqueKey'] = f"{type_key}_{s.id}" d['name'] = d.get('material_name') or (s.base.name if s.base else '') or '' d['standard'] = d.get('spec_model') or (s.base.spec_model if s.base else '') or '' d['warehouse_location'] = getattr(s, 'warehouse_location', '') or '' d['available_quantity'] = float(s.available_quantity or 0) d['allocated_qty'] = take # ★ 本次分配给该行的数量 d['export_quantity'] = take # 兼容购物车字段名 # Fail-Closed: 剥离价格成本字段 for k in ('unit_price', 'post_tax_unit_price', 'pre_tax_unit_price', 'total_price', 'tax_rate', 'currency', 'exchange_rate', 'sale_price', 'raw_material_cost', 'manual_cost', 'unit_total_cost'): d.pop(k, None) allocated_items.append(d) remaining -= take if remaining > 0: shortages.append({ 'base_id': bid, 'name': req['name'], 'spec_model': req['spec_model'], 'required_qty': req['required_qty'], 'allocated_qty': req['required_qty'] - remaining, 'missing': remaining, }) return jsonify({ 'code': 200, 'msg': 'success', 'data': { 'items': allocated_items, 'shortages': shortages, 'summary': { 'requested': len(reqs), 'allocated_kinds': len({i['base_id'] for i in allocated_items}), 'shortage_kinds': len(shortages), }, } }), 200 # ============================================================================== # 备选库位查询 (GET /api/v1/outbound/alternatives) # # 场景:申请单已把货预占在某个库位,但工人到现场发现该库位进不去/找不到, # 需要改扫同物料的其它批次。改造前系统不告诉他「还有哪些库位有货」, # 工人只能凭记忆或挨个翻 —— 这个接口就是为「物理覆盖」提供可见性。 # # 与 bom-match-stock 查询模式的区别: # · 该模式按 stock_quantity > 0 过滤,会把「有货但已被别单全部预占」的 # 库位也列出来,工人跑过去才发现拿不到货; # · 本接口按 available_quantity > 0 过滤,只给**真正能拿**的库位, # 并额外标注哪一条是本单锁定的推荐行。 # ============================================================================== @outbound_bp.route('/alternatives', methods=['GET']) @jwt_required() def get_stock_alternatives(): """ 查询某物料的全部可替代库位。 Query: base_id(必填)、source_table / stock_id(可选,用于标注推荐行) biz_type / request_id(可选,出库/借库单据;用于回加本单预占) ★ available_quantity 返回的是「有效可用量」= 实时可用量 + 本单在该行的预占量, 因此过滤条件也相应放宽为「实时可用量 > 0 或 本单预占了该行」。 否则被本单占满的行会从列表里凭空消失(实时可用量为 0),工人看不到 自己明明锁定的批次。实时原值另以 raw_available_quantity 返回备查。 别人的预占不回加,防超卖能力不丢。 Returns: { items: [{stock_id, source_table, warehouse_location, available_quantity, raw_available_quantity, reserved_quantity, is_own_reserved, is_locked, typeLabel, sku, batch_number}], total_available } """ try: base_id = request.args.get('base_id', type=int) if not base_id: return jsonify({'code': 400, 'msg': 'base_id 不能为空'}), 400 prefer_table = (request.args.get('source_table') or '').strip() try: prefer_stock_id = int(request.args.get('stock_id') or 0) except (TypeError, ValueError): prefer_stock_id = 0 # ★ 本单预占回加(biz_type 区分出库/借库两张审批单) biz_type = (request.args.get('biz_type') or 'outbound').strip() request_id = request.args.get('request_id', type=int) reserved_map = _own_reserved_index(biz_type, request_id) # 按库存表分组本单预占的 stock_id,供 OR 过滤使用 own_ids = {} for (st, sid) in reserved_map: own_ids.setdefault(st, set()).add(sid) from app.utils.decorators import get_current_company_filter from app.models.base import MaterialBase from app.models.inbound.buy import StockBuy from app.models.inbound.semi import StockSemi from app.models.inbound.product import StockProduct from sqlalchemy import or_ from sqlalchemy.orm import joinedload company_limit = get_current_company_filter() items = [] for model, source_table, label in ( (StockBuy, 'stock_buy', '采购件'), (StockSemi, 'stock_semi', '半成品'), (StockProduct, 'stock_product', '成品'), ): # ★ 只给真正能拿的;但本单自己预占的行即使实时可用量为 0 也要给 # (否则工人看不到自己锁定的批次)。 # 注意 id 集合为空时不能拼 in_([]):SQLAlchemy 会渲染成恒假 # 表达式并告警,这里退化为原条件。 condition = model.available_quantity > 0 _ids = own_ids.get(source_table) if _ids: condition = or_(condition, model.id.in_(_ids)) q = model.query.filter(model.base_id == base_id, condition) # 公司隔离作用于整个 query,保持在 OR 之外 if company_limit is not None: q = q.filter(model.base.has(MaterialBase.company_name == company_limit)) try: rows = q.options(joinedload(model.base)).all() except Exception as e: current_app.logger.error( f"[alternatives] {source_table} 查询失败: {type(e).__name__}: {e}" ) continue for s in rows: raw_avail = float(s.available_quantity or 0) reserved = float(reserved_map.get((source_table, s.id), 0) or 0) items.append({ 'stock_id': s.id, 'source_table': source_table, 'typeLabel': label, 'sku': s.sku or '', 'batch_number': getattr(s, 'batch_number', '') or getattr(s, 'serial_number', '') or '', 'warehouse_location': getattr(s, 'warehouse_location', '') or '', # 有效可用量:本单可拿的上限 'available_quantity': raw_avail + reserved, 'raw_available_quantity': raw_avail, 'reserved_quantity': reserved, # ★ 本单已锁定该行(分配器可能跨批次拆分,故可能是多行) 'is_own_reserved': reserved > 0, # ★ 该行是否就是本单锁定的推荐批次 'is_locked': (prefer_stock_id and s.id == prefer_stock_id and source_table == prefer_table), }) # 排序:推荐行置顶,其余按有效可用量降序(工人优先看到货最多的库位) items.sort(key=lambda x: (not x['is_locked'], -x['available_quantity'])) return jsonify({ 'code': 200, 'msg': 'success', 'data': { 'items': items, 'total_available': round(sum(i['available_quantity'] for i in items), 4), 'reservation_applied': bool(reserved_map), } }), 200 except Exception as e: traceback.print_exc() return jsonify({'code': 500, 'msg': f'查询备选库位失败: {str(e)}'}), 500 # ============================================================================== # BOM 匹配库存接口 (POST /api/v1/outbound/bom-match-stock) # # ★ 两种用法: # 1) 分配模式(推荐):传 requirements=[{base_id, required_qty, ...}] # 后端在 DB 层完成「跨批次分配」,返回精确的 (stock_id, source_table, allocated_qty) # 2) 查询模式(兼容旧调用):传 child_ids=[...],返回该批 base_id 的全部库存行 # # 为什么分配必须在后端做 # ---------------------- # 分配需要「该 base_id 的全部可用库存行」这一完整视图,且必须与出库扣减 # (outbound_service.create_outbound_batch 按 stock_id 逐行 with_for_update 扣减) # 使用同一套数据。放在前端会引入两类必然故障: # · 前端 stockList 由多个入口写入(手动选单/搜索/BOM),随时可能被覆盖; # · base_id 与 stock_id 的类型/精度差异会导致匹配落空,静默算成"缺料"。 # 后端直接查库分配,从根本上消除上述不确定性。 # ============================================================================== @outbound_bp.route('/bom-match-stock', methods=['POST']) @jwt_required() def bom_match_stock(): """ BOM 库存匹配 / 分配。 分配模式 Body: { "requirements": [ {"base_id": 123, "required_qty": 10, "name": "...", "spec_model": "..."}, ... ] } Returns: { "code": 200, "data": { "items": [ # 已分配好的库存行,前端可直接入购物车 {"base_id", "stock_id", "source_table", "allocated_qty", "available_quantity", "sku", "name", ..., "shortage": 0} ], "shortages": [{"base_id", "name", "required_qty", "allocated_qty", "missing"}] } } 查询模式 Body: { "child_ids": [1, 2, 3] } → 返回全部匹配库存行(旧行为) """ try: data = request.get_json() or {} requirements = data.get('requirements') child_ids = data.get('child_ids', []) if not requirements and not child_ids: return jsonify({'code': 400, 'msg': 'requirements 或 child_ids 不能为空'}), 400 # ★ 行级公司隔离:普通用户只能匹配本公司的库存(超管/跨域不受限) from app.utils.decorators import get_current_company_filter from app.models.base import MaterialBase company_limit = get_current_company_filter() 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 # ------------------------------------------------------------------ # ★ 分配模式:后端完成跨批次分配 # ------------------------------------------------------------------ if requirements: return _allocate_bom_requirements( requirements, company_limit, StockBuy, StockSemi, StockProduct, MaterialBase, ) # ------------------------------------------------------------------ # 查询模式(兼容旧调用):返回全部匹配库存行 # ------------------------------------------------------------------ # 去重 child_ids = list(set(int(x) for x in child_ids)) all_items = [] # 采购件 buy_items = StockBuy.query.filter( StockBuy.base_id.in_(child_ids), StockBuy.stock_quantity > 0 ) if company_limit is not None: buy_items = buy_items.filter(StockBuy.base.has(MaterialBase.company_name == company_limit)) buy_items = buy_items.options(joinedload(StockBuy.base)).all() for s in buy_items: d = s.to_dict() d['type'] = 'material' d['stock_type'] = 'material' d['typeLabel'] = '采购件' d['uniqueKey'] = f"material_{s.id}" d['name'] = d.get('material_name', '') d['standard'] = d.get('spec_model', '') all_items.append(d) # 半成品 try: semi_items = StockSemi.query.filter( StockSemi.base_id.in_(child_ids), StockSemi.stock_quantity > 0 ) if company_limit is not None: semi_items = semi_items.filter(StockSemi.base.has(MaterialBase.company_name == company_limit)) semi_items = semi_items.options(joinedload(StockSemi.base)).all() for s in semi_items: d = s.to_dict() d['type'] = 'semi' d['stock_type'] = 'semi' d['typeLabel'] = '半成品' d['uniqueKey'] = f"semi_{s.id}" d['name'] = d.get('material_name', '') d['standard'] = d.get('spec_model', '') all_items.append(d) except Exception: pass # 成品 try: prod_items = StockProduct.query.filter( StockProduct.base_id.in_(child_ids), StockProduct.stock_quantity > 0 ) if company_limit is not None: prod_items = prod_items.filter(StockProduct.base.has(MaterialBase.company_name == company_limit)) prod_items = prod_items.options(joinedload(StockProduct.base)).all() for s in prod_items: d = s.to_dict() d['type'] = 'product' d['stock_type'] = 'product' d['typeLabel'] = '成品' d['uniqueKey'] = f"product_{s.id}" d['name'] = d.get('material_name', '') d['standard'] = d.get('spec_model', '') all_items.append(d) except Exception: pass # ★ Fail-Closed: 剥离所有价格成本字段(BOM 匹配用于出库选单,无需价格) for d in all_items: stype = d.get('stock_type', '') if stype == 'material': for k in ('unit_price', 'post_tax_unit_price', 'total_price', 'tax_rate', 'currency', 'exchange_rate'): d.pop(k, None) elif stype == 'semi': for k in ('raw_material_cost', 'manual_cost', 'unit_total_cost', 'total_price', 'unit_price'): d.pop(k, None) elif stype == 'product': for k in ('raw_material_cost', 'manual_cost', 'unit_total_cost', 'sale_price', 'unit_price'): d.pop(k, None) return jsonify({'code': 200, 'msg': 'success', 'data': {'items': all_items}}) except Exception as e: traceback.print_exc() return jsonify({'code': 500, 'msg': str(e)}), 500 # ============================================================================== # 出库审批相关接口 # ============================================================================== from app.services.outbound_service import OutboundApprovalService def get_current_user_id(): """获取当前用户ID""" from app.models.system import SysUser identity = get_jwt_identity() if not identity: return None # JWT identity 是数据库主键整数,直接用 .get() 查询 user = SysUser.query.get(identity) return user.id if user else None def get_current_user_info(): """获取当前用户信息和角色""" from app.models.system import SysUser identity = get_jwt_identity() if not identity: return None, None # JWT identity 是数据库主键整数,直接用 .get() 查询 user = SysUser.query.get(identity) return user.id if user else None, user.role if user else None # -------------------------------------------------------- # 4. 创建出库审批单 # POST /api/v1/outbound/request # -------------------------------------------------------- @outbound_bp.route('/request', methods=['POST']) @jwt_required() @permission_required('outbound_selection') def create_outbound_request(): """ 创建出库审批单(申请阶段,用户只需提交宏观物料信息,无需关联具体库存记录) 请求体示例: { "items": [ { "name": "物料A", // 物料名称 (必填) "spec_model": "规格1", // 规格型号 (必填) "quantity": 10, // 计划出库数量 (必填) "warehouse_location": "A区-01-01", // 库位 (可选) "remark": "备注信息" // 物品备注 (可选) } ], "allowed_approvers": [ {"type": "role", "value": "SUPERVISOR"}, {"type": "role", "value": "SUPER_ADMIN"} ], "remark": "紧急出库申请" } """ try: user_id, user_role = get_current_user_info() if not user_id: return jsonify({'code': 401, 'msg': '用户未登录'}), 401 data = request.get_json() if not data: return jsonify({'code': 400, 'msg': '无有效数据'}), 400 items = data.get('items', []) if not items: return jsonify({'code': 400, 'msg': '出库物品列表不能为空'}), 400 # ★ 申请阶段仅校验宏观字段:名称、规格、数量 required_fields = ['name', 'spec_model', 'quantity'] for idx, item in enumerate(items): missing = [f for f in required_fields if f not in item or item.get(f) is None or str(item.get(f)).strip() == ''] if missing: return jsonify({ 'code': 400, 'msg': f'第{idx + 1}条物品缺少必填字段: {", ".join(missing)}。' f'必须包含: name(名称), spec_model(规格), quantity(数量)' }), 400 try: qty = float(item.get('quantity', 0)) if qty <= 0: return jsonify({'code': 400, 'msg': f'第{idx + 1}条物品的出库数量必须大于0'}), 400 except (TypeError, ValueError): return jsonify({'code': 400, 'msg': f'第{idx + 1}条物品的 quantity 格式无效'}), 400 # ★ 指定审批人:前端传 approver_id 则精准通知,否则用默认角色规则 approver_id = data.get('approver_id') _default_approvers = [ {"type": "role", "value": "SUPERVISOR"}, {"type": "role", "value": "SUPER_ADMIN"} ] allowed_approvers = data.get('allowed_approvers') or _default_approvers # 创建审批单(直接存储前端传来的宏观信息快照,不查询库存) approval = OutboundApprovalService.create_request( applicant_id=user_id, items=items, allowed_approvers=allowed_approvers, remark=data.get('remark'), approver_id=approver_id, outbound_type=data.get('outbound_type'), # 出库类型(申请时确定) force_approval=((user_role or '').upper() == 'WAREHOUSE_MGR') # 库管代建 → 强制审批 ) return jsonify({ 'code': 200, 'msg': '审批单创建成功', 'data': approval.to_dict() }), 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 # -------------------------------------------------------- # 4.1 出库申请预检(判断所选物料是否需审批,驱动前端是否显示审批人) # POST /api/v1/outbound/request/check-approval # -------------------------------------------------------- @outbound_bp.route('/request/check-approval', methods=['POST']) @jwt_required() @permission_required('outbound_selection') def check_outbound_approval(): try: data = request.get_json() or {} items = data.get('items', []) or [] from app.services.approval_control import resolve_approval_control need_approval, flagged = resolve_approval_control(items) return jsonify({ "code": 200, "msg": "success", "data": {"need_approval": need_approval, "materials": flagged} }), 200 except Exception as e: traceback.print_exc() return jsonify({"code": 500, "msg": f"预检失败: {str(e)}"}), 500 # -------------------------------------------------------- # 5. 审批出库申请 # PATCH /api/v1/outbound/request//approve # -------------------------------------------------------- @outbound_bp.route('/request//approve', methods=['PATCH']) @jwt_required() @permission_required('outbound_approval') def approve_outbound_request(request_id): """ 审批出库申请 请求体示例: { "action": "approve", // "approve" 通过, "reject" 驳回 "reject_reason": "库存不足" // 仅在驳回时需要 } """ try: user_id, user_role = get_current_user_info() if not user_id: return jsonify({'code': 401, 'msg': '用户未登录'}), 401 data = request.get_json() or {} action = data.get('action', 'approve') reject_reason = data.get('reject_reason') if action not in ('approve', 'reject'): return jsonify({'code': 400, 'msg': '无效的审批操作,仅支持 approve 或 reject'}), 400 if action == 'reject' and not reject_reason: return jsonify({'code': 400, 'msg': '驳回时必须提供原因'}), 400 success, message, approval = OutboundApprovalService.approve( request_id=request_id, user_id=user_id, user_role=user_role, action=action, reject_reason=reject_reason ) if not success: return jsonify({'code': 400, 'msg': message}), 400 return jsonify({ 'code': 200, 'msg': message, 'data': approval.to_dict() if approval else None }), 200 except Exception as e: traceback.print_exc() return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500 # -------------------------------------------------------- # 5.5 手动完结/作废审批单 # POST /api/v1/outbound/request//close # -------------------------------------------------------- @outbound_bp.route('/request//close', methods=['POST']) @jwt_required() @permission_required('outbound_approval') def close_outbound_request(request_id): """ 手动完结/作废已通过的审批单(状态 1-已通过 → 4-已完结) 适用场景:已通过但无法出库/作废的单据,库管手动清理, 使其从"已审批通过"列表中消失。 """ try: user_id, user_role = get_current_user_info() if not user_id: return jsonify({'code': 401, 'msg': '用户未登录'}), 401 success, message, approval = OutboundApprovalService.close_request( request_id=request_id, user_id=user_id, user_role=user_role ) if not success: return jsonify({'code': 400, 'msg': message}), 400 return jsonify({ 'code': 200, 'msg': message, 'data': approval.to_dict() if approval else None }), 200 except Exception as e: traceback.print_exc() return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500 # -------------------------------------------------------- # 5.6 申请人撤回自己的申请单 # POST /api/v1/outbound/request//withdraw # -------------------------------------------------------- @outbound_bp.route('/request//withdraw', methods=['POST']) @jwt_required() def withdraw_outbound_request(request_id): """ 撤回自己的出库申请单(待审批 或 已通过但未执行)。 ★ 严格职责分离:本端点**不做模块权限校验**(@jwt_required 即可), 权限判定完全落在「单据归属」上 —— 服务层会断言 applicant_id == 当前用户,否则 403。库管/主管可代撤。 与 /close 的区别:/close 是管理路径(需 outbound_approval 权限), 本端点是申请人路径,两者共用底层释放逻辑。 """ try: identity = get_jwt_identity() if not identity: return jsonify({'code': 401, 'msg': '用户未登录'}), 401 claims = get_jwt() success, message, approval = OutboundApprovalService.withdraw_request( request_id=request_id, user_id=int(identity), user_role=claims.get('role'), ) if not success: # 归属不符按 403 返回,其余为业务校验失败(400) code = 403 if '无权' in message else 400 return jsonify({'code': code, 'msg': message}), code return jsonify({ 'code': 200, 'msg': message, 'data': approval.to_dict() if approval else None }), 200 except Exception as e: traceback.print_exc() return jsonify({'code': 500, 'msg': f'撤回失败: {str(e)}'}), 500 # -------------------------------------------------------- # 5.7 我的申请单(申请人视角) # GET /api/v1/outbound/my-requests # # ★ 严格职责分离:本端点仅需 @jwt_required,**不做模块权限校验**。 # applicant_id 在服务端硬编码为当前登录用户,不接受任何入参覆盖 —— # 因此普通申请人无需持有 outbound_approval(那是管理权限), # 也不可能借此看到他人的单据。 # # 这与「给审批端点加 if 降级放行」是两条路:后者把管理与用户逻辑 # 混在一个端点里,一旦 is_privileged_viewer() 判定出错就会越权; # 本端点从设计上就没有"看别人"的分支。 # -------------------------------------------------------- @outbound_bp.route('/my-requests', methods=['GET']) @jwt_required() def get_my_outbound_requests(): """ 查询当前登录用户提交的出库申请单。 Query: page / limit / status(可选,0待审 1已通过 2已驳回 3已完成 4已撤回) """ try: identity = get_jwt_identity() if not identity: return jsonify({'code': 401, 'msg': '用户未登录'}), 401 page = int(request.args.get('page', 1)) limit = int(request.args.get('limit', 10)) status = request.args.get('status') status = int(status) if status not in (None, '', 'all') else None result = OutboundApprovalService.get_request_list( page=page, per_page=limit, applicant_id=int(identity), # ★ 硬编码,不接受入参覆盖 status=status, ) return jsonify({'code': 200, 'msg': '获取成功', 'data': result}), 200 except Exception as e: traceback.print_exc() return jsonify({'code': 500, 'msg': f'获取我的申请单失败: {str(e)}'}), 500 # -------------------------------------------------------- # 6. 获取审批单列表 # GET /api/v1/outbound/request # -------------------------------------------------------- @outbound_bp.route('/request', methods=['GET']) @jwt_required() @permission_required('outbound_approval') def get_outbound_request_list(): """ 获取出库审批单列表 Query参数: - page: 页码 (默认1) - limit: 每页数量 (默认10) - applicant_id: 按申请人筛选 (可选) - status: 按状态筛选 (0待审/1通过/2驳回/3完成, 可选) """ try: page = int(request.args.get('page', 1)) limit = int(request.args.get('limit', 10)) applicant_id = request.args.get('applicant_id') if applicant_id: applicant_id = int(applicant_id) status = request.args.get('status') if status is not None: status = int(status) # ★ 数据权限:普通申请人只能看“自己的”出库记录;库管/主管/超管(或跨域)才可看他人 if not is_privileged_viewer(): identity = get_jwt_identity() applicant_id = int(identity) if identity else None result = OutboundApprovalService.get_request_list( page=page, per_page=limit, applicant_id=applicant_id, status=status ) return jsonify({ 'code': 200, 'msg': '获取成功', 'data': result }), 200 except Exception as e: traceback.print_exc() return jsonify({'code': 500, 'msg': str(e)}), 500 # -------------------------------------------------------- # 7. 获取单个审批单详情 # GET /api/v1/outbound/request/ # -------------------------------------------------------- @outbound_bp.route('/request/', methods=['GET']) @jwt_required() @permission_required('outbound_approval') def get_outbound_request_detail(request_id): """获取出库审批单详情""" try: approval = OutboundApprovalService.get_request_by_id(request_id) if not approval: return jsonify({'code': 404, 'msg': '审批单不存在'}), 404 # ★ 数据权限:普通申请人只能看自己的单;库管/主管/超管(或跨域)可看任意 if not is_privileged_viewer(): identity = get_jwt_identity() if int(approval.applicant_id or 0) != int(identity or 0): return jsonify({'code': 403, 'msg': '无权查看他人的出库记录'}), 403 return jsonify({ 'code': 200, 'msg': '获取成功', 'data': approval.to_dict() }), 200 except Exception as e: traceback.print_exc() return jsonify({'code': 500, 'msg': str(e)}), 500