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:
@ -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()
|
||||
|
||||
@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user