Files
KCGL/inventory-backend/app/api/v1/warehouse.py
yueli 4890b996c4 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,
  不带就会拉全量、盘点范围脱离所选公司
- 盘点页切公司时清库位树缓存(原来会继续沿用上一个公司的树)
- 修复批量删除只取叶子导致中间层节点残留成孤儿
2026-09-29 12:03:53 +08:00

932 lines
39 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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