feat(warehouse): 库位按公司隔离,并重做批量生成与库位管理
隔离(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, 不带就会拉全量、盘点范围脱离所选公司 - 盘点页切公司时清库位树缓存(原来会继续沿用上一个公司的树) - 修复批量删除只取叶子导致中间层节点残留成孤儿
This commit is contained in:
148
db_migrations/phase15_warehouse_location_company.sql
Normal file
148
db_migrations/phase15_warehouse_location_company.sql
Normal file
@ -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;
|
||||
@ -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,40 +384,82 @@ 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()
|
||||
@ -168,27 +482,98 @@ 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']
|
||||
@ -198,7 +583,11 @@ def update_location(location_id):
|
||||
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})
|
||||
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()
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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<string, any>) {
|
||||
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'
|
||||
})
|
||||
}
|
||||
|
||||
@ -56,12 +56,12 @@
|
||||
|
||||
<!-- 列表展示区 -->
|
||||
<div class="selector-body">
|
||||
<div v-if="displayList.length === 0" class="empty-tip">
|
||||
<div v-if="currentList.length === 0" class="empty-tip">
|
||||
当前层级无库位数据
|
||||
</div>
|
||||
<ul v-else class="location-list">
|
||||
<li
|
||||
v-for="item in displayList"
|
||||
v-for="item in currentList"
|
||||
:key="item.id"
|
||||
class="location-item"
|
||||
:class="{ 'is-selected': item.full_path === modelValue }"
|
||||
@ -119,13 +119,10 @@ interface WarehouseItem {
|
||||
|
||||
interface Props {
|
||||
modelValue?: string
|
||||
// 顶层库位白名单前缀:仅显示 name 以这些前缀开头的顶层节点(空数组 = 全部)
|
||||
allowedTopPrefixes?: string[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: '',
|
||||
allowedTopPrefixes: () => []
|
||||
modelValue: ''
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@ -160,17 +157,9 @@ const currentParentId = computed<number | null>(() => {
|
||||
})
|
||||
|
||||
// 当前显示的列表(懒加载缓存)
|
||||
// ★ 公司隔离已在后端按 JWT 完成,这里不再做任何前端过滤 —— 后端返回什么就显示什么
|
||||
const currentList = computed<WarehouseItem[]>(() => cache[currentParentKey.value] || [])
|
||||
|
||||
// 顶层按公司白名单前缀过滤后的显示列表(响应式:切换物料公司时实时变化)
|
||||
const displayList = computed<WarehouseItem[]>(() => {
|
||||
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
|
||||
|
||||
@ -1,22 +0,0 @@
|
||||
/**
|
||||
* 公司 → 允许选择的库位顶层前缀(白名单)
|
||||
*
|
||||
* - 匹配到公司:顶层库位只显示 name 以这些前缀开头的节点(其子级随顶层隐藏不可达)
|
||||
* - 未配置的公司 / 空数组:显示全部库位
|
||||
*
|
||||
* 如需调整规则(新增公司/改前缀),只需改这张表,无需改动组件。
|
||||
*/
|
||||
export const COMPANY_WAREHOUSE_PREFIXES: Record<string, string[]> = {
|
||||
// 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] || []
|
||||
}
|
||||
@ -98,41 +98,61 @@
|
||||
<el-dialog v-model="warehouseDialogVisible" title="库位管理" width="700px" destroy-on-close :close-on-click-modal="false">
|
||||
<div class="warehouse-dialog">
|
||||
<div class="warehouse-header">
|
||||
<!-- 非批量模式 -->
|
||||
<template v-if="!isBulkDeleteMode">
|
||||
<!-- 普通模式 -->
|
||||
<template v-if="!selectMode">
|
||||
<el-button type="primary" @click="handleAddTopLevel" :icon="Plus">
|
||||
新增顶级区域
|
||||
</el-button>
|
||||
<el-button type="success" @click="openBatchGenerate" :icon="Plus">
|
||||
批量生成
|
||||
</el-button>
|
||||
<el-button type="danger" @click="isBulkDeleteMode = true" :icon="Delete">
|
||||
批量删除
|
||||
<el-button type="warning" plain @click="enterSelectMode" :icon="Checked">
|
||||
批量选择
|
||||
</el-button>
|
||||
</template>
|
||||
<!-- 批量模式 -->
|
||||
<!-- 选择模式:勾中的节点既能批量删除,也能批量新增下级 -->
|
||||
<template v-else>
|
||||
<el-button @click="cancelBatchMode" :icon="Close">
|
||||
<el-button @click="cancelSelectMode" :icon="Close">
|
||||
取消
|
||||
</el-button>
|
||||
<el-button type="danger" @click="handleBatchDelete" :disabled="selectedIds.length === 0" :icon="Delete">
|
||||
确认删除 {{ selectedIds.length > 0 ? `(${selectedIds.length}项)` : '' }}
|
||||
<span class="select-hint">已选 {{ checkedIds.length }} 个</span>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="openBatchAddForSelection"
|
||||
:disabled="checkedIds.length === 0"
|
||||
:icon="Plus"
|
||||
>
|
||||
批量新增下级
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
@click="handleBatchDelete"
|
||||
:disabled="selectedIds.length === 0"
|
||||
:icon="Delete"
|
||||
>
|
||||
批量删除{{ selectedIds.length > 0 ? `(连同子级共 ${batchDeleteTotal} 个)` : '' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</div>
|
||||
<el-scrollbar height="450px">
|
||||
<el-tree
|
||||
ref="treeRef"
|
||||
:data="treeData"
|
||||
:data="groupedTree"
|
||||
node-key="id"
|
||||
:props="treeProps"
|
||||
:expand-on-click-node="false"
|
||||
:default-expand-all="false"
|
||||
:show-checkbox="isBulkDeleteMode"
|
||||
:show-checkbox="selectMode"
|
||||
:check-strictly="selectMode"
|
||||
@check="handleTreeCheck"
|
||||
>
|
||||
<template #default="{ node, data }">
|
||||
<div class="tree-node">
|
||||
<!-- 公司分组头:只是个折叠容器,不能增删改(它不是库位) -->
|
||||
<div v-if="data.is_group" class="tree-group" @click="toggleGroup(data.id)">
|
||||
<span class="group-label">{{ data.name }}</span>
|
||||
<span class="group-count">{{ data.children?.length || 0 }} 个区域</span>
|
||||
</div>
|
||||
<div v-else class="tree-node">
|
||||
<span class="node-label">{{ node.label }}</span>
|
||||
<span class="node-actions">
|
||||
<el-button
|
||||
@ -179,6 +199,26 @@
|
||||
<el-form-item label="名称" required>
|
||||
<el-input v-model="locationForm.name" placeholder="请输入库位名称" />
|
||||
</el-form-item>
|
||||
<!-- 公司归属:
|
||||
· 顶级 + 跨域角色 → 可选(含「全局共享」)
|
||||
· 顶级 + 普通角色 → 只读,强制本公司(后端同样强制,绕不过去)
|
||||
· 子级 → 只读,永远继承上级(不允许一棵树跨两家) -->
|
||||
<el-form-item label="公司归属">
|
||||
<el-select
|
||||
v-if="!locationForm.parent_id && hasCrossDomain"
|
||||
v-model="locationForm.company_name"
|
||||
placeholder="请选择公司"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option v-for="c in companies" :key="c" :label="c" :value="c" />
|
||||
<el-option label="全局共享(两司均可见)" value="" />
|
||||
</el-select>
|
||||
<el-input
|
||||
v-else
|
||||
:value="locationForm.parent_id ? `继承上级:${companyLabel(locationForm.company_name)}` : companyLabel(locationForm.company_name)"
|
||||
disabled
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="启用状态">
|
||||
<el-switch v-model="locationForm.is_enabled" />
|
||||
</el-form-item>
|
||||
@ -192,10 +232,51 @@
|
||||
<!-- 批量生成库位弹窗 -->
|
||||
<el-dialog v-model="batchGenerateVisible" title="批量生成库位" width="800px" destroy-on-close>
|
||||
<el-form :model="batchGenerateForm" label-width="100px">
|
||||
<el-form-item label="父级库位">
|
||||
<el-input :value="batchGenerateForm.parentName" disabled />
|
||||
<el-button link size="small" @click="batchGenerateForm.parent_id = null; batchGenerateForm.parentName = '无(顶级)'">清空选择</el-button>
|
||||
<!-- 起点来自树上勾选:不需要选父级,也不涉及归属(各起点各继承自己的) -->
|
||||
<el-form-item v-if="batchGenerateForm.fromSelection" label="起点">
|
||||
<el-input :value="`已勾选的 ${batchGenerateForm.seedIds.length} 个库位`" disabled />
|
||||
</el-form-item>
|
||||
<template v-else>
|
||||
<el-form-item label="父级库位">
|
||||
<!-- 原来这里是只读输入框 + 一个恒为空的「清空选择」,导致批量生成
|
||||
只能产出顶级区域;换成真正的库位选择器后可以从任意节点起算 -->
|
||||
<WarehouseSelector
|
||||
:model-value="batchGenerateForm.parentPath"
|
||||
@update:model-value="onBatchParentChange"
|
||||
/>
|
||||
<el-button
|
||||
link
|
||||
size="small"
|
||||
:disabled="!batchGenerateForm.parent_id"
|
||||
@click="onBatchParentChange('')"
|
||||
>
|
||||
清空(改为生成顶级区域)
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
<!-- 起点范围:只在这个节点下,还是它子树里的每个末级各来一遍 -->
|
||||
<el-form-item v-if="batchGenerateForm.parent_id" label="生成位置">
|
||||
<el-radio-group v-model="batchGenerateForm.leaf_only" class="batch-scope">
|
||||
<el-radio :label="false">仅在「{{ batchGenerateForm.parentName }}」下生成</el-radio>
|
||||
<el-radio :label="true">
|
||||
在「{{ batchGenerateForm.parentName }}」下的所有末级库位下各生成一份
|
||||
<span class="scope-hint">(共 {{ batchLeafCount }} 个末级)</span>
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<!-- 归属:选中的父级决定归属;父级为「无(顶级)」时由跨域角色指定 -->
|
||||
<el-form-item label="公司归属">
|
||||
<el-select
|
||||
v-if="!batchGenerateForm.parent_id && hasCrossDomain"
|
||||
v-model="batchGenerateForm.company_name"
|
||||
placeholder="请选择公司"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option v-for="c in companies" :key="c" :label="c" :value="c" />
|
||||
<el-option label="全局共享(两司均可见)" value="" />
|
||||
</el-select>
|
||||
<el-input v-else :value="batchGenerateForm.parent_id ? '继承上级' : companyLabel(batchGenerateForm.company_name)" disabled />
|
||||
</el-form-item>
|
||||
</template>
|
||||
<el-divider>层级规则(按顺序生成)</el-divider>
|
||||
<div v-for="(rule, index) in batchGenerateForm.rules" :key="index" class="rule-item">
|
||||
<el-row :gutter="10">
|
||||
@ -219,8 +300,42 @@
|
||||
<el-button type="primary" plain @click="batchGenerateForm.rules.push({ prefix: '', start: 1, end: 10, pad: 1 })" :icon="Plus">
|
||||
添加层级
|
||||
</el-button>
|
||||
<div class="batch-preview">
|
||||
<span v-if="previewCount > 0">即将生成 <strong>{{ previewCount }}</strong> 个库位</span>
|
||||
<el-divider>生成结构预览</el-divider>
|
||||
<!-- 边填边看:几层、每层叫什么、多少个,不用在脑子里推演 -->
|
||||
<div v-if="previewLevels.length" class="batch-structure">
|
||||
<div class="struct-row struct-root">
|
||||
<span class="struct-branch">●</span>
|
||||
<span class="struct-root-label">{{ batchRootLabel }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="lv in previewLevels"
|
||||
:key="lv.level"
|
||||
class="struct-row"
|
||||
:style="{ paddingLeft: 16 + lv.level * 18 + 'px' }"
|
||||
>
|
||||
<span class="struct-branch">└</span>
|
||||
<span class="struct-level">
|
||||
第 {{ lv.level }} 层
|
||||
<span v-if="lv.level > 1" class="struct-hint">上一层每个之下</span>
|
||||
</span>
|
||||
<span class="struct-names">
|
||||
{{ lv.names.join(' / ') }}<template v-if="lv.truncated"> … 等 {{ lv.per }} 个</template>
|
||||
</span>
|
||||
<span class="struct-count">{{ lv.total }} 个</span>
|
||||
</div>
|
||||
<div class="struct-sample">
|
||||
生成后的库位长这样:<code>{{ batchSamplePath }}</code>
|
||||
</div>
|
||||
<div class="struct-total">
|
||||
共 <strong>{{ previewLevels.length }}</strong> 层 ·
|
||||
新建 <strong>{{ previewCount }}</strong> 个库位
|
||||
<span v-if="batchSkippedCount > 0" class="struct-skip">
|
||||
(另跳过 {{ batchSkippedCount }} 个已存在的同名库位)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="batch-preview">
|
||||
<span v-if="batchSkippedCount > 0">这些库位都已存在,无需生成</span>
|
||||
<span v-else>请完善规则</span>
|
||||
</div>
|
||||
</el-form>
|
||||
@ -240,12 +355,13 @@ import { ref, reactive, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
// 1. 引入 User Store
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import WarehouseSelector from '@/components/WarehouseSelector.vue'
|
||||
// 引入需要的图标
|
||||
import { Box, TrendCharts, ShoppingCart, Operation, Setting, Location, Plus, Edit, Delete, Close, Search } from '@element-plus/icons-vue'
|
||||
import { Box, TrendCharts, ShoppingCart, Operation, Setting, Location, Plus, Edit, Delete, Close, Search, Checked } from '@element-plus/icons-vue'
|
||||
import { getPrinterConfig, updatePrinterConfig } from '@/api/common/print'
|
||||
import request from '@/utils/request'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getWarehouseTree, createWarehouse, updateWarehouse, deleteWarehouse, batchDeleteWarehouse, batchGenerateWarehouse } from '@/api/common/warehouse'
|
||||
import { getWarehouseTree, createWarehouse, updateWarehouse, deleteWarehouse, batchDeleteWarehouse, batchGenerateWarehouse, getWarehouseCompanies } from '@/api/common/warehouse'
|
||||
|
||||
const router = useRouter()
|
||||
// 2. 实例化 store
|
||||
@ -392,6 +508,68 @@ const treeProps = {
|
||||
children: 'children'
|
||||
}
|
||||
|
||||
// 顶级库位按公司归属分组折叠。
|
||||
// 归属是「区域级」属性(子级强制继承父级,不会跨公司),所以按顶级分组最自然:
|
||||
// 挂在每个节点后面既挤又难扫,分组后一眼看出两家各有哪些区域,也不占横向空间。
|
||||
// ★ 分组节点是**虚拟节点**(id 形如 'group:IRIS'),不对应任何库位行,
|
||||
// 所以不提供新增下级/编辑/删除;批量删除取的是叶子(getCheckedKeys(true)),
|
||||
// 分组节点永远不是叶子,不会被误选。
|
||||
const groupedTree = computed(() => {
|
||||
const groups = new Map<string, { label: string; company: string | null; children: any[] }>()
|
||||
for (const root of treeData.value) {
|
||||
const company = root.company_name ?? null
|
||||
const key = company ?? '__GLOBAL__'
|
||||
if (!groups.has(key)) groups.set(key, { label: companyLabel(company), company, children: [] })
|
||||
groups.get(key)!.children.push(root)
|
||||
}
|
||||
// 固定顺序:具体公司按字典序,全局共享垫底
|
||||
return [...groups.entries()]
|
||||
.sort(([a], [b]) => (a === '__GLOBAL__' ? 1 : b === '__GLOBAL__' ? -1 : a.localeCompare(b)))
|
||||
.map(([key, g]) => ({
|
||||
id: `group:${key}`,
|
||||
name: g.label,
|
||||
is_group: true,
|
||||
company_name: g.company,
|
||||
children: g.children,
|
||||
}))
|
||||
})
|
||||
|
||||
// 整行点击也能展开/收起分组(el-tree 的 expand-on-click-node 关着,
|
||||
// 默认只有那个小三角能点,对分组头来说太难点中)
|
||||
const toggleGroup = (key: string) => {
|
||||
const node = treeRef.value?.getNode(key)
|
||||
if (node) node.expanded = !node.expanded
|
||||
}
|
||||
|
||||
// ---- 公司归属 ----
|
||||
// 只有跨域角色(超管,或持 crossDomain 权限,当前为 WAREHOUSE_MGR)能指定归属 ——
|
||||
// 与后端 get_current_company_filter() 的口径一致:对它们返回 None(不隔离)。
|
||||
// ★ SUPER_ADMIN 的权限是通配符 ['*'],permissions.includes('crossDomain')
|
||||
// 对它恒为 false,所以角色与权限码两个条件都要判。
|
||||
const hasCrossDomain = computed(() => {
|
||||
const role = (userStore.role || '').toUpperCase()
|
||||
if (role === 'SUPER_ADMIN') return true
|
||||
return userStore.hasPermission('crossDomain')
|
||||
})
|
||||
|
||||
const companies = ref<string[]>([])
|
||||
|
||||
const loadCompanies = async () => {
|
||||
if (!hasCrossDomain.value) return // 普通角色的归属字段是只读的,不需要公司清单
|
||||
try {
|
||||
const res: any = await getWarehouseCompanies()
|
||||
if (res.code === 200) companies.value = res.data || []
|
||||
} catch (e) {
|
||||
console.error('获取公司清单失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
// null / 空串 = 全局共享(两司均可见)
|
||||
const companyLabel = (v?: string | null) => v || '全局共享'
|
||||
|
||||
// 编辑前的原归属,用于判断本次是否真的改了归属
|
||||
const editingCompany = ref<string | null>(null)
|
||||
|
||||
// 库位表单
|
||||
const locationFormVisible = ref(false)
|
||||
const locationFormTitle = ref('')
|
||||
@ -400,29 +578,88 @@ const locationForm = reactive({
|
||||
parent_id: null as number | null,
|
||||
parentName: '无(顶级)',
|
||||
name: '',
|
||||
is_enabled: true
|
||||
is_enabled: true,
|
||||
// null = 尚未选择(仅顶级库位需要);'' = 全局共享;具体值 = 公司名
|
||||
company_name: null as string | null
|
||||
})
|
||||
const locationLoading = ref(false)
|
||||
|
||||
// 批量删除相关
|
||||
const selectedIds = ref<number[]>([])
|
||||
const isBulkDeleteMode = ref(false)
|
||||
// 批量选择模式:树上出勾选框,勾中的节点既能批量删除,也能批量新增下级。
|
||||
// ★ check-strictly 只在选择模式打开 —— 勾父节点不再自动连带勾中子节点。
|
||||
// 「勾哪些就作用到哪些」才是这个模式的语义:否则勾中一个 217 节点的区域
|
||||
// 去「批量新增下级」,会给区域内**每一个**节点都长出子节点。
|
||||
const selectMode = ref(false)
|
||||
const checkedIds = ref<number[]>([]) // 勾中的全部节点(批量新增用)
|
||||
const selectedIds = ref<number[]>([]) // 收敛成最小集(批量删除用,见 handleTreeCheck)
|
||||
|
||||
const handleTreeCheck = (data: any, checked: any) => {
|
||||
// 使用 getCheckedKeys(true) 只获取叶子节点,防止 el-tree 自动连带选中父节点导致误删
|
||||
if (treeRef.value) {
|
||||
selectedIds.value = treeRef.value.getCheckedKeys(true) as number[]
|
||||
}
|
||||
}
|
||||
|
||||
const cancelBatchMode = () => {
|
||||
isBulkDeleteMode.value = false
|
||||
const enterSelectMode = () => {
|
||||
selectMode.value = true
|
||||
checkedIds.value = []
|
||||
selectedIds.value = []
|
||||
if (treeRef.value) {
|
||||
treeRef.value.setCheckedKeys([])
|
||||
}
|
||||
treeRef.value?.setCheckedKeys([], false)
|
||||
}
|
||||
|
||||
const cancelSelectMode = () => {
|
||||
selectMode.value = false
|
||||
checkedIds.value = []
|
||||
selectedIds.value = []
|
||||
treeRef.value?.setCheckedKeys([], false)
|
||||
}
|
||||
|
||||
// 勾选 → 两个集合
|
||||
//
|
||||
// ★ 删除用「最小集」。这里不能用 getCheckedKeys(true)(只取叶子)—— 旧实现就是
|
||||
// 那么写的,理由是「防止 el-tree 连带选中父节点导致误删」,但反过来的坑更大:
|
||||
// 勾中一个区域时只删掉它的叶子,中间层节点全部残留成孤儿(后端删除是级联的,
|
||||
// 父删了子自然没,只传叶子等于专门把中间层挑出来留着)。
|
||||
// 也不能把 getCheckedKeys() 原样全塞给后端 —— 祖先和子孙同时在列,后端虽不炸,
|
||||
// 但会重复递归、deleted_count 失真。剔掉「祖先已在集合里」的冗余项即可。
|
||||
//
|
||||
// ★ 新增用「全集」:每个勾中的节点都要长出子级,父与子各自加一层是符合预期的,
|
||||
// 不做同样的收敛。
|
||||
const handleTreeCheck = () => {
|
||||
const tree = treeRef.value
|
||||
if (!tree) return
|
||||
|
||||
const isRealNode = (id: any) => {
|
||||
const n = tree.getNode(id)
|
||||
return !!(n && n.data && !n.data.is_group) // 分组是虚拟节点,不是库位
|
||||
}
|
||||
|
||||
const checked = ((tree.getCheckedKeys() as any[]) || []).filter(isRealNode)
|
||||
checkedIds.value = checked
|
||||
const set = new Set(checked)
|
||||
|
||||
selectedIds.value = checked.filter((id) => {
|
||||
let parent = tree.getNode(id)?.parent
|
||||
while (parent) {
|
||||
const pdata = parent.data
|
||||
if (!pdata || pdata.is_group) break // 走到分组就停,分组不参与
|
||||
if (set.has(pdata.id)) return false // 祖先已在集合里 → 由祖先级联删除
|
||||
parent = parent.parent
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// 实际会被删掉的节点数(含子孙)。只显示 selectedIds.length 会严重低报 ——
|
||||
// 勾一个区域可能实际删掉几百个节点,操作者得知道自己按下去会发生什么。
|
||||
const batchDeleteTotal = computed(() => {
|
||||
const tree = treeRef.value
|
||||
if (!tree || !selectedIds.value.length) return 0
|
||||
let n = 0
|
||||
const walk = (id: any) => {
|
||||
n += 1
|
||||
const node = tree.getNode(id)
|
||||
for (const child of node?.childNodes || []) {
|
||||
if (!child.data?.is_group) walk(child.data.id)
|
||||
}
|
||||
}
|
||||
selectedIds.value.forEach(walk)
|
||||
return n
|
||||
})
|
||||
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
if (selectedIds.value.length === 0) {
|
||||
ElMessage.warning('请先选择要删除的库位')
|
||||
@ -431,7 +668,8 @@ const handleBatchDelete = async () => {
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除选中的 ${selectedIds.value.length} 个库位吗?该操作将同时删除所有子库位。`,
|
||||
`确定要删除选中的 ${selectedIds.value.length} 个节点吗?` +
|
||||
`连同其下所有子库位,共 ${batchDeleteTotal.value} 个库位将被删除,且不可恢复。`,
|
||||
'警告',
|
||||
{ type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' }
|
||||
)
|
||||
@ -439,7 +677,7 @@ const handleBatchDelete = async () => {
|
||||
const res = await batchDeleteWarehouse(selectedIds.value)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('批量删除成功')
|
||||
cancelBatchMode()
|
||||
cancelSelectMode()
|
||||
await loadWarehouseTree()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '删除失败')
|
||||
@ -454,25 +692,216 @@ const batchGenerateVisible = ref(false)
|
||||
const batchGenerateForm = reactive({
|
||||
parent_id: null as number | null,
|
||||
parentName: '无(顶级)',
|
||||
// 选择器绑的是 full_path;parent_id 由它反查得出(见 onBatchParentChange)
|
||||
parentPath: '',
|
||||
// 起点范围:false = 只在 parent 下;true = 它子树下的所有末级各来一遍
|
||||
leaf_only: false,
|
||||
// 起点来自树上勾选(「批量选择」→「批量新增下级」),此时 parent_* 全部不参与
|
||||
fromSelection: false,
|
||||
// 打开弹窗那一刻勾选的 id 快照 —— 弹窗打开期间树被遮住没法再改,但快照更保险
|
||||
seedIds: [] as number[],
|
||||
// 同 locationForm.company_name:null = 尚未选择,'' = 全局共享
|
||||
company_name: null as string | null,
|
||||
rules: [{ prefix: '', start: 1, end: 10, pad: 1 }] as { prefix: string, start: number, end: number, pad: number }[]
|
||||
})
|
||||
const batchLoading = ref(false)
|
||||
|
||||
const previewCount = computed(() => {
|
||||
let count = 1
|
||||
for (const rule of batchGenerateForm.rules) {
|
||||
if (rule.start && rule.end && rule.start <= rule.end) {
|
||||
count *= (rule.end - rule.start + 1)
|
||||
} else {
|
||||
return 0
|
||||
// 按 full_path 在已加载的树里反查节点
|
||||
// (WarehouseSelector 只吐 full_path,后端要的是 id)
|
||||
const findNodeByPath = (nodes: any[], path: string): any => {
|
||||
for (const n of nodes) {
|
||||
if (n.full_path === path) return n
|
||||
if (n.children?.length) {
|
||||
const hit = findNodeByPath(n.children, path)
|
||||
if (hit) return hit
|
||||
}
|
||||
}
|
||||
return count
|
||||
return null
|
||||
}
|
||||
|
||||
const findNodeById = (nodes: any[], id: number): any => {
|
||||
for (const n of nodes) {
|
||||
if (n.id === id) return n
|
||||
if (n.children?.length) {
|
||||
const hit = findNodeById(n.children, id)
|
||||
if (hit) return hit
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const onBatchParentChange = (path: string) => {
|
||||
batchGenerateForm.parentPath = path || ''
|
||||
if (!path) {
|
||||
batchGenerateForm.parent_id = null
|
||||
batchGenerateForm.parentName = '无(顶级)'
|
||||
batchGenerateForm.leaf_only = false
|
||||
batchGenerateForm.company_name = hasCrossDomain.value ? null : (userStore.companyName || '')
|
||||
return
|
||||
}
|
||||
const node = findNodeByPath(treeData.value, path)
|
||||
if (!node) {
|
||||
ElMessage.warning('该库位不在当前库位树中,请重新选择')
|
||||
return
|
||||
}
|
||||
batchGenerateForm.parent_id = node.id
|
||||
batchGenerateForm.parentName = node.full_path || node.name
|
||||
}
|
||||
|
||||
// 末级 = 没有子节点(含父级自身),与后端 _subtree_leaf_ids 口径一致。
|
||||
// ★ 不按「最深一层」判断 —— 那只在规则化生成的树上跟「没有子节点」等价,
|
||||
// 手建的树深浅不一,按深度算会漏掉浅枝上的末级。
|
||||
const collectLeaves = (node: any): any[] =>
|
||||
!node?.children?.length ? [node] : node.children.flatMap((c: any) => collectLeaves(c))
|
||||
|
||||
const batchLeafCount = computed(() => {
|
||||
if (!batchGenerateForm.parent_id || !batchGenerateForm.parentPath) return 0
|
||||
const node = findNodeByPath(treeData.value, batchGenerateForm.parentPath)
|
||||
return node ? collectLeaves(node).length : 0
|
||||
})
|
||||
|
||||
const batchSeedCount = computed(() => {
|
||||
if (batchGenerateForm.fromSelection) return batchGenerateForm.seedIds.length
|
||||
if (!batchGenerateForm.parent_id) return 1 // 顶级生成:起点是「根」,算一个
|
||||
return batchGenerateForm.leaf_only ? batchLeafCount.value : 1
|
||||
})
|
||||
|
||||
// 每个起点下「已有的子库位名」集合 —— 用来判断哪些名字会被后端跳过。
|
||||
// 顶级生成时没有父节点,要和「所有顶级库位」比名字。
|
||||
const batchExistingNames = computed<Set<string>[]>(() => {
|
||||
const namesOf = (n: any) => new Set<string>((n.children || []).map((c: any) => c.name))
|
||||
if (batchGenerateForm.fromSelection) {
|
||||
return batchGenerateForm.seedIds
|
||||
.map((id) => findNodeById(treeData.value, id))
|
||||
.filter(Boolean)
|
||||
.map((n: any) => namesOf(n))
|
||||
}
|
||||
if (!batchGenerateForm.parent_id) {
|
||||
return [new Set<string>(treeData.value.map((n: any) => n.name))]
|
||||
}
|
||||
if (!batchGenerateForm.parentPath) return []
|
||||
const node = findNodeByPath(treeData.value, batchGenerateForm.parentPath)
|
||||
if (!node) return []
|
||||
return batchGenerateForm.leaf_only
|
||||
? collectLeaves(node).map((n: any) => namesOf(n))
|
||||
: [namesOf(node)]
|
||||
})
|
||||
|
||||
// 会被跳过(已存在同名)的个数
|
||||
const batchSkippedCount = computed(() => {
|
||||
const first = batchGenerateForm.rules[0]
|
||||
if (!first) return 0
|
||||
const s = Number(first.start)
|
||||
const e = Number(first.end)
|
||||
if (!(s > 0 && e >= s)) return 0
|
||||
const pad = Number(first.pad) || 1
|
||||
const wanted: string[] = []
|
||||
for (let n = s; n <= e; n++) wanted.push(`${first.prefix || ''}${String(n).padStart(pad, '0')}`)
|
||||
let hit = 0
|
||||
for (const have of batchExistingNames.value) {
|
||||
hit += wanted.filter((n) => have.has(n)).length
|
||||
}
|
||||
return hit
|
||||
})
|
||||
|
||||
// 逐层的生成结构,供「生成结构预览」用。
|
||||
// 每层的总量 = 上一层的总量 × 本层规则个数;第 1 层还要扣掉重名跳过的。
|
||||
// (第 2 层往下的父节点都是本次新建的,不可能撞名。)
|
||||
// ★ 预览数字唯一来源就是这里 —— 结构表上的每层数字加起来正好等于最终提交后
|
||||
// 后端会新建的个数,不会出现"预览 10、实际 7"这种对不上的情况。
|
||||
const previewLevels = computed(() => {
|
||||
const rules = batchGenerateForm.rules
|
||||
const seeds = batchSeedCount.value
|
||||
if (!seeds) return []
|
||||
const out: { level: number; per: number; total: number; names: string[]; truncated: boolean }[] = []
|
||||
let prevTotal = 0
|
||||
for (let i = 0; i < rules.length; i++) {
|
||||
const r = rules[i]
|
||||
const s = Number(r.start)
|
||||
const e = Number(r.end)
|
||||
if (!(s > 0 && e >= s)) return [] // 规则没填完 → 整块不显示
|
||||
const per = e - s + 1
|
||||
const total = i === 0 ? seeds * per - batchSkippedCount.value : prevTotal * per
|
||||
if (total <= 0) break // 上一层没建成 → 下面也不会有
|
||||
const pad = Number(r.pad) || 1
|
||||
const cap = Math.min(per, 6) // 名字只列前 6 个,其余折叠成「等 N 个」
|
||||
const names: string[] = []
|
||||
for (let k = s; k < s + cap; k++) names.push(`${r.prefix || ''}${String(k).padStart(pad, '0')}`)
|
||||
out.push({ level: i + 1, per, total, names, truncated: per > cap })
|
||||
prevTotal = total
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
// 实际会**新建**的节点数 = 各层之和。必须扣掉跳过的那些 ——
|
||||
// full_path 上有唯一约束 uniq_full_path,同名不跳过的话整个请求会 500 并整体回滚,
|
||||
// 连本该新建的也一并丢失(实测:已有 1/2/3 时生成 1~10,4~10 那 7 个全没落库)。
|
||||
const previewCount = computed(() => previewLevels.value.reduce((s, l) => s + l.total, 0))
|
||||
|
||||
// 起点本身的 full_path(顶级生成时为空 —— 没有父级可拼)
|
||||
const batchRootPath = computed(() => {
|
||||
if (batchGenerateForm.fromSelection) {
|
||||
const n = findNodeById(treeData.value, batchGenerateForm.seedIds[0])
|
||||
return n?.full_path || ''
|
||||
}
|
||||
if (!batchGenerateForm.parent_id || !batchGenerateForm.parentPath) return ''
|
||||
const node = findNodeByPath(treeData.value, batchGenerateForm.parentPath)
|
||||
if (!node) return ''
|
||||
if (batchGenerateForm.leaf_only) return collectLeaves(node)[0]?.full_path || ''
|
||||
return node.full_path || ''
|
||||
})
|
||||
|
||||
// 第一条完整路径示例 = 起点 full_path + 每层第一个名字。
|
||||
// 这就是最终会落进库存表 warehouse_location 的值(图里那种 L3/4/4/1),
|
||||
// 光看每层的 D1 / 1 / 1 还得自己在脑子里拼。
|
||||
// ★ 第 1 层要跳过已存在的同名 —— 否则示例会给出一个实际根本没建的路径。
|
||||
// 第 2 层往下的父节点都是本次新建的,不可能撞名。
|
||||
const batchSamplePath = computed(() => {
|
||||
const levels = previewLevels.value
|
||||
if (!levels.length) return ''
|
||||
const existing = batchExistingNames.value[0]
|
||||
const parts: string[] = []
|
||||
const root = batchRootPath.value
|
||||
if (root) parts.push(root)
|
||||
for (let i = 0; i < levels.length; i++) {
|
||||
const names = levels[i].names
|
||||
const pick = (i === 0 && existing)
|
||||
? (names.find((n) => !existing.has(n)) || names[0])
|
||||
: names[0]
|
||||
parts.push(pick)
|
||||
}
|
||||
return parts.join('/')
|
||||
})
|
||||
|
||||
// 结构预览的根节点文案
|
||||
const batchRootLabel = computed(() => {
|
||||
if (batchGenerateForm.fromSelection) return `勾选的 ${batchGenerateForm.seedIds.length} 个库位`
|
||||
if (!batchGenerateForm.parent_id) return '顶级(无父级)'
|
||||
if (batchGenerateForm.leaf_only) return `${batchGenerateForm.parentName} 下的 ${batchLeafCount.value} 个末级库位`
|
||||
return batchGenerateForm.parentName || '—'
|
||||
})
|
||||
|
||||
const openBatchGenerate = () => {
|
||||
batchGenerateForm.fromSelection = false
|
||||
batchGenerateForm.seedIds = []
|
||||
batchGenerateForm.parent_id = null
|
||||
batchGenerateForm.parentName = '无(顶级)'
|
||||
batchGenerateForm.parentPath = ''
|
||||
batchGenerateForm.leaf_only = false
|
||||
batchGenerateForm.company_name = hasCrossDomain.value ? null : (userStore.companyName || '')
|
||||
batchGenerateForm.rules = [{ prefix: '', start: 1, end: 10, pad: 1 }]
|
||||
batchGenerateVisible.value = true
|
||||
}
|
||||
|
||||
// 「批量选择」模式下点「批量新增下级」:把勾中的库位当起点
|
||||
const openBatchAddForSelection = () => {
|
||||
if (!checkedIds.value.length) return
|
||||
batchGenerateForm.fromSelection = true
|
||||
batchGenerateForm.seedIds = [...checkedIds.value]
|
||||
batchGenerateForm.parent_id = null
|
||||
batchGenerateForm.parentName = ''
|
||||
batchGenerateForm.parentPath = ''
|
||||
batchGenerateForm.leaf_only = false
|
||||
batchGenerateForm.rules = [{ prefix: '', start: 1, end: 10, pad: 1 }]
|
||||
batchGenerateVisible.value = true
|
||||
}
|
||||
@ -483,14 +912,40 @@ const handleBatchGenerate = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
const fromSelection = batchGenerateForm.fromSelection
|
||||
const isTopLevel = !fromSelection && !batchGenerateForm.parent_id
|
||||
if (isTopLevel && hasCrossDomain.value && batchGenerateForm.company_name === null) {
|
||||
ElMessage.warning('请选择公司归属')
|
||||
return
|
||||
}
|
||||
if (fromSelection && !batchGenerateForm.seedIds.length) {
|
||||
ElMessage.warning('没有勾选任何库位')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
batchLoading.value = true
|
||||
const res = await batchGenerateWarehouse({
|
||||
parent_id: batchGenerateForm.parent_id,
|
||||
rules: batchGenerateForm.rules
|
||||
})
|
||||
const payload: any = { rules: batchGenerateForm.rules }
|
||||
if (fromSelection) {
|
||||
// 勾选模式下 parent_id / leaf_only / company_name 全部不参与 ——
|
||||
// 归属由每个起点各自继承,后端按各自的父节点算
|
||||
payload.parent_ids = batchGenerateForm.seedIds
|
||||
} else {
|
||||
payload.parent_id = batchGenerateForm.parent_id
|
||||
if (isTopLevel && hasCrossDomain.value) payload.company_name = batchGenerateForm.company_name
|
||||
if (!isTopLevel && batchGenerateForm.leaf_only) payload.leaf_only = true
|
||||
}
|
||||
const res = await batchGenerateWarehouse(payload)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success(`生成成功,共生成 ${res.data.generated_count} 个库位`)
|
||||
const seed = res.data.seed_count || 1
|
||||
let scope = ''
|
||||
if (fromSelection) scope = `(起点:勾选的 ${seed} 个库位)`
|
||||
else if (batchGenerateForm.leaf_only) scope = `(起点:${seed} 个末级库位)`
|
||||
// 直接用后端的 msg —— 它会写明「新建 N 个、跳过 M 个已存在同名」,
|
||||
// 前端另拼一套容易和真实结果对不上
|
||||
ElMessage.success(`${res.msg}${scope}`)
|
||||
// 从勾选进来的,生成后清掉选择,免得同一批被重复点
|
||||
if (fromSelection) cancelSelectMode()
|
||||
batchGenerateVisible.value = false
|
||||
await loadWarehouseTree()
|
||||
} else {
|
||||
@ -505,7 +960,7 @@ const handleBatchGenerate = async () => {
|
||||
|
||||
// 打开库位管理弹窗
|
||||
const openWarehouseDialog = async () => {
|
||||
await loadWarehouseTree()
|
||||
await Promise.all([loadWarehouseTree(), loadCompanies()])
|
||||
warehouseDialogVisible.value = true
|
||||
}
|
||||
|
||||
@ -528,6 +983,9 @@ const handleAddTopLevel = () => {
|
||||
locationForm.parentName = '无(顶级)'
|
||||
locationForm.name = ''
|
||||
locationForm.is_enabled = true
|
||||
// 跨域角色必须显式选(null),不给默认值 —— 默认全局共享会让两司都凭空多出一个库位
|
||||
locationForm.company_name = hasCrossDomain.value ? null : (userStore.companyName || '')
|
||||
editingCompany.value = null
|
||||
locationFormTitle.value = '新增顶级库位'
|
||||
locationFormVisible.value = true
|
||||
}
|
||||
@ -539,6 +997,9 @@ const handleAddChild = (data: any) => {
|
||||
locationForm.parentName = data.full_path || data.name
|
||||
locationForm.name = ''
|
||||
locationForm.is_enabled = true
|
||||
// 子级归属由后端强制继承父级,这里只为把继承到的值显示出来
|
||||
locationForm.company_name = data.company_name ?? ''
|
||||
editingCompany.value = null
|
||||
locationFormTitle.value = `新增下级库位:${data.name}`
|
||||
locationFormVisible.value = true
|
||||
}
|
||||
@ -550,6 +1011,8 @@ const handleEdit = (data: any) => {
|
||||
locationForm.parentName = data.parent_id ? data.parent_full_path || '无(顶级)' : '无(顶级)'
|
||||
locationForm.name = data.name
|
||||
locationForm.is_enabled = data.is_enabled
|
||||
locationForm.company_name = data.company_name ?? ''
|
||||
editingCompany.value = data.company_name ?? ''
|
||||
locationFormTitle.value = `编辑库位:${data.name}`
|
||||
locationFormVisible.value = true
|
||||
}
|
||||
@ -582,6 +1045,31 @@ const saveLocation = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
const isTopLevel = !locationForm.parent_id
|
||||
|
||||
// 顶级库位由跨域角色指定归属,必须显式选择 ——
|
||||
// 否则 null 会静默变成「全局共享」(两司都能看见),事后很难发现。
|
||||
if (isTopLevel && hasCrossDomain.value && locationForm.company_name === null) {
|
||||
ElMessage.warning('请选择公司归属')
|
||||
return
|
||||
}
|
||||
|
||||
// 归属变更会级联整棵子树,先让操作者确认影响面
|
||||
const companyChanged = isTopLevel && hasCrossDomain.value && locationForm.id !== undefined
|
||||
&& (locationForm.company_name || '') !== (editingCompany.value || '')
|
||||
if (companyChanged) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`归属将由「${companyLabel(editingCompany.value)}」改为「${companyLabel(locationForm.company_name)}」,` +
|
||||
`该库位下的所有子库位会一并变更。确定继续?`,
|
||||
'确认变更归属',
|
||||
{ type: 'warning', confirmButtonText: '确定变更', cancelButtonText: '取消' }
|
||||
)
|
||||
} catch (e) {
|
||||
return // 用户取消
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
locationLoading.value = true
|
||||
const payload: any = {
|
||||
@ -591,6 +1079,9 @@ const saveLocation = async () => {
|
||||
|
||||
if (locationForm.parent_id) {
|
||||
payload.parent_id = locationForm.parent_id
|
||||
} else if (hasCrossDomain.value) {
|
||||
// '' = 全局共享(后端 _normalize_company 折算成 NULL)
|
||||
payload.company_name = locationForm.company_name
|
||||
}
|
||||
|
||||
let res
|
||||
@ -604,7 +1095,14 @@ const saveLocation = async () => {
|
||||
}
|
||||
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('保存成功')
|
||||
// 把连带影响如实回显 —— 改名会同步台账快照,改归属会级联子树,
|
||||
// 操作者不被告知的话,会以为只动了一个节点
|
||||
let msg = '保存成功'
|
||||
if (res.company_affected > 1) msg += `,已同步变更 ${res.company_affected} 个库位的归属`
|
||||
const snap = res.snapshot_affected || {}
|
||||
const snapTotal = Object.values(snap).reduce((a: number, b: any) => a + Number(b), 0)
|
||||
if (snapTotal > 0) msg += `,并同步了 ${snapTotal} 条台账快照`
|
||||
ElMessage.success(msg)
|
||||
locationFormVisible.value = false
|
||||
await loadWarehouseTree()
|
||||
} else {
|
||||
@ -716,6 +1214,23 @@ const handleNav = (path: string) => {
|
||||
.node-label {
|
||||
font-size: 14px;
|
||||
}
|
||||
/* 公司分组头 */
|
||||
.tree-group {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
.group-label {
|
||||
font-size: 14px;
|
||||
}
|
||||
.group-count {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: #909399;
|
||||
}
|
||||
.node-actions {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
@ -728,6 +1243,103 @@ const handleNav = (path: string) => {
|
||||
background: #f5f7fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
/* 生成位置单选:两个选项文案都长,竖排免得挤出弹窗 */
|
||||
.batch-scope {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
.batch-scope .el-radio {
|
||||
margin-right: 0;
|
||||
height: auto;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.scope-hint {
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
/* 选择模式下的「已选 N 个」 */
|
||||
.select-hint {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
/* 生成结构预览:逐层缩进,一眼看出几层、每层长什么样 */
|
||||
.batch-structure {
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 4px;
|
||||
padding: 10px 12px;
|
||||
background: #fafafa;
|
||||
font-size: 13px;
|
||||
}
|
||||
.struct-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
line-height: 22px;
|
||||
}
|
||||
.struct-root-label {
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
.struct-branch {
|
||||
color: #c0c4cc;
|
||||
flex: none;
|
||||
}
|
||||
.struct-level {
|
||||
flex: none;
|
||||
color: #606266;
|
||||
}
|
||||
.struct-hint {
|
||||
color: #c0c4cc;
|
||||
font-size: 12px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.struct-names {
|
||||
color: #409eff;
|
||||
font-family: monospace;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.struct-count {
|
||||
flex: none;
|
||||
color: #909399;
|
||||
}
|
||||
.struct-sample {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed #dcdfe6;
|
||||
color: #909399;
|
||||
text-align: center;
|
||||
}
|
||||
.struct-sample code {
|
||||
font-family: monospace;
|
||||
color: #409eff;
|
||||
background: #ecf5ff;
|
||||
padding: 1px 8px;
|
||||
border-radius: 3px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.struct-total {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed #dcdfe6;
|
||||
text-align: center;
|
||||
color: #606266;
|
||||
}
|
||||
.struct-total strong {
|
||||
color: #409eff;
|
||||
font-size: 15px;
|
||||
}
|
||||
.struct-skip {
|
||||
color: #e6a23c;
|
||||
}
|
||||
|
||||
.batch-preview {
|
||||
margin-top: 15px;
|
||||
padding: 10px;
|
||||
|
||||
@ -384,7 +384,7 @@
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item label="库位" prop="warehouse_location">
|
||||
<WarehouseSelector v-model="form.warehouse_location" :allowed-top-prefixes="allowedLocPrefixes" />
|
||||
<WarehouseSelector v-model="form.warehouse_location" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
@ -807,7 +807,6 @@ import {getLabelPreview, executePrint} from '@/api/common/print'
|
||||
import { usePasteUpload } from '@/hooks/usePasteUpload'
|
||||
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue'
|
||||
import WarehouseSelector from '@/components/WarehouseSelector.vue'
|
||||
import { getAllowedLocPrefixes } from '@/utils/warehouseCompany'
|
||||
import SmartScannerDialog from '@/components/SmartScannerDialog.vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
@ -1168,9 +1167,6 @@ const handleCheckAllChange = (val: boolean) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 按物料公司限制可选库位顶层前缀(IRIS→Y,LICA→C/L,未配置→全部)
|
||||
const allowedLocPrefixes = computed(() => getAllowedLocPrefixes(form.company_name))
|
||||
|
||||
const form = reactive({
|
||||
id: undefined, base_id: undefined as number | undefined,
|
||||
company_name: '',
|
||||
|
||||
@ -354,7 +354,7 @@
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="6"><el-form-item label="SKU" prop="sku"><el-input v-model="form.sku" placeholder="自动生成" disabled /></el-form-item></el-col>
|
||||
<el-col :span="6"><el-form-item label="库位" prop="warehouse_location">
|
||||
<WarehouseSelector v-model="form.warehouse_location" :allowed-top-prefixes="allowedLocPrefixes" />
|
||||
<WarehouseSelector v-model="form.warehouse_location" />
|
||||
</el-form-item></el-col>
|
||||
<el-col :span="6"><el-form-item label="入库日期"><el-date-picker v-model="form.in_date" type="date" value-format="YYYY-MM-DD" style="width:100%" disabled /></el-form-item></el-col>
|
||||
</el-row>
|
||||
@ -614,7 +614,6 @@ import {
|
||||
import { uploadFile, deleteFile } from '@/api/inbound/buy'
|
||||
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue'
|
||||
import WarehouseSelector from '@/components/WarehouseSelector.vue'
|
||||
import { getAllowedLocPrefixes } from '@/utils/warehouseCompany'
|
||||
import SmartScannerDialog from '@/components/SmartScannerDialog.vue'
|
||||
import TrackScanDialog from '@/components/TrackScanDialog.vue'
|
||||
import { getLabelPreview, executePrint } from '@/api/common/print'
|
||||
@ -930,9 +929,6 @@ const displayData = computed(() => {
|
||||
|
||||
const defaultVisibleCols = ['company_name', 'material_name', 'sku', 'warehouse_loc', 'serial_number', 'in_quantity', 'stock_quantity', 'available_quantity', 'status', 'quality_status', 'product_photo', 'sale_price', 'order_id']
|
||||
|
||||
// 按物料公司限制可选库位顶层前缀(IRIS→Y,LICA→C/L,未配置→全部)
|
||||
const allowedLocPrefixes = computed(() => getAllowedLocPrefixes(form.company_name))
|
||||
|
||||
const form = reactive({
|
||||
id: undefined, base_id: undefined as number | undefined,
|
||||
company_name: '', // [新增]
|
||||
|
||||
@ -393,7 +393,7 @@
|
||||
<el-col :span="6"><el-form-item label="编码/SKU" prop="sku"><el-input v-model="form.sku" placeholder="系统自动生成" disabled/></el-form-item></el-col>
|
||||
<el-col :span="6"><el-form-item label="入库日期" prop="in_date"><el-date-picker v-model="form.in_date" type="date" value-format="YYYY-MM-DD" style="width:100%" disabled/></el-form-item></el-col>
|
||||
<el-col :span="6"><el-form-item label="库位" prop="warehouse_location">
|
||||
<WarehouseSelector v-model="form.warehouse_location" :allowed-top-prefixes="allowedLocPrefixes" />
|
||||
<WarehouseSelector v-model="form.warehouse_location" />
|
||||
</el-form-item></el-col>
|
||||
</el-row>
|
||||
|
||||
@ -658,7 +658,6 @@ import {
|
||||
import { uploadFile, deleteFile } from '@/api/inbound/buy'
|
||||
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue'
|
||||
import WarehouseSelector from '@/components/WarehouseSelector.vue'
|
||||
import { getAllowedLocPrefixes } from '@/utils/warehouseCompany'
|
||||
import SmartScannerDialog from '@/components/SmartScannerDialog.vue'
|
||||
import TrackScanDialog from '@/components/TrackScanDialog.vue'
|
||||
import {getLabelPreview, executePrint} from '@/api/common/print'
|
||||
@ -974,9 +973,6 @@ const handleCheckAllChange = (val: boolean) => {
|
||||
|
||||
const defaultColumns = ['company_name', 'material_name', 'spec_model', 'unit', 'inbound_date', 'sn_bn', 'status', 'quality_status', 'bom_code', 'work_order_code', 'stock_quantity', 'available_quantity', 'unit_total_cost', 'arrival_photo', 'quality_report_link']
|
||||
|
||||
// 按物料公司限制可选库位顶层前缀(IRIS→Y,LICA→C/L,未配置→全部)
|
||||
const allowedLocPrefixes = computed(() => getAllowedLocPrefixes(form.company_name))
|
||||
|
||||
const form = reactive({
|
||||
id: undefined, base_id: undefined as number | undefined,
|
||||
company_name: '',
|
||||
|
||||
@ -665,7 +665,6 @@
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||
import { getAllStocktakeItems, getDraftMergedList, updateStocktakeQuantity, scanStockByBarcode, getStocktakeCompanies } from '@/api/inbound/stock'
|
||||
import { getWarehouseTree } from '@/api/common/warehouse'
|
||||
import { getAllowedLocPrefixes } from '@/utils/warehouseCompany'
|
||||
import QrScanner from '@/components/QrScanner/index.vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Search, VideoPlay, VideoPause, List, Checked, Download, ArrowRight, Cloudy, Edit, EditPen, CameraFilled, Close, WarningFilled } from '@element-plus/icons-vue'
|
||||
@ -839,11 +838,12 @@ const loadLocationTree = async () => {
|
||||
if (locTreeData.value.length) return
|
||||
treeLoading.value = true
|
||||
try {
|
||||
// ★ 按公司前缀**后端过滤**顶层节点(子树完整保留):IRIS 只拉 Y*,
|
||||
// LICA 只拉 C*/L*,显著减少节点数与传输量。
|
||||
// ★ 公司隔离由后端按 JWT claim 完成,前端不再维护前缀白名单。
|
||||
// 但仍必须显式带上 company_name:跨域角色(超管 / crossDomain)在后端
|
||||
// get_current_company_filter() 里返回 None,不传就等于拉全量 ——
|
||||
// 盘点范围会脱离用户在下拉里选的公司。
|
||||
// 注意不能改成懒加载 —— setCheckedKeys / getCheckedNodes 依赖全树已构建。
|
||||
const prefixes = getAllowedLocPrefixes(selectedCompany.value)
|
||||
const res: any = await getWarehouseTree(prefixes)
|
||||
const res: any = await getWarehouseTree(withCompany())
|
||||
locTreeData.value = res?.data || []
|
||||
} catch (e) {
|
||||
console.error('获取库位树失败', e)
|
||||
@ -1122,7 +1122,17 @@ const handleCompanyChange = async (val: string) => {
|
||||
activeSession.value = null
|
||||
probeFailed.value = false
|
||||
persistCompany(val)
|
||||
|
||||
// ★ 库位树缓存与勾选结果必须一起清掉。
|
||||
// loadLocationTree 首行是「已加载则短路返回」,不清就会继续沿用上一个公司的树,
|
||||
// 勾选范围也跟着错 —— 跨域角色切换公司后尤其明显(选的 A 公司,勾的却是 B 的库位)。
|
||||
resetScopeSelection()
|
||||
locTreeData.value = []
|
||||
|
||||
await checkServerDraft()
|
||||
// 当前已是抽盘模式时,上面的清空会让树一直空着(只有「全仓→抽盘」的切换
|
||||
// 才会触发 watch 里的加载),所以这里要立刻重拉。
|
||||
if (newScopeType.value === 'active') await loadLocationTree()
|
||||
}
|
||||
|
||||
// ★ 探测该公司是否有进行中的盘点 —— 服务端是唯一事实来源,
|
||||
|
||||
Reference in New Issue
Block a user