From 4890b996c4d5ac6ad6a38cf846a42eea543edca2 Mon Sep 17 00:00:00 2001 From: yueli Date: Tue, 29 Sep 2026 12:03:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(warehouse):=20=E5=BA=93=E4=BD=8D=E6=8C=89?= =?UTF-8?q?=E5=85=AC=E5=8F=B8=E9=9A=94=E7=A6=BB=EF=BC=8C=E5=B9=B6=E9=87=8D?= =?UTF-8?q?=E5=81=9A=E6=89=B9=E9=87=8F=E7=94=9F=E6=88=90=E4=B8=8E=E5=BA=93?= =?UTF-8?q?=E4=BD=8D=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 隔离(db_migrations/phase15_warehouse_location_company.sql) - sys_warehouse_location 新增 company_name,NULL = 全局共享 - 存量 3371 行按 full_path 顶段首字母回填:Y*→IRIS(500),L*/C*→LICA(2871) - 读范围 = 本公司 + 全局;写范围 = 仅本公司 删除/改名/批量生成都加归属校验 —— 只隔离读接口等于没隔离, 否则 LICA 一条 DELETE 就能删掉 IRIS 整棵库位树 - /tree 与 /children 补上缺失的 @jwt_required() - 公司名走白名单校验,非法值 400(拼错会造出谁都看不见的库位,且无从排查) - 超管可改归属,级联整棵子树(归属撕裂会让父子可见性错乱) 改名补偿 - 级联重写子孙 full_path(原实现只改自己一行,子孙仍挂旧前缀, 而库存快照存的正是 full_path,改一次名整棵子树就和台账对不上) - 同事务改写在库台账 stock_buy/semi/product/adjustment 的快照, 以及未结束盘点会话的 scope_config.locations - 刻意不含 trans_outbound / trans_borrow 等历史单据(改历史 = 篡改操作记录), 也不含 trans_repair.customer_location(那是客户地址不是库位) 批量生成 - 起点支持三种:勾选的一批库位 / 某子树下所有末级 / 单个父级 - 已存在同名跳过 —— full_path 上有唯一约束 uniq_full_path, 不跳过会整单 500 并整体回滚,连本该新建的也一并丢失 - 数量上限改按实际创建数(各层之和 × 起点数),不再是各层相乘 (旧口径低估,3000 的上限因此能被悄悄突破) - 前端加生成结构预览:逐层数量 + 完整路径示例,边填边看 前端清理 - 删除 utils/warehouseCompany.ts 前缀白名单,WarehouseSelector 与三个 入库页改为纯渲染后端数据(隔离已下沉到后端) - 盘点页 loadLocationTree 必须带 company_name:跨域角色在后端返回 None, 不带就会拉全量、盘点范围脱离所选公司 - 盘点页切公司时清库位树缓存(原来会继续沿用上一个公司的树) - 修复批量删除只取叶子导致中间层节点残留成孤儿 --- .../phase15_warehouse_location_company.sql | 148 ++++ inventory-backend/app/api/v1/warehouse.py | 641 ++++++++++++++-- inventory-backend/app/models/system.py | 7 + inventory-web/src/api/common/warehouse.ts | 35 +- .../src/components/WarehouseSelector.vue | 19 +- inventory-web/src/utils/warehouseCompany.ts | 22 - inventory-web/src/views/dashboard/index.vue | 722 ++++++++++++++++-- inventory-web/src/views/stock/inbound/buy.vue | 6 +- .../src/views/stock/inbound/product.vue | 6 +- .../src/views/stock/inbound/semi.vue | 6 +- .../src/views/stock/stocktake/index.vue | 20 +- 11 files changed, 1465 insertions(+), 167 deletions(-) create mode 100644 db_migrations/phase15_warehouse_location_company.sql delete mode 100644 inventory-web/src/utils/warehouseCompany.ts diff --git a/db_migrations/phase15_warehouse_location_company.sql b/db_migrations/phase15_warehouse_location_company.sql new file mode 100644 index 0000000..5c982e2 --- /dev/null +++ b/db_migrations/phase15_warehouse_location_company.sql @@ -0,0 +1,148 @@ +-- ============================================================================= +-- 库位归属公司化:sys_warehouse_location 增加 company_name +-- +-- 背景 +-- 库位字典表全表共享,IRIS 与 LICA 都能看到对方的库位。此前的「隔离」是 +-- 前端硬编码前缀白名单(inventory-web/src/utils/warehouseCompany.ts: +-- IRIS→['Y'],LICA→['C','L']),改前端即可绕过,且新增公司必须改代码。 +-- 更有甚者,/tree 与 /children 两个读接口连 @jwt_required() 都没有。 +-- +-- 本次把「归属」从命名约定升级为数据字段,隔离下沉到后端。 +-- +-- 口径(依据 2026-09-29 全表摸底,实测 3371 行 / 33 个顶级前缀) +-- 顶级前缀首字母唯一决定归属,无第三种取值、无跨司同名前缀: +-- Y1~Y8 → IRIS 500 行 +-- L1~L14 / C10,C11 / CC1~CC9 → LICA 2871 行 +-- 故按「full_path 顶段首字母」回填,而非裸 LIKE 'Y%': +-- split_part 取顶段是精确匹配,不受 LIKE 通配符(_ / %)影响 —— +-- 库位名由用户自由输入,含下划线时 LIKE 会误命中。 +-- +-- ★ L14(仅 2 个节点,同族其余均为 217)与 CC1~CC9(各 4 个节点)结构异常, +-- 疑似历史遗留/半截生成。按前缀归入 LICA,但**建议业务侧复核后**再定, +-- 若需改归属,单行 UPDATE 即可(见文件末「手工修正」段)。 +-- +-- 安全性 +-- 纯追加式 DDL,无 NOT NULL、无 DEFAULT,既有行取值 NULL。 +-- NULL 是**有意义**的取值 —— 表示全局共享(两司均可见),不是「未归类」。 +-- 本迁移把全部存量行都收敛到 IRIS/LICA,迁移后应为 0 行 NULL。 +-- +-- 幂等 +-- ADD COLUMN IF NOT EXISTS + CREATE TABLE IF NOT EXISTS + +-- UPDATE 带 company_name IS NULL 条件,可重复执行; +-- 重复执行不会覆盖已归类(含人工修正过)的值。 +-- +-- 执行(dev 库;生产为 *_prod 容器与 pgdata_prod,须由部署流程另行执行) +-- docker exec -i inventory_db psql -U test -d inventory_system \ +-- -v ON_ERROR_STOP=1 < 本文件 +-- +-- ★ 上线顺序:必须先跑本文件,再发布带 company_name 列的代码。 +-- 反过来会让 /api/v1/warehouse/* 全部 500(模型引用了不存在的列)。 +-- ============================================================================= + +BEGIN; + +-- --------------------------------------------------------------------------- +-- 0) 备份原表(3371 行,代价可忽略)。回滚时从这里捞。 +-- IF NOT EXISTS 保证重复执行时**不会**用迁移后的数据覆盖掉原始备份。 +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS sys_warehouse_location_bak_20260929 AS +SELECT * FROM sys_warehouse_location; + +-- --------------------------------------------------------------------------- +-- 1) 归属列 +-- --------------------------------------------------------------------------- +ALTER TABLE sys_warehouse_location + ADD COLUMN IF NOT EXISTS company_name varchar(50); + +COMMENT ON COLUMN sys_warehouse_location.company_name IS + '公司归属:IRIS/LICA;NULL = 全局共享(两司均可见)。由 warehouse.py 依据 JWT claim company_name 过滤'; + +CREATE INDEX IF NOT EXISTS ix_wh_loc_company + ON sys_warehouse_location (company_name); + +-- --------------------------------------------------------------------------- +-- 2) 存量回填:按 full_path 顶段首字母 +-- WHERE company_name IS NULL 保证幂等,且不覆盖已归类/人工修正过的值。 +-- full_path 为 NULL 的行 split_part 返回 NULL,不命中任何分支 → 保持 NULL +-- (即全局共享)。摸底确认当前无此类行。 +-- --------------------------------------------------------------------------- +UPDATE sys_warehouse_location + SET company_name = 'IRIS' + WHERE company_name IS NULL + AND upper(left(split_part(full_path, '/', 1), 1)) = 'Y'; + +UPDATE sys_warehouse_location + SET company_name = 'LICA' + WHERE company_name IS NULL + AND upper(left(split_part(full_path, '/', 1), 1)) IN ('L', 'C'); + +COMMIT; + + +-- ============================================================================= +-- 执行后核对 +-- ============================================================================= +SELECT '=== 1) 归属分布(期望 IRIS=500 / LICA=2871 / NULL=0)===' AS "核对项"; +SELECT COALESCE(company_name, '(NULL=全局)') AS 归属, + count(*) AS 节点数 + FROM sys_warehouse_location + GROUP BY company_name + ORDER BY 节点数 DESC; + +SELECT '=== 2) ★ 仍未归类的行(应为 0;非 0 需人工判定)===' AS "核对项"; +SELECT id, parent_id, name, full_path, level + FROM sys_warehouse_location + WHERE company_name IS NULL + ORDER BY id; + +SELECT '=== 3) 顶级节点归属明细(应 33 行,Y*→IRIS,其余→LICA)===' AS "核对项"; +SELECT COALESCE(company_name, '(NULL)') AS 归属, + split_part(full_path, '/', 1) AS 顶级库位, + count(*) AS 含子孙节点数 + FROM sys_warehouse_location + WHERE parent_id IS NULL + GROUP BY company_name, split_part(full_path, '/', 1) + ORDER BY 归属, 顶级库位; + +SELECT '=== 4) ★ 跨公司父子节点(应为 0;非 0 说明回填把某棵树切开了)===' AS "核对项"; +SELECT c.id AS 子节点id, c.full_path AS 子路径, c.company_name AS 子公司, + p.id AS 父节点id, p.full_path AS 父路径, p.company_name AS 父公司 + FROM sys_warehouse_location c + JOIN sys_warehouse_location p ON c.parent_id = p.id + WHERE c.company_name IS DISTINCT FROM p.company_name + ORDER BY c.id; + +SELECT '=== 5) 疑似重复的 (company_name, parent_id, name)(>=2 表示可考虑加唯一约束)===' AS "核对项"; +SELECT company_name, parent_id, name, count(*) AS 重复数 + FROM sys_warehouse_location + GROUP BY company_name, parent_id, name +HAVING count(*) >= 2 + ORDER BY 重复数 DESC, name; + + +-- ============================================================================= +-- 手工修正(按需单独执行,本迁移不自动跑) +-- ============================================================================= +-- 例:L14 结构异常,若业务确认它其实不属于 LICA: +-- UPDATE sys_warehouse_location SET company_name = 'IRIS' +-- WHERE full_path = 'L14' OR full_path LIKE 'L14/%'; +-- +-- 例:某个库位要改成全局共享: +-- UPDATE sys_warehouse_location SET company_name = NULL WHERE id = ?; + + +-- ============================================================================= +-- 回滚段(仅撤销本迁移) +-- ============================================================================= +-- BEGIN; +-- -- 方式一:从备份表整体还原(会丢弃迁移后新增的库位) +-- -- TRUNCATE sys_warehouse_location; +-- -- INSERT INTO sys_warehouse_location SELECT * FROM sys_warehouse_location_bak_20260929; +-- -- +-- -- 方式二:只去列(应用层须同步回滚,否则 /warehouse/* 报列不存在) +-- DROP INDEX IF EXISTS ix_wh_loc_company; +-- ALTER TABLE sys_warehouse_location DROP COLUMN IF EXISTS company_name; +-- COMMIT; +-- +-- 备份表确认无误后可另行清理: +-- DROP TABLE sys_warehouse_location_bak_20260929; diff --git a/inventory-backend/app/api/v1/warehouse.py b/inventory-backend/app/api/v1/warehouse.py index 8b24af6..5b1fbb0 100644 --- a/inventory-backend/app/api/v1/warehouse.py +++ b/inventory-backend/app/api/v1/warehouse.py @@ -1,11 +1,265 @@ # 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): """ @@ -31,6 +285,9 @@ def build_tree(nodes, parent_id=None): @warehouse_bp.route('/tree', methods=['GET']) +# ★ 补鉴权:原先两个 GET 接口连 @jwt_required() 都没有,未登录即可拉走全量库位树。 +# 公司隔离依赖 JWT claim,没有它 get_current_company_filter() 也取不到公司。 +@jwt_required() def get_tree(): """ 获取库位树形结构 @@ -40,16 +297,24 @@ def get_tree(): 只返回**顶层** name / full_path 命中这些前缀的根节点及其完整子树; 不传则返回全量。 - 用途:前端按公司精简拉取(IRIS 只要 Y*,LICA 只要 C*/L*), - 在**保留完整子树**的前提下减少节点数与传输量 —— 不能退回懒加载, - 因为 setCheckedKeys / getCheckedNodes 依赖全树已构建。 + 公司隔离: + 默认按 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()] - # 查询所有库位,按 name 升序排序 - all_locations = SysWarehouseLocation.query.order_by(SysWarehouseLocation.name.asc()).all() + 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) @@ -78,27 +343,34 @@ def get_tree(): @warehouse_bp.route('/children', methods=['GET']) +@jwt_required() # ★ 补鉴权,同 /tree def get_children(): """ 懒加载:获取指定库位的直接子节点(parent_id 省略/为空 = 顶层)。 每个节点附带 has_children 标记,前端据此渲染「进入下级」而非点进去才知道。 - 与 /tree 行为一致(不额外过滤 is_enabled、按 name 升序)。 + 与 /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 = SysWarehouseLocation.query.filter( + nodes = query.filter( SysWarehouseLocation.parent_id.is_(None) ).order_by(SysWarehouseLocation.name.asc()).all() else: - nodes = SysWarehouseLocation.query.filter( + 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 db.session.query(SysWarehouseLocation.parent_id) - .filter(SysWarehouseLocation.parent_id.isnot(None)).distinct().all() + cid for (cid,) in _scope( + db.session.query(SysWarehouseLocation.parent_id), company_limit + ).filter(SysWarehouseLocation.parent_id.isnot(None)).distinct().all() ) data = [] @@ -112,44 +384,86 @@ def get_children(): 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() - name = data.get('name', '').strip() + 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}) - - # 计算 level 和 full_path + + 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 - parent_full_path = '' + 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 + is_enabled=is_enabled, + company_name=assigned_company ) db.session.add(location) db.session.commit() - + return jsonify({ 'code': 200, 'msg': '创建成功', @@ -168,37 +482,112 @@ def create_location(): @jwt_required() def update_location(location_id): """ - 更新库位 + 更新库位(改名 / 启停) + + ★ 改名会级联重写所有子孙的 full_path(见下方注释)。 + ★ 不能改归属、不能换父节点 —— 跨公司搬迁请另开接口,此处一律拒绝。 """ try: - data = request.get_json() + 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: - location.full_path = f"{parent.full_path}/{new_name}" if parent.full_path else new_name + new_full_path = f"{parent.full_path}/{new_name}" if parent.full_path else new_name else: - location.full_path = new_name + 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() + 'data': location.to_dict(), + # 本次跟着改写的台账快照行数,如 {"stock_buy": 12};无改写则为 {} + 'snapshot_affected': snapshot_affected, + # 本次归属变更影响到的节点数(含自身与全部子孙);未改归属则为 0 + 'company_affected': company_affected, }) except Exception as e: db.session.rollback() @@ -214,6 +603,9 @@ def update_location(location_id): def delete_location(location_id): """ 删除库位(级联删除子库位) + + ★ 归属校验不可省:否则 LICA 一条请求就能删掉 IRIS 的整棵库位树。 + 读接口的隔离挡不住写接口 —— 这里漏一处,前面的隔离全部作废。 """ try: location = SysWarehouseLocation.query.get(location_id) @@ -221,6 +613,10 @@ def delete_location(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 @@ -255,12 +651,27 @@ def delete_location(location_id): 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 = [] @@ -303,36 +714,138 @@ def batch_delete_locations(): def batch_generate_locations(): """ 规则化批量新增库位 + + 归属规则与 create_location 一致:子节点继承父节点,父节点是全局则落到 + 创建人本公司;顶级节点由超管显式指定,未指定即全局共享。 + + 起点(三选一): + parent_ids —— 前端在树上**勾选**出来的一批库位,每个各按规则生成一遍。 + 范围完全由勾选决定:可以只挑某棵子树里的几个末级, + 也可以跨几棵树混选。三者中优先级最高。 + leaf_only —— 起点换成 parent_id **子树下的全部末级库位**, + 是「勾选」的自动版:不用手动挑,凡末级都算。 + parent_id —— 只在 parent_id 这一个节点下按规则逐层展开(默认)。 """ MAX_TOTAL = 3000 # 单次最多生成数量限制 try: - data = request.get_json() + 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}) - # 验证规则并计算总数 - total_count = 1 - for rule in rules: - start = rule.get('start', 1) - end = rule.get('end', 1) - total_count *= max(0, end - start + 1) + if leaf_only and not parent_id: + return jsonify({'code': 400, 'msg': '「在该库位下所有末级生成」需要先选择一个父级库位', 'data': None}) - if total_count > MAX_TOTAL: - return jsonify({'code': 400, 'msg': f'单次生成数量不能超过 {MAX_TOTAL} 个,当前计划生成 {total_count} 个', '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}) - # 初始化父级列表 - if parent_id: + 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}) - current_parents = [parent_id] + 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: @@ -343,43 +856,71 @@ def batch_generate_locations(): new_locations = [] - for parent_id in current_parents: + # ★ 循环变量原为 parent_id,会把外层的入参 parent_id 遮蔽掉。 + # 当前恰好没踩到(第一层之后入参不再被读),但这是埋在重构路径上的雷。 + for p_id in current_parents: # 1. 动态获取当前特定父节点的信息(严禁放循环外面共享!) - if parent_id is None: + if p_id is None: current_level = 0 current_parent_path = '' + assigned_company = root_company else: - p = SysWarehouseLocation.query.get(parent_id) + 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=parent_id, + parent_id=p_id, full_path=full_path, level=current_level, - is_enabled=True + is_enabled=True, + company_name=assigned_company ) - db.session.add(location) new_locations.append(location) - # 单层循环结束后再 flush 和获取新 ID 列表 + # 单层循环结束后再 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': f'生成成功,共生成 {len(generated_ids)} 个库位', - 'data': {'generated_count': len(generated_ids), 'generated_ids': generated_ids} + '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() diff --git a/inventory-backend/app/models/system.py b/inventory-backend/app/models/system.py index 6ef3985..594de96 100644 --- a/inventory-backend/app/models/system.py +++ b/inventory-backend/app/models/system.py @@ -172,6 +172,12 @@ class SysWarehouseLocation(db.Model): full_path = db.Column(db.String(500)) # 完整路径,如 "A区/货架1/第3层" level = db.Column(db.Integer, default=0) # 层级深度,顶级为0 is_enabled = db.Column(db.Boolean, default=True) + # [新增] 公司归属:NULL = 全局共享(两司均可见)。 + # ★ 这是「归属」不是「任意公司名」,取值域只有 IRIS / LICA / NULL 三态。 + # 切勿写入 'System' —— 超管的 JWT company_name 正是 'System',一旦落库, + # 这批库位对**所有**公司都不可见且无从排查。写入前统一过 + # warehouse.py 的 _normalize_company()。 + company_name = db.Column(db.String(50), index=True, comment='公司归属: IRIS/LICA,NULL=全局共享') created_at = db.Column(db.DateTime, default=beijing_time) # 注意:数据库表中没有 updated_at 字段,不要添加! @@ -190,5 +196,6 @@ class SysWarehouseLocation(db.Model): 'full_path': self.full_path, 'level': self.level, 'is_enabled': self.is_enabled, + 'company_name': self.company_name, 'created_at': self.created_at.isoformat() if self.created_at else None } \ No newline at end of file diff --git a/inventory-web/src/api/common/warehouse.ts b/inventory-web/src/api/common/warehouse.ts index 2c6f0b3..7cdc37a 100644 --- a/inventory-web/src/api/common/warehouse.ts +++ b/inventory-web/src/api/common/warehouse.ts @@ -1,13 +1,16 @@ import request from '@/utils/request' // 获取库位树形结构 -// prefixes 可选:只返回顶层命中这些前缀的根节点及其完整子树(后端过滤), -// 用于按公司精简拉取(IRIS 传 ['Y'],LICA 传 ['C','L']);不传则全量 -export function getWarehouseTree(prefixes?: string[]) { +// ★ 公司隔离已由后端按 JWT claim 完成,前端不再维护前缀白名单。 +// 跨域角色(超管 / crossDomain)需显式传 params.company_name 指定要看的公司 —— +// 后端 get_current_company_filter() 对它们返回 None,不传就等于拉全量; +// 普通用户传了也会被后端强制收敛回本公司,绕不过去。 +// prefixes 仍是后端支持的可选精简参数(只返回顶层命中前缀的子树),当前无调用方使用。 +export function getWarehouseTree(params?: Record) { return request({ url: '/v1/warehouse/tree', method: 'get', - params: prefixes && prefixes.length ? { prefixes: prefixes.join(',') } : undefined + params }) } @@ -56,10 +59,32 @@ export function batchDeleteWarehouse(ids: number[]) { } // 规则化批量生成库位 -export function batchGenerateWarehouse(data: { parent_id: number | null, rules: any[] }) { +// 起点三选一,优先级 parent_ids > leaf_only > parent_id: +// parent_ids —— 树上勾选出来的一批库位,每个各按规则生成一遍 +// leaf_only —— parent_id 子树下的所有末级库位,每个各生成一遍 +// parent_id —— 只在这一个节点下展开(默认) +// company_name 仅「顶级库位 + 跨域角色」时传;子级一律由后端继承父级归属 +export function batchGenerateWarehouse( + data: { + parent_id?: number | null + parent_ids?: number[] + rules: any[] + company_name?: string + leaf_only?: boolean + } +) { return request({ url: '/v1/warehouse/batch-generate', method: 'post', data }) } + +// 公司清单(口径同 /v1/inbound/base/options 的 companies:material_base 去重) +// 单独开接口而不复用 options —— 那个挂了 material_list 权限,管库位的人未必有 +export function getWarehouseCompanies() { + return request({ + url: '/v1/warehouse/companies', + method: 'get' + }) +} diff --git a/inventory-web/src/components/WarehouseSelector.vue b/inventory-web/src/components/WarehouseSelector.vue index 52b2de2..4e37599 100644 --- a/inventory-web/src/components/WarehouseSelector.vue +++ b/inventory-web/src/components/WarehouseSelector.vue @@ -56,12 +56,12 @@
-
+
当前层级无库位数据
  • (), { - modelValue: '', - allowedTopPrefixes: () => [] + modelValue: '' }) const emit = defineEmits<{ @@ -160,17 +157,9 @@ const currentParentId = computed(() => { }) // 当前显示的列表(懒加载缓存) +// ★ 公司隔离已在后端按 JWT 完成,这里不再做任何前端过滤 —— 后端返回什么就显示什么 const currentList = computed(() => cache[currentParentKey.value] || []) -// 顶层按公司白名单前缀过滤后的显示列表(响应式:切换物料公司时实时变化) -const displayList = computed(() => { - const list = currentList.value - if (currentParentKey.value === 'root' && props.allowedTopPrefixes.length > 0) { - return list.filter((item) => props.allowedTopPrefixes.some((p) => item.name.startsWith(p))) - } - return list -}) - // 懒加载某层子节点(已缓存则跳过) const ensureLoaded = async (key: string, parentId: number | null) => { if (cache[key]) return diff --git a/inventory-web/src/utils/warehouseCompany.ts b/inventory-web/src/utils/warehouseCompany.ts deleted file mode 100644 index d0201bd..0000000 --- a/inventory-web/src/utils/warehouseCompany.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * 公司 → 允许选择的库位顶层前缀(白名单) - * - * - 匹配到公司:顶层库位只显示 name 以这些前缀开头的节点(其子级随顶层隐藏不可达) - * - 未配置的公司 / 空数组:显示全部库位 - * - * 如需调整规则(新增公司/改前缀),只需改这张表,无需改动组件。 - */ -export const COMPANY_WAREHOUSE_PREFIXES: Record = { - // IRIS 仅显示 Y 开头(Y1~Y8) - IRIS: ['Y'], - // LICA 隐藏 Y,显示 C / L 开头 - LICA: ['C', 'L'], -} - -/** - * 根据公司名取允许的库位顶层前缀(未配置返回空数组 = 不过滤) - */ -export function getAllowedLocPrefixes(companyName?: string | null): string[] { - if (!companyName) return [] - return COMPANY_WAREHOUSE_PREFIXES[companyName] || [] -} diff --git a/inventory-web/src/views/dashboard/index.vue b/inventory-web/src/views/dashboard/index.vue index aaadc1c..0d55213 100644 --- a/inventory-web/src/views/dashboard/index.vue +++ b/inventory-web/src/views/dashboard/index.vue @@ -98,41 +98,61 @@
    - -