Files
KCGL/inventory-backend/app/models/system.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

201 lines
7.4 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/models/system.py
from app.extensions import db, beijing_time
from werkzeug.security import generate_password_hash, check_password_hash
from datetime import datetime
# ==========================================
# 1. 系统用户表
# ==========================================
class SysUser(db.Model):
"""
系统用户表
对应数据库: sys_user
username 字段存储格式约定: "真实姓名/登录账号" (例如: 张三/zhangsan01)
"""
__tablename__ = 'sys_user'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(100), nullable=False) # 存储 "张三/zhangsan"
email = db.Column(db.String(100), unique=True)
department = db.Column(db.String(100))
role = db.Column(db.String(50))
status = db.Column(db.String(20), default='active')
password_hash = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=beijing_time)
def set_password(self, password):
"""生成加密密码"""
self.password_hash = generate_password_hash(password)
def check_password(self, password):
"""验证密码"""
return check_password_hash(self.password_hash, password)
def to_dict(self):
"""
序列化为字典
数据库存的是 '张三/zhangsan'
前端需要的是 '张三(zhangsan)'
"""
raw_name = self.username
real_name = ''
account_id = raw_name
# 解析存储格式: Name/ID
if '/' in raw_name:
parts = raw_name.split('/')
real_name = parts[0]
acc_id = parts[1]
# 格式化为前端展示格式: 张三(zhangsan01)
display_name = f"{real_name}({acc_id})"
# 单独提取账号ID (如果前端需要单独用)
account_id = acc_id
else:
display_name = raw_name
return {
'id': self.id,
'username': display_name, # 列表显示: 张三(zhangsan01)
'raw_username': self.username, # 原始数据
'real_name': real_name, # 真实姓名: 张三
'display_name': display_name, # 显示名: 张三(zhangsan01)
'account_id': account_id, # 纯账号ID: zhangsan01
'email': self.email,
'department': self.department,
'role': self.role,
'status': self.status,
'created_at': self.created_at.isoformat() if self.created_at else None
}
# ==========================================
# 2. 系统日志表
# ==========================================
class SysLog(db.Model):
"""
系统操作日志表
对应数据库表: sys_log
"""
__tablename__ = 'sys_log'
id = db.Column(db.Integer, primary_key=True)
op_time = db.Column(db.DateTime, default=beijing_time)
op_user_name = db.Column(db.String(100))
op_user_id = db.Column(db.String(50))
module_name = db.Column(db.String(100))
action_type = db.Column(db.String(50))
target_table = db.Column(db.String(100))
target_id = db.Column(db.Integer)
description = db.Column(db.Text)
ip_address = db.Column(db.String(50))
def to_dict(self):
return {
'id': self.id,
'op_time': self.op_time.isoformat() if self.op_time else None,
'op_user_name': self.op_user_name,
'module_name': self.module_name,
'action_type': self.action_type,
'description': self.description
}
# ==========================================
# 3. 权限管理模型 (RBAC) - [新增]
# ==========================================
class SysMenu(db.Model):
"""系统菜单/页面表"""
__tablename__ = 'sys_menu'
id = db.Column(db.Integer, primary_key=True)
parent_id = db.Column(db.Integer, default=0)
name = db.Column(db.String(50), nullable=False)
code = db.Column(db.String(100), unique=True, nullable=False)
path = db.Column(db.String(200))
sort_order = db.Column(db.Integer, default=0)
is_visible = db.Column(db.Boolean, default=True)
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'code': self.code,
'path': self.path,
'type': 'menu' # 前端树形控件图标判断用
}
class SysElement(db.Model):
"""页面元素/列定义表"""
__tablename__ = 'sys_element'
id = db.Column(db.Integer, primary_key=True)
menu_code = db.Column(db.String(100), db.ForeignKey('sys_menu.code'))
name = db.Column(db.String(100), nullable=False)
code = db.Column(db.String(100), nullable=False) # 如: unit_price
element_type = db.Column(db.String(20), default='column')
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'code': self.code,
'menu_code': self.menu_code,
'type': 'element',
'element_type': self.element_type
}
class SysRolePermission(db.Model):
"""角色权限关联表(支持公司级别隔离)"""
__tablename__ = 'sys_role_permission'
id = db.Column(db.Integer, primary_key=True)
role_code = db.Column(db.String(50), nullable=False)
target_code = db.Column(db.String(100), nullable=False) # menu_code 或 element_code
type = db.Column(db.String(20), nullable=False) # 'menu' 或 'element'
company_name = db.Column(db.String(255), comment='所属公司(NULL/空/System=全局模板,具体值=公司定制)')
# ==========================================
# 4. 库位管理模型
# ==========================================
class SysWarehouseLocation(db.Model):
"""
库位字典表(支持无限层级树形结构)
对应数据库: sys_warehouse_location
"""
__tablename__ = 'sys_warehouse_location'
id = db.Column(db.Integer, primary_key=True)
parent_id = db.Column(db.Integer, db.ForeignKey('sys_warehouse_location.id'), nullable=True)
name = db.Column(db.String(100), nullable=False)
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 字段,不要添加!
# 自关联 - 使用 backref 定义父节点的反向引用
children = db.relationship(
'SysWarehouseLocation',
backref=db.backref('parent', remote_side=[id]),
lazy='dynamic'
)
def to_dict(self):
return {
'id': self.id,
'parent_id': self.parent_id,
'name': self.name,
'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
}