# inventory-backend/app/api/v1/warehouse.py from flask import Blueprint, request, jsonify from flask_jwt_extended import jwt_required from sqlalchemy import func, or_, text from app.extensions import db from app.models.system import SysWarehouseLocation from app.utils.decorators import get_current_company_filter warehouse_bp = Blueprint('warehouse', __name__, url_prefix='/api/v1/warehouse') # 不能作为库位归属落库的取值 —— 一律折算成 NULL(全局共享)。 # ★ 超管的 JWT company_name 是 'System'(见 auth_service 内置超管分支), # 若原样写进库位,这批库位对 IRIS/LICA 双方都不可见,而且无从排查。 _GLOBAL_COMPANY_SENTINELS = {'', 'SYSTEM', 'ALL', '__NO_COMPANY__'} class _InvalidCompany(ValueError): """公司归属取值非法:不在 material_base 的公司清单里。""" def _valid_companies(): """ 真实存在的公司清单。 口径与 /api/v1/inbound/base/options 的 companies 一致:material_base 去重。 ★ 延迟导入 MaterialBase —— 本文件在 create_app() 期间就被注册,模块级导入模型 有踩到「模型尚未映射」的先例(见 audit_listener 的惰性补绑)。 """ from app.models.base import MaterialBase rows = db.session.query(MaterialBase.company_name).filter( MaterialBase.company_name.isnot(None), MaterialBase.company_name != '', ).distinct().all() return {str(r[0]).strip() for r in rows} def _normalize_company(raw): """ 把入参收敛为合法归属:真实存在的公司名,或 None(全局共享)。 ★ 非法值一律抛错,不静默落库。拼错的公司名(大小写不符、前后带空格) 会造出一批**任何公司都看不见**的库位,界面上完全看不出问题。 """ value = (raw or '').strip() if value.upper() in _GLOBAL_COMPANY_SENTINELS: return None valid = _valid_companies() if value not in valid: raise _InvalidCompany(f'公司不存在:{value!r},可选:{sorted(valid)}') return value def _bad_company(e): return jsonify({'code': 400, 'msg': str(e), 'data': None}), 400 def _subtree_leaf_ids(parent): """ 取「以 parent 为根(**含自身**)的子树」中的全部末级库位 id,按 id 升序。 末级 = **没有子节点**,不是「level 最深」。两者只在规则化生成的树上等价, 手建的树深浅不一,按 level 判断会漏掉浅枝上的末级节点。 ★ 子查询显式过滤 parent_id IS NOT NULL —— 否则 NULL 进了 NOT IN, 整个条件恒为 NULL,一个都选不出来(SQL 三值逻辑的经典坑)。 """ has_children = db.session.query(SysWarehouseLocation.parent_id).filter( SysWarehouseLocation.parent_id.isnot(None) ) q = SysWarehouseLocation.query.filter(~SysWarehouseLocation.id.in_(has_children)) prefix = f"{parent.full_path}/" if parent.full_path else '' if prefix: q = q.filter(or_( SysWarehouseLocation.id == parent.id, SysWarehouseLocation.full_path.startswith(prefix, autoescape=True), )) else: # full_path 为空的老数据:只能退化成它自己 q = q.filter(SysWarehouseLocation.id == parent.id) return [r.id for r in q.order_by(SysWarehouseLocation.id.asc()).all()] def _count_generated(num_seeds, rules): """ 按规则逐层展开后**实际会创建**多少个节点 = 各层规模之和 × 起点个数。 ★ 不是「各层规模相乘」—— 那是**最底层**的规模。总数是各层之和: 第 1 层 P×N₁,第 2 层 P×N₁N₂ …… total = P × (N₁ + N₁N₂ + … + N₁…Nk) 旧实现直接拿乘积当总数并据此判 3000 上限,于是 50×60 预览显示 3000、 实际生成 3050,上限也被悄悄突破。 """ total = 0 level = 1 for rule in rules: n = int(rule.get('end', 1)) - int(rule.get('start', 1)) + 1 level *= max(0, n) total += level return total * num_seeds def _scope(query, company_limit): """ 读范围:本公司 + 全局共享。 company_limit 取自 get_current_company_filter(): None → 超管 / 拥有 crossDomain 权限,不加限制 'IRIS' / 'LICA' → 本公司 或 全局(NULL) '__NO_COMPANY__' → 哨兵值,等值匹配必然落空,退化成「只见全局库位」。 Fail-Closed:没有公司归属的账号宁可少看,不可多看。 """ if company_limit is None: return query return query.filter(or_( SysWarehouseLocation.company_name == company_limit, SysWarehouseLocation.company_name.is_(None), )) def _visible_to(location, company_limit): """节点对我是否可见(本公司 或 全局共享)。用于「能否在其下建子库位」。""" return (company_limit is None or location.company_name == company_limit or location.company_name is None) def _owned_by_me(location, company_limit): """ 节点是否归属本公司。用于改名 / 删除。 ★ 刻意**不**放行全局节点(company_name IS NULL):全局库位是两司共用的, 单方面改名或删除会波及另一家公司 —— 那是跨租户副作用,只留给超管。 ★ 读范围(_scope/_visible_to)比写范围宽,这是有意为之: 公司用户「看得见」全局库位,但「动不了」它。 """ return company_limit is None or location.company_name == company_limit def _forbidden(msg): return jsonify({'code': 403, 'msg': msg, 'data': None}), 403 # 库位改名后需要跟着改写 full_path 快照的「在库台账」表 —— 表名 → 库位列名。 # # ★ 口径(2026-09-29 确认):只含「货现在在哪」的台账,不含已完成的业务单据。 # · stock_buy / stock_semi / stock_product —— 当前库存。库位改名后必须跟着变, # 否则拣货路径排序、备选库位、盘点范围过滤全部指向已不存在的路径。 # · stock_adjustment —— 调整单台账,同理。 # # ✗ 刻意不含 trans_outbound / trans_borrow —— 那是已出库/已借出的历史单据, # 事后改写等于篡改当时的操作记录。改名后这些单据里的路径会解析不到, # 这是**已知且被接受**的代价,需要展示时由读取侧解析。 # ✗ 更不含 trans_repair.customer_location —— 那是**客户地址**,不是库位。 # ✗ trans_borrow.return_location 同理排除:前端是自由文本输入(非库位选择器), # 值不保证是 full_path,按路径前缀改写有误伤风险。 SNAPSHOT_TABLES = { 'stock_buy': 'warehouse_location', 'stock_semi': 'warehouse_location', 'stock_product': 'warehouse_location', 'stock_adjustment': 'warehouse_location', } def _sync_snapshot_locations(old_full_path, new_full_path): """ 库位改名后,把在库台账里的 full_path 快照一并改写。 ★ 与字典改名放在**同一个事务**(由调用方统一 commit),两者必须同生共死。 拆成后台任务的话,一旦失败就是「字典已改、台账没改」的静默不一致, 而当时没有谁会知道。实测四张表合计不足 3000 行,同步执行是毫秒级。 返回 {表名: 改写行数},供调用方回显核对。 """ if not old_full_path or old_full_path == new_full_path: return {} old_prefix = f"{old_full_path}/" new_prefix = f"{new_full_path}/" affected = {} # 表名/列名只能靠 f-string 拼进 SQL(标识符无法用绑定参数), # 取值全部来自上面的模块常量,不接受任何外部输入。 for table, column in SNAPSHOT_TABLES.items(): # 1) 子孙库位:old/A/B → new/A/B # ★ 用 left(...) 判前缀,不用 LIKE —— 库位名可能含 _ / %, # 裸 LIKE 会把它们当通配符(见 update_location 里同一处理)。 # ★ 用 substr 拼接而非 replace():replace 会替换字符串中**任意位置** # 的旧前缀,'L1/01/L1/01' 这类值会被一并改坏。 r = db.session.execute(text( f"UPDATE {table} " f" SET {column} = :new_prefix || substr({column}, length(:old_prefix) + 1) " f" WHERE left({column}, length(:old_prefix)) = :old_prefix" ), {'new_prefix': new_prefix, 'old_prefix': old_prefix}) count = r.rowcount or 0 # 2) 被改名节点自身持有的库存:old → new # 与上面互斥(精确值不以 "old/" 开头),不会重复计数。 r = db.session.execute(text( f"UPDATE {table} " f" SET {column} = :new_full_path " f" WHERE {column} = :old_full_path" ), {'new_full_path': new_full_path, 'old_full_path': old_full_path}) count += r.rowcount or 0 if count: affected[table] = count # 盘点会话的抽盘范围(scope_config.locations)存的也是 full_path,一并改 scope_count = _sync_stocktake_scopes(old_full_path, new_full_path) if scope_count: affected['stocktake_session'] = scope_count return affected def _sync_stocktake_scopes(old_full_path, new_full_path): """ 改写**进行中**盘点会话的抽盘范围。 scope_config 形如 {"locations": ["L1/01", "L1/02"], "recommend_days": 30}, locations 里存的正是 full_path。改名后不跟改,这个会话的范围就有一半 解析不到 —— 工人以为盘到了,实际那些库位根本没进范围。 ★ 只动 status='active' 的会话:已结束的会话是历史记录,改了等于篡改当时的范围。 """ from app.models.inbound.stocktake import STOCKTAKE_STATUS_ACTIVE old_prefix = f"{old_full_path}/" new_prefix = f"{new_full_path}/" # jsonb_typeof(...) = 'array' 同时挡掉「没有 locations 键」和「不是数组」两种形态, # 比 ? 'locations' 更严(后者对非数组值也返回 true)。 r = db.session.execute(text(""" UPDATE stocktake_session s SET scope_config = jsonb_set(s.scope_config, '{locations}', (SELECT jsonb_agg( CASE WHEN l = :old_full_path THEN :new_full_path WHEN left(l, length(:old_prefix)) = :old_prefix THEN :new_prefix || substr(l, length(:old_prefix) + 1) ELSE l END) FROM jsonb_array_elements_text(s.scope_config -> 'locations') AS l)) WHERE s.status = :active AND jsonb_typeof(s.scope_config -> 'locations') = 'array' AND EXISTS ( SELECT 1 FROM jsonb_array_elements_text(s.scope_config -> 'locations') AS x WHERE x = :old_full_path OR left(x, length(:old_prefix)) = :old_prefix) """), { 'old_full_path': old_full_path, 'new_full_path': new_full_path, 'old_prefix': old_prefix, 'new_prefix': new_prefix, 'active': STOCKTAKE_STATUS_ACTIVE, }) return r.rowcount or 0 def build_tree(nodes, parent_id=None): """ 将平铺的数据构建为树形结构(O(N) 内存组装,避免递归时每层全量扫描导致 O(N²)) 做法:先把全部节点按 parent_id 分组到 Map,再从根出发逐层用 Map 取子节点组装。 每个节点只被处理一次,显著快于"每次递归 for 遍历全表"的旧实现。 """ by_parent = {} for node in nodes: by_parent.setdefault(node.parent_id, []).append(node) def assemble(pid): kids = sorted(by_parent.get(pid, []), key=lambda x: (x.name or '')) out = [] for k in kids: d = k.to_dict() d['children'] = assemble(k.id) out.append(d) return out return assemble(parent_id) @warehouse_bp.route('/tree', methods=['GET']) # ★ 补鉴权:原先两个 GET 接口连 @jwt_required() 都没有,未登录即可拉走全量库位树。 # 公司隔离依赖 JWT claim,没有它 get_current_company_filter() 也取不到公司。 @jwt_required() def get_tree(): """ 获取库位树形结构 查询参数: prefixes —— 可选,逗号分隔的顶层前缀,例如 ?prefixes=Y 或 ?prefixes=C,L 只返回**顶层** name / full_path 命中这些前缀的根节点及其完整子树; 不传则返回全量。 公司隔离: 默认按 JWT 中的公司收敛到「本公司 + 全局共享」。超管/跨域用户可通过 ?company_name=IRIS 切换视角(由 get_current_company_filter 统一处理)。 ★ prefixes 现在只是**叠加上去的**前端精简手段,不再是隔离机制 —— 真正的隔离在 _scope(),改前端绕不过去。 为什么不能退回懒加载:setCheckedKeys / getCheckedNodes 依赖全树已构建。 """ try: raw_prefixes = request.args.get('prefixes', '', type=str) prefixes = [p.strip().upper() for p in raw_prefixes.split(',') if p.strip()] company_limit = get_current_company_filter() # 查询库位(本公司 + 全局),按 name 升序排序 all_locations = _scope( SysWarehouseLocation.query, company_limit ).order_by(SysWarehouseLocation.name.asc()).all() # 构建树形结构(O(N) 内存组装,见 build_tree) tree_data = build_tree(all_locations, parent_id=None) # ★ 只在**顶层**做前缀过滤:命中即整棵子树保留,不递归裁剪, # 避免把子树打散导致前端勾选语义错乱 if prefixes: def _hit(node): name = str(node.get('name') or '').upper() path = str(node.get('full_path') or '').upper() return any(name.startswith(p) or path.startswith(p) for p in prefixes) tree_data = [n for n in tree_data if _hit(n)] return jsonify({ 'code': 200, 'msg': 'success', 'data': tree_data }) except Exception as e: return jsonify({ 'code': 500, 'msg': str(e), 'data': None }), 500 @warehouse_bp.route('/children', methods=['GET']) @jwt_required() # ★ 补鉴权,同 /tree def get_children(): """ 懒加载:获取指定库位的直接子节点(parent_id 省略/为空 = 顶层)。 每个节点附带 has_children 标记,前端据此渲染「进入下级」而非点进去才知道。 与 /tree 行为一致(不额外过滤 is_enabled、按 name 升序、本公司+全局)。 """ try: parent_id = request.args.get('parent_id', type=int) company_limit = get_current_company_filter() query = _scope(SysWarehouseLocation.query, company_limit) if parent_id is None: nodes = query.filter( SysWarehouseLocation.parent_id.is_(None) ).order_by(SysWarehouseLocation.name.asc()).all() else: nodes = query.filter( SysWarehouseLocation.parent_id == parent_id ).order_by(SysWarehouseLocation.name.asc()).all() # 一次查询所有"有子节点"的 parent_id,用于 has_children 判断(避免 N+1) # ★ 必须与上面同一个范围:否则别家公司(或全局)的下级会把父节点标成 # 有下级,前端显示可下钻、点进去却是空列表。 parent_with_children = set( cid for (cid,) in _scope( db.session.query(SysWarehouseLocation.parent_id), company_limit ).filter(SysWarehouseLocation.parent_id.isnot(None)).distinct().all() ) data = [] for node in nodes: d = node.to_dict() d['has_children'] = node.id in parent_with_children data.append(d) return jsonify({'code': 200, 'msg': 'success', 'data': data}) except Exception as e: return jsonify({'code': 500, 'msg': str(e), 'data': None}), 500 @warehouse_bp.route('/companies', methods=['GET']) @jwt_required() def list_companies(): """ 公司清单,供库位管理页的「公司归属」下拉使用。 口径与 /api/v1/inbound/base/options 的 companies 一致(material_base 去重)。 单独开接口而不复用 options —— 那个挂了 material_list 权限, 能进库位管理页的人未必有物料列表权限。 """ try: return jsonify({'code': 200, 'msg': 'success', 'data': sorted(_valid_companies())}) except Exception as e: return jsonify({'code': 500, 'msg': str(e), 'data': None}), 500 @warehouse_bp.route('', methods=['POST']) @jwt_required() def create_location(): """ 创建库位 归属规则: 顶级节点 —— 超管可用 company_name 入参显式指定,未指定则全局共享(NULL); 公司用户强制落到本公司(否则他能凭空造出全司可见的库位)。 子节点 —— 继承父节点归属;父节点是全局时落到创建人本公司(超管保持全局), 避免公司用户借「全局父节点」造出跨司可见的节点。 """ try: data = request.get_json() or {} name = (data.get('name') or '').strip() parent_id = data.get('parent_id') # None 表示顶级 is_enabled = data.get('is_enabled', True) if not name: return jsonify({'code': 400, 'msg': '库位名称不能为空', 'data': None}) company_limit = get_current_company_filter() # 无公司归属且无跨域权限的账号(哨兵)只能建全局库位 own_company = None if company_limit == '__NO_COMPANY__' else company_limit # 计算 level、full_path 与归属 if parent_id is None: level = 0 full_path = name if company_limit is None: # 超管 / 跨域:显式指定,未指定 = 全局共享 try: assigned_company = _normalize_company(data.get('company_name')) except _InvalidCompany as e: return _bad_company(e) else: assigned_company = own_company else: parent = SysWarehouseLocation.query.get(parent_id) if not parent: return jsonify({'code': 400, 'msg': '父级库位不存在', 'data': None}) if not _visible_to(parent, company_limit): return _forbidden('无权在其他公司的库位下创建子库位') level = parent.level + 1 parent_full_path = parent.full_path or '' full_path = f"{parent_full_path}/{name}" if parent_full_path else name # 统一继承父节点归属;父节点是全局时落到创建人本公司 # (超管则保持全局)—— 保证一棵树不被切成两家的同时, # 也不让公司用户借全局父节点造出跨司可见的节点。 assigned_company = parent.company_name if assigned_company is None: assigned_company = own_company location = SysWarehouseLocation( name=name, parent_id=parent_id, full_path=full_path, level=level, is_enabled=is_enabled, company_name=assigned_company ) db.session.add(location) db.session.commit() return jsonify({ 'code': 200, 'msg': '创建成功', 'data': location.to_dict() }) except Exception as e: db.session.rollback() return jsonify({ 'code': 500, 'msg': str(e), 'data': None }), 500 @warehouse_bp.route('/', methods=['PUT']) @jwt_required() def update_location(location_id): """ 更新库位(改名 / 启停) ★ 改名会级联重写所有子孙的 full_path(见下方注释)。 ★ 不能改归属、不能换父节点 —— 跨公司搬迁请另开接口,此处一律拒绝。 """ try: data = request.get_json() or {} location = SysWarehouseLocation.query.get(location_id) if not location: return jsonify({'code': 404, 'msg': '库位不存在', 'data': None}) company_limit = get_current_company_filter() if not _owned_by_me(location, company_limit): return _forbidden('无权修改其他公司的库位') # 台账快照改写行数,供响应回显核对 snapshot_affected = {} # 归属变更影响的节点数 company_affected = 0 # 更新名称 if 'name' in data and data['name']: new_name = data['name'].strip() if new_name != location.name: old_full_path = location.full_path or '' # 需要更新 full_path parent = location.parent if parent: new_full_path = f"{parent.full_path}/{new_name}" if parent.full_path else new_name else: new_full_path = new_name location.full_path = new_full_path location.name = new_name # ★ 级联重写子孙节点的 full_path。 # 原实现只改自己一行,子孙仍挂着旧路径前缀 —— 而三张库存表 # 与出库/借库/调整单的快照列存的正是 full_path, # 改一次名就让整棵子树与台账彻底对不上。 # (下面的 autoflush 会先把本行的新 full_path 刷下去, # 故本行不会再被自己这条 UPDATE 命中。) if old_full_path: old_prefix = f"{old_full_path}/" new_prefix = f"{new_full_path}/" db.session.query(SysWarehouseLocation).filter( # autoescape:库位名可能含 _ / %,不转义会被当成 LIKE 通配符 SysWarehouseLocation.full_path.startswith(old_prefix, autoescape=True) ).update( # ★ 用 substr 拼接而非 replace(): # replace 会替换路径中**任意位置**出现的旧前缀, # 例如 'L1/01/L1/01' 会被一并改坏。 {SysWarehouseLocation.full_path: new_prefix + func.substr( SysWarehouseLocation.full_path, len(old_prefix) + 1 )}, synchronize_session=False, ) # ★ 在库台账的 full_path 快照一并改写。 # 不 commit —— 交给本函数末尾统一提交,与上面的字典级联 # 同属一个事务,要么全成要么全滚。 snapshot_affected = _sync_snapshot_locations(old_full_path, new_full_path) # 更新归属(仅跨域用户)。公司用户即使传了 company_name 也被忽略 —— # 否则任何公司用户都能把自己的库位送给别家,或抢走别家的库位。 if company_limit is None and 'company_name' in data: try: new_company = _normalize_company(data.get('company_name')) except _InvalidCompany as e: return _bad_company(e) if new_company != location.company_name: # ★ 级联整棵子树。归属撕裂的后果很具体: # 父节点可见、子节点不可见 —— /children 只会返回父节点, # 前端显示可下钻、点进去却是空列表;「子节点继承父级归属」 # 这条规则也跟着失效。 # 注意此处 location.full_path 已是改名后的新路径(若本次同时改了名), # 子孙也已在上面一并重写,故用新路径当前缀是自洽的。 subtree_prefix = f"{location.full_path}/" if location.full_path else '' if subtree_prefix: r = db.session.query(SysWarehouseLocation).filter( SysWarehouseLocation.full_path.startswith(subtree_prefix, autoescape=True) ).update( {SysWarehouseLocation.company_name: new_company}, synchronize_session=False, ) company_affected = r or 0 location.company_name = new_company company_affected += 1 # 更新启用状态 if 'is_enabled' in data: location.is_enabled = data['is_enabled'] db.session.commit() return jsonify({ 'code': 200, 'msg': '更新成功', 'data': location.to_dict(), # 本次跟着改写的台账快照行数,如 {"stock_buy": 12};无改写则为 {} 'snapshot_affected': snapshot_affected, # 本次归属变更影响到的节点数(含自身与全部子孙);未改归属则为 0 'company_affected': company_affected, }) except Exception as e: db.session.rollback() return jsonify({ 'code': 500, 'msg': str(e), 'data': None }), 500 @warehouse_bp.route('/', methods=['DELETE']) @jwt_required() def delete_location(location_id): """ 删除库位(级联删除子库位) ★ 归属校验不可省:否则 LICA 一条请求就能删掉 IRIS 的整棵库位树。 读接口的隔离挡不住写接口 —— 这里漏一处,前面的隔离全部作废。 """ try: location = SysWarehouseLocation.query.get(location_id) if not location: return jsonify({'code': 404, 'msg': '库位不存在', 'data': None}) company_limit = get_current_company_filter() if not _owned_by_me(location, company_limit): return _forbidden('无权删除其他公司的库位') # 在删除前提取属性,避免 commit 后访问已删除对象 deleted_loc_name = location.name # 递归删除所有子库位 def delete_recursive(loc): # 先删除所有子节点 children = SysWarehouseLocation.query.filter_by(parent_id=loc.id).all() for child in children: delete_recursive(child) # 再删除自身 db.session.delete(loc) delete_recursive(location) db.session.commit() return jsonify({ 'code': 200, 'msg': '删除成功', 'deleted_location': deleted_loc_name }) except Exception as e: db.session.rollback() return jsonify({ 'code': 500, 'msg': str(e), 'data': None }), 500 @warehouse_bp.route('/batch', methods=['DELETE']) @jwt_required() def batch_delete_locations(): """ 批量删除库位 ★ 先整体校验归属再动手:只要批里混进一个别家节点就整批拒绝, 不做「删一半留一半」——静默的部分成功比直接报错更难排查。 """ try: ids = request.get_json() if not ids or not isinstance(ids, list): return jsonify({'code': 400, 'msg': '请提供要删除的库位ID列表', 'data': None}) company_limit = get_current_company_filter() if company_limit is not None: forbidden_ids = [ loc_id for loc_id in ids if (lambda loc: loc is not None and not _owned_by_me(loc, company_limit))( SysWarehouseLocation.query.get(loc_id) ) ] if forbidden_ids: return _forbidden(f'无权删除其他公司的库位: {forbidden_ids}') deleted_count = 0 deleted_names = [] for loc_id in ids: location = SysWarehouseLocation.query.get(loc_id) if not location: continue # 在删除前提取属性 deleted_names.append(location.name) # 递归删除 def delete_recursive(loc): children = SysWarehouseLocation.query.filter_by(parent_id=loc.id).all() for child in children: delete_recursive(child) db.session.delete(loc) delete_recursive(location) deleted_count += 1 db.session.commit() return jsonify({ 'code': 200, 'msg': f'删除成功,共删除 {deleted_count} 个库位', 'data': {'deleted_count': deleted_count, 'deleted_names': deleted_names} }) except Exception as e: db.session.rollback() return jsonify({ 'code': 500, 'msg': str(e), 'data': None }), 500 @warehouse_bp.route('/batch-generate', methods=['POST']) @jwt_required() def batch_generate_locations(): """ 规则化批量新增库位 归属规则与 create_location 一致:子节点继承父节点,父节点是全局则落到 创建人本公司;顶级节点由超管显式指定,未指定即全局共享。 起点(三选一): parent_ids —— 前端在树上**勾选**出来的一批库位,每个各按规则生成一遍。 范围完全由勾选决定:可以只挑某棵子树里的几个末级, 也可以跨几棵树混选。三者中优先级最高。 leaf_only —— 起点换成 parent_id **子树下的全部末级库位**, 是「勾选」的自动版:不用手动挑,凡末级都算。 parent_id —— 只在 parent_id 这一个节点下按规则逐层展开(默认)。 """ MAX_TOTAL = 3000 # 单次最多生成数量限制 try: data = request.get_json() or {} parent_id = data.get('parent_id') rules = data.get('rules', []) # leaf_only:起点不是 parent 本身,而是它子树下的**全部末级库位**, # 每个末级各按规则生成一遍。用来「给整棵树的底层统一再加一层」。 leaf_only = bool(data.get('leaf_only')) company_limit = get_current_company_filter() own_company = None if company_limit == '__NO_COMPANY__' else company_limit if not rules: return jsonify({'code': 400, 'msg': '请提供生成规则', 'data': None}) if leaf_only and not parent_id: return jsonify({'code': 400, 'msg': '「在该库位下所有末级生成」需要先选择一个父级库位', 'data': None}) # ★ 先定起点,再算数量上限 —— 上限依赖起点的个数。 if 'parent_ids' in data: # 手动指定起点:树上勾选任意多个库位,每个各按规则生成一遍。 # ★ 用 `'parent_ids' in data` 而不是真值判断 —— 前端传空数组代表 # 「一个都没勾」,绝不能顺着往下掉进无父级分支,那会生成一堆顶级库位。 raw_ids = data.get('parent_ids') or [] if not raw_ids: return jsonify({'code': 400, 'msg': '请先勾选要新增下级的库位', 'data': None}) ids, seen = [], set() for raw in raw_ids: try: pid = int(raw) except (TypeError, ValueError): continue if pid not in seen: seen.add(pid) ids.append(pid) if not ids: return jsonify({'code': 400, 'msg': '勾选的库位 id 无效', 'data': None}) nodes = {n.id: n for n in SysWarehouseLocation.query.filter( SysWarehouseLocation.id.in_(ids)).all()} missing = [i for i in ids if i not in nodes] if missing: return jsonify({'code': 404, 'msg': f'库位不存在:{missing}', 'data': None}) forbidden_ids = [i for i in ids if not _visible_to(nodes[i], company_limit)] if forbidden_ids: return _forbidden(f'无权在其他公司的库位下生成子库位:{forbidden_ids}') # 保持前端给的顺序,生成顺序 = 审计 targets 的顺序,便于核对 current_parents = ids # 该模式没有单一父级,只在起点是全局库位时用它兜底(落到创建人本公司) root_company = own_company elif parent_id: parent = SysWarehouseLocation.query.get(parent_id) if not parent: return jsonify({'code': 404, 'msg': '父级库位不存在', 'data': None}) if not _visible_to(parent, company_limit): return _forbidden('无权在其他公司的库位下生成子库位') if leaf_only: seed_ids = _subtree_leaf_ids(parent) if not seed_ids: return jsonify({'code': 400, 'msg': '该库位下找不到末级库位', 'data': None}) current_parents = seed_ids else: current_parents = [parent_id] root_company = parent.company_name if root_company is None: root_company = own_company else: current_parents = [None] if company_limit is None: try: root_company = _normalize_company(data.get('company_name')) except _InvalidCompany as e: return _bad_company(e) else: root_company = own_company # 上限按**实际会创建**的节点数算,不是各层规模相乘(见 _count_generated) total_count = _count_generated(len(current_parents), rules) if total_count > MAX_TOTAL: # 只在多起点时补充拆解,单起点下「每个 N 个」等于总数,纯属噪声 detail = '' if len(current_parents) > 1: label = '个末级库位' if leaf_only else '个库位' detail = f'({len(current_parents)} {label} × 每个 {_count_generated(1, rules)} 个)' return jsonify({ 'code': 400, 'msg': f'单次生成数量不能超过 {MAX_TOTAL} 个,当前计划生成 {total_count} 个{detail}', 'data': None, }) # ★ 预取各起点下**已有的**子库位名,用来跳过同名。 # # 这不是优化,是必需语义:full_path 上有唯一约束 uniq_full_path, # 生成的 name 一旦与既有子库位重名,整个请求会 UniqueViolation 500 # 并**整体回滚** —— 一个都不会生成,用户看到的只是「生成失败」。 # (实测:目标下已有 1/2/3 时再生成 1~10,报 500,7 个新库位全部丢失。) # # 只查一次即可:第 2 层往下的父节点都是本次新建的,不可能有既有子库位。 taken = {p_id: set() for p_id in current_parents} pids = [p for p in current_parents if p is not None] if pids: for pid_, nm in db.session.query( SysWarehouseLocation.parent_id, SysWarehouseLocation.name ).filter(SysWarehouseLocation.parent_id.in_(pids)).all(): taken.setdefault(pid_, set()).add(nm) if None in taken: # 顶级是 parent_id IS NULL,in_() 抓不到,单独查 taken[None] = {nm for (nm,) in db.session.query(SysWarehouseLocation.name).filter( SysWarehouseLocation.parent_id.is_(None)).all()} skipped_names = [] # 逐层处理规则 # ★ seed_count 必须先取:下面每轮都会把 current_parents 换成新生成的节点, # 循环跑完它就只剩最底层的 id 了。 seed_count = len(current_parents) generated_ids = [] for rule in rules: prefix = rule.get('prefix', '') start = rule.get('start', 1) end = rule.get('end', 1) pad = rule.get('pad', 1) new_locations = [] # ★ 循环变量原为 parent_id,会把外层的入参 parent_id 遮蔽掉。 # 当前恰好没踩到(第一层之后入参不再被读),但这是埋在重构路径上的雷。 for p_id in current_parents: # 1. 动态获取当前特定父节点的信息(严禁放循环外面共享!) if p_id is None: current_level = 0 current_parent_path = '' assigned_company = root_company else: p = SysWarehouseLocation.query.get(p_id) current_level = (p.level + 1) if p else 0 current_parent_path = p.full_path if p and p.full_path else '' assigned_company = p.company_name if p else root_company if assigned_company is None: assigned_company = root_company # 2. 生成当前父节点下的专属子节点 for num in range(start, end + 1): name = f"{prefix}{str(num).zfill(pad)}" # 该父节点下已有同名子库位 → 跳过(补足语义)。 # 第 2 层往下的 p_id 都是本次新建的,不在 taken 里,天然不会命中。 if name in taken.get(p_id, ()): skipped_names.append(name) continue # 路径由当前特定的 current_parent_path 决定 full_path = f"{current_parent_path}/{name}" if current_parent_path else name location = SysWarehouseLocation( name=name, parent_id=p_id, full_path=full_path, level=current_level, is_enabled=True, company_name=assigned_company ) new_locations.append(location) # 单层循环结束后再 add_all / flush 和获取新 ID 列表 db.session.add_all(new_locations) db.session.flush() current_parents = [loc.id for loc in new_locations] generated_ids.extend(current_parents) db.session.commit() skipped_count = len(skipped_names) if skipped_count: msg = f'生成成功:新建 {len(generated_ids)} 个,跳过 {skipped_count} 个已存在的同名库位' else: msg = f'生成成功,共生成 {len(generated_ids)} 个库位' return jsonify({ 'code': 200, 'msg': msg, 'data': { 'generated_count': len(generated_ids), 'generated_ids': generated_ids, # 因同名已存在而跳过的节点数(补足语义,不是失败) 'skipped_count': skipped_count, 'skipped_names': sorted(set(skipped_names)), # 起点个数:leaf_only 时是末级库位数量,否则为 1 'seed_count': seed_count, } }) except Exception as e: db.session.rollback() return jsonify({ 'code': 500, 'msg': str(e), 'data': None }), 500