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:
yueli
2026-09-29 12:03:53 +08:00
parent 896bbd8eeb
commit 4890b996c4
11 changed files with 1465 additions and 167 deletions

View File

@ -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()

View File

@ -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
}