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

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