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,13 +1,16 @@
import request from '@/utils/request'
// 获取库位树形结构
// prefixes 可选:只返回顶层命中这些前缀的根节点及其完整子树(后端过滤),
// 用于按公司精简拉取(IRIS 传 ['Y'],LICA 传 ['C','L']);不传则全量
export function getWarehouseTree(prefixes?: string[]) {
// ★ 公司隔离已由后端按 JWT claim 完成,前端不再维护前缀白名单。
// 跨域角色(超管 / crossDomain)需显式传 params.company_name 指定要看的公司 ——
// 后端 get_current_company_filter() 对它们返回 None,不传就等于拉全量;
// 普通用户传了也会被后端强制收敛回本公司,绕不过去。
// prefixes 仍是后端支持的可选精简参数(只返回顶层命中前缀的子树),当前无调用方使用。
export function getWarehouseTree(params?: Record<string, any>) {
return request({
url: '/v1/warehouse/tree',
method: 'get',
params: prefixes && prefixes.length ? { prefixes: prefixes.join(',') } : undefined
params
})
}
@ -56,10 +59,32 @@ export function batchDeleteWarehouse(ids: number[]) {
}
// 规则化批量生成库位
export function batchGenerateWarehouse(data: { parent_id: number | null, rules: any[] }) {
// 起点三选一,优先级 parent_ids > leaf_only > parent_id:
// parent_ids —— 树上勾选出来的一批库位,每个各按规则生成一遍
// leaf_only —— parent_id 子树下的所有末级库位,每个各生成一遍
// parent_id —— 只在这一个节点下展开(默认)
// company_name 仅「顶级库位 + 跨域角色」时传;子级一律由后端继承父级归属
export function batchGenerateWarehouse(
data: {
parent_id?: number | null
parent_ids?: number[]
rules: any[]
company_name?: string
leaf_only?: boolean
}
) {
return request({
url: '/v1/warehouse/batch-generate',
method: 'post',
data
})
}
// 公司清单(口径同 /v1/inbound/base/options 的 companies:material_base 去重)
// 单独开接口而不复用 options —— 那个挂了 material_list 权限,管库位的人未必有
export function getWarehouseCompanies() {
return request({
url: '/v1/warehouse/companies',
method: 'get'
})
}

View File

@ -56,12 +56,12 @@
<!-- 列表展示区 -->
<div class="selector-body">
<div v-if="displayList.length === 0" class="empty-tip">
<div v-if="currentList.length === 0" class="empty-tip">
当前层级无库位数据
</div>
<ul v-else class="location-list">
<li
v-for="item in displayList"
v-for="item in currentList"
:key="item.id"
class="location-item"
:class="{ 'is-selected': item.full_path === modelValue }"
@ -119,13 +119,10 @@ interface WarehouseItem {
interface Props {
modelValue?: string
// 顶层库位白名单前缀:仅显示 name 以这些前缀开头的顶层节点(空数组 = 全部)
allowedTopPrefixes?: string[]
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
allowedTopPrefixes: () => []
modelValue: ''
})
const emit = defineEmits<{
@ -160,17 +157,9 @@ const currentParentId = computed<number | null>(() => {
})
// 当前显示的列表(懒加载缓存)
// ★ 公司隔离已在后端按 JWT 完成,这里不再做任何前端过滤 —— 后端返回什么就显示什么
const currentList = computed<WarehouseItem[]>(() => cache[currentParentKey.value] || [])
// 顶层按公司白名单前缀过滤后的显示列表(响应式:切换物料公司时实时变化)
const displayList = computed<WarehouseItem[]>(() => {
const list = currentList.value
if (currentParentKey.value === 'root' && props.allowedTopPrefixes.length > 0) {
return list.filter((item) => props.allowedTopPrefixes.some((p) => item.name.startsWith(p)))
}
return list
})
// 懒加载某层子节点(已缓存则跳过)
const ensureLoaded = async (key: string, parentId: number | null) => {
if (cache[key]) return

View File

@ -1,22 +0,0 @@
/**
* 公司 → 允许选择的库位顶层前缀(白名单)
*
* - 匹配到公司:顶层库位只显示 name 以这些前缀开头的节点(其子级随顶层隐藏不可达)
* - 未配置的公司 / 空数组:显示全部库位
*
* 如需调整规则(新增公司/改前缀),只需改这张表,无需改动组件。
*/
export const COMPANY_WAREHOUSE_PREFIXES: Record<string, string[]> = {
// IRIS 仅显示 Y 开头(Y1~Y8)
IRIS: ['Y'],
// LICA 隐藏 Y,显示 C / L 开头
LICA: ['C', 'L'],
}
/**
* 根据公司名取允许的库位顶层前缀(未配置返回空数组 = 不过滤)
*/
export function getAllowedLocPrefixes(companyName?: string | null): string[] {
if (!companyName) return []
return COMPANY_WAREHOUSE_PREFIXES[companyName] || []
}

View File

@ -98,41 +98,61 @@
<el-dialog v-model="warehouseDialogVisible" title="库位管理" width="700px" destroy-on-close :close-on-click-modal="false">
<div class="warehouse-dialog">
<div class="warehouse-header">
<!-- 非批量模式 -->
<template v-if="!isBulkDeleteMode">
<!-- 普通模式 -->
<template v-if="!selectMode">
<el-button type="primary" @click="handleAddTopLevel" :icon="Plus">
新增顶级区域
</el-button>
<el-button type="success" @click="openBatchGenerate" :icon="Plus">
批量生成
</el-button>
<el-button type="danger" @click="isBulkDeleteMode = true" :icon="Delete">
批量删除
<el-button type="warning" plain @click="enterSelectMode" :icon="Checked">
批量选择
</el-button>
</template>
<!-- 批量模式 -->
<!-- 选择模式:勾中的节点既能批量删除,也能批量新增下级 -->
<template v-else>
<el-button @click="cancelBatchMode" :icon="Close">
<el-button @click="cancelSelectMode" :icon="Close">
取消
</el-button>
<el-button type="danger" @click="handleBatchDelete" :disabled="selectedIds.length === 0" :icon="Delete">
确认删除 {{ selectedIds.length > 0 ? `(${selectedIds.length}项)` : '' }}
<span class="select-hint">已选 {{ checkedIds.length }} 个</span>
<el-button
type="primary"
@click="openBatchAddForSelection"
:disabled="checkedIds.length === 0"
:icon="Plus"
>
批量新增下级
</el-button>
<el-button
type="danger"
@click="handleBatchDelete"
:disabled="selectedIds.length === 0"
:icon="Delete"
>
批量删除{{ selectedIds.length > 0 ? `(连同子级共 ${batchDeleteTotal} 个)` : '' }}
</el-button>
</template>
</div>
<el-scrollbar height="450px">
<el-tree
ref="treeRef"
:data="treeData"
:data="groupedTree"
node-key="id"
:props="treeProps"
:expand-on-click-node="false"
:default-expand-all="false"
:show-checkbox="isBulkDeleteMode"
:show-checkbox="selectMode"
:check-strictly="selectMode"
@check="handleTreeCheck"
>
<template #default="{ node, data }">
<div class="tree-node">
<!-- 公司分组头:只是个折叠容器,不能增删改(它不是库位) -->
<div v-if="data.is_group" class="tree-group" @click="toggleGroup(data.id)">
<span class="group-label">{{ data.name }}</span>
<span class="group-count">{{ data.children?.length || 0 }} 个区域</span>
</div>
<div v-else class="tree-node">
<span class="node-label">{{ node.label }}</span>
<span class="node-actions">
<el-button
@ -179,6 +199,26 @@
<el-form-item label="名称" required>
<el-input v-model="locationForm.name" placeholder="请输入库位名称" />
</el-form-item>
<!-- 公司归属:
· 顶级 + 跨域角色 → 可选(含「全局共享」)
· 顶级 + 普通角色 → 只读,强制本公司(后端同样强制,绕不过去)
· 子级 → 只读,永远继承上级(不允许一棵树跨两家) -->
<el-form-item label="公司归属">
<el-select
v-if="!locationForm.parent_id && hasCrossDomain"
v-model="locationForm.company_name"
placeholder="请选择公司"
style="width: 100%"
>
<el-option v-for="c in companies" :key="c" :label="c" :value="c" />
<el-option label="全局共享(两司均可见)" value="" />
</el-select>
<el-input
v-else
:value="locationForm.parent_id ? `继承上级:${companyLabel(locationForm.company_name)}` : companyLabel(locationForm.company_name)"
disabled
/>
</el-form-item>
<el-form-item label="启用状态">
<el-switch v-model="locationForm.is_enabled" />
</el-form-item>
@ -192,10 +232,51 @@
<!-- 批量生成库位弹窗 -->
<el-dialog v-model="batchGenerateVisible" title="批量生成库位" width="800px" destroy-on-close>
<el-form :model="batchGenerateForm" label-width="100px">
<el-form-item label="父级库位">
<el-input :value="batchGenerateForm.parentName" disabled />
<el-button link size="small" @click="batchGenerateForm.parent_id = null; batchGenerateForm.parentName = '无(顶级)'">清空选择</el-button>
<!-- 起点来自树上勾选:不需要选父级,也不涉及归属(各起点各继承自己的) -->
<el-form-item v-if="batchGenerateForm.fromSelection" label="起点">
<el-input :value="`已勾选的 ${batchGenerateForm.seedIds.length} 个库位`" disabled />
</el-form-item>
<template v-else>
<el-form-item label="父级库位">
<!-- 原来这里是只读输入框 + 一个恒为空的「清空选择」,导致批量生成
只能产出顶级区域;换成真正的库位选择器后可以从任意节点起算 -->
<WarehouseSelector
:model-value="batchGenerateForm.parentPath"
@update:model-value="onBatchParentChange"
/>
<el-button
link
size="small"
:disabled="!batchGenerateForm.parent_id"
@click="onBatchParentChange('')"
>
清空(改为生成顶级区域)
</el-button>
</el-form-item>
<!-- 起点范围:只在这个节点下,还是它子树里的每个末级各来一遍 -->
<el-form-item v-if="batchGenerateForm.parent_id" label="生成位置">
<el-radio-group v-model="batchGenerateForm.leaf_only" class="batch-scope">
<el-radio :label="false">仅在「{{ batchGenerateForm.parentName }}」下生成</el-radio>
<el-radio :label="true">
在「{{ batchGenerateForm.parentName }}」下的所有末级库位下各生成一份
<span class="scope-hint">(共 {{ batchLeafCount }} 个末级)</span>
</el-radio>
</el-radio-group>
</el-form-item>
<!-- 归属:选中的父级决定归属;父级为「无(顶级)」时由跨域角色指定 -->
<el-form-item label="公司归属">
<el-select
v-if="!batchGenerateForm.parent_id && hasCrossDomain"
v-model="batchGenerateForm.company_name"
placeholder="请选择公司"
style="width: 100%"
>
<el-option v-for="c in companies" :key="c" :label="c" :value="c" />
<el-option label="全局共享(两司均可见)" value="" />
</el-select>
<el-input v-else :value="batchGenerateForm.parent_id ? '继承上级' : companyLabel(batchGenerateForm.company_name)" disabled />
</el-form-item>
</template>
<el-divider>层级规则(按顺序生成)</el-divider>
<div v-for="(rule, index) in batchGenerateForm.rules" :key="index" class="rule-item">
<el-row :gutter="10">
@ -219,8 +300,42 @@
<el-button type="primary" plain @click="batchGenerateForm.rules.push({ prefix: '', start: 1, end: 10, pad: 1 })" :icon="Plus">
添加层级
</el-button>
<div class="batch-preview">
<span v-if="previewCount > 0">即将生成 <strong>{{ previewCount }}</strong> 个库位</span>
<el-divider>生成结构预览</el-divider>
<!-- 边填边看:几层、每层叫什么、多少个,不用在脑子里推演 -->
<div v-if="previewLevels.length" class="batch-structure">
<div class="struct-row struct-root">
<span class="struct-branch">●</span>
<span class="struct-root-label">{{ batchRootLabel }}</span>
</div>
<div
v-for="lv in previewLevels"
:key="lv.level"
class="struct-row"
:style="{ paddingLeft: 16 + lv.level * 18 + 'px' }"
>
<span class="struct-branch">└</span>
<span class="struct-level">
第 {{ lv.level }} 层
<span v-if="lv.level > 1" class="struct-hint">上一层每个之下</span>
</span>
<span class="struct-names">
{{ lv.names.join(' / ') }}<template v-if="lv.truncated"> … 等 {{ lv.per }} 个</template>
</span>
<span class="struct-count">{{ lv.total }} 个</span>
</div>
<div class="struct-sample">
生成后的库位长这样:<code>{{ batchSamplePath }}</code>
</div>
<div class="struct-total">
共 <strong>{{ previewLevels.length }}</strong> 层 ·
新建 <strong>{{ previewCount }}</strong> 个库位
<span v-if="batchSkippedCount > 0" class="struct-skip">
(另跳过 {{ batchSkippedCount }} 个已存在的同名库位)
</span>
</div>
</div>
<div v-else class="batch-preview">
<span v-if="batchSkippedCount > 0">这些库位都已存在,无需生成</span>
<span v-else>请完善规则</span>
</div>
</el-form>
@ -240,12 +355,13 @@ import { ref, reactive, computed } from 'vue'
import { useRouter } from 'vue-router'
// 1. 引入 User Store
import { useUserStore } from '@/stores/user'
import WarehouseSelector from '@/components/WarehouseSelector.vue'
// 引入需要的图标
import { Box, TrendCharts, ShoppingCart, Operation, Setting, Location, Plus, Edit, Delete, Close, Search } from '@element-plus/icons-vue'
import { Box, TrendCharts, ShoppingCart, Operation, Setting, Location, Plus, Edit, Delete, Close, Search, Checked } from '@element-plus/icons-vue'
import { getPrinterConfig, updatePrinterConfig } from '@/api/common/print'
import request from '@/utils/request'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getWarehouseTree, createWarehouse, updateWarehouse, deleteWarehouse, batchDeleteWarehouse, batchGenerateWarehouse } from '@/api/common/warehouse'
import { getWarehouseTree, createWarehouse, updateWarehouse, deleteWarehouse, batchDeleteWarehouse, batchGenerateWarehouse, getWarehouseCompanies } from '@/api/common/warehouse'
const router = useRouter()
// 2. 实例化 store
@ -392,6 +508,68 @@ const treeProps = {
children: 'children'
}
// 顶级库位按公司归属分组折叠。
// 归属是「区域级」属性(子级强制继承父级,不会跨公司),所以按顶级分组最自然:
// 挂在每个节点后面既挤又难扫,分组后一眼看出两家各有哪些区域,也不占横向空间。
// ★ 分组节点是**虚拟节点**(id 形如 'group:IRIS'),不对应任何库位行,
// 所以不提供新增下级/编辑/删除;批量删除取的是叶子(getCheckedKeys(true)),
// 分组节点永远不是叶子,不会被误选。
const groupedTree = computed(() => {
const groups = new Map<string, { label: string; company: string | null; children: any[] }>()
for (const root of treeData.value) {
const company = root.company_name ?? null
const key = company ?? '__GLOBAL__'
if (!groups.has(key)) groups.set(key, { label: companyLabel(company), company, children: [] })
groups.get(key)!.children.push(root)
}
// 固定顺序:具体公司按字典序,全局共享垫底
return [...groups.entries()]
.sort(([a], [b]) => (a === '__GLOBAL__' ? 1 : b === '__GLOBAL__' ? -1 : a.localeCompare(b)))
.map(([key, g]) => ({
id: `group:${key}`,
name: g.label,
is_group: true,
company_name: g.company,
children: g.children,
}))
})
// 整行点击也能展开/收起分组(el-tree 的 expand-on-click-node 关着,
// 默认只有那个小三角能点,对分组头来说太难点中)
const toggleGroup = (key: string) => {
const node = treeRef.value?.getNode(key)
if (node) node.expanded = !node.expanded
}
// ---- 公司归属 ----
// 只有跨域角色(超管,或持 crossDomain 权限,当前为 WAREHOUSE_MGR)能指定归属 ——
// 与后端 get_current_company_filter() 的口径一致:对它们返回 None(不隔离)。
// ★ SUPER_ADMIN 的权限是通配符 ['*'],permissions.includes('crossDomain')
// 对它恒为 false,所以角色与权限码两个条件都要判。
const hasCrossDomain = computed(() => {
const role = (userStore.role || '').toUpperCase()
if (role === 'SUPER_ADMIN') return true
return userStore.hasPermission('crossDomain')
})
const companies = ref<string[]>([])
const loadCompanies = async () => {
if (!hasCrossDomain.value) return // 普通角色的归属字段是只读的,不需要公司清单
try {
const res: any = await getWarehouseCompanies()
if (res.code === 200) companies.value = res.data || []
} catch (e) {
console.error('获取公司清单失败', e)
}
}
// null / 空串 = 全局共享(两司均可见)
const companyLabel = (v?: string | null) => v || '全局共享'
// 编辑前的原归属,用于判断本次是否真的改了归属
const editingCompany = ref<string | null>(null)
// 库位表单
const locationFormVisible = ref(false)
const locationFormTitle = ref('')
@ -400,29 +578,88 @@ const locationForm = reactive({
parent_id: null as number | null,
parentName: '无(顶级)',
name: '',
is_enabled: true
is_enabled: true,
// null = 尚未选择(仅顶级库位需要);'' = 全局共享;具体值 = 公司名
company_name: null as string | null
})
const locationLoading = ref(false)
// 批量删除相关
const selectedIds = ref<number[]>([])
const isBulkDeleteMode = ref(false)
// 批量选择模式:树上出勾选框,勾中的节点既能批量删除,也能批量新增下级。
// ★ check-strictly 只在选择模式打开 —— 勾父节点不再自动连带勾中子节点。
// 「勾哪些就作用到哪些」才是这个模式的语义:否则勾中一个 217 节点的区域
// 去「批量新增下级」,会给区域内**每一个**节点都长出子节点。
const selectMode = ref(false)
const checkedIds = ref<number[]>([]) // 勾中的全部节点(批量新增用)
const selectedIds = ref<number[]>([]) // 收敛成最小集(批量删除用,见 handleTreeCheck)
const handleTreeCheck = (data: any, checked: any) => {
// 使用 getCheckedKeys(true) 只获取叶子节点,防止 el-tree 自动连带选中父节点导致误删
if (treeRef.value) {
selectedIds.value = treeRef.value.getCheckedKeys(true) as number[]
}
}
const cancelBatchMode = () => {
isBulkDeleteMode.value = false
const enterSelectMode = () => {
selectMode.value = true
checkedIds.value = []
selectedIds.value = []
if (treeRef.value) {
treeRef.value.setCheckedKeys([])
}
treeRef.value?.setCheckedKeys([], false)
}
const cancelSelectMode = () => {
selectMode.value = false
checkedIds.value = []
selectedIds.value = []
treeRef.value?.setCheckedKeys([], false)
}
// 勾选 → 两个集合
//
// ★ 删除用「最小集」。这里不能用 getCheckedKeys(true)(只取叶子)—— 旧实现就是
// 那么写的,理由是「防止 el-tree 连带选中父节点导致误删」,但反过来的坑更大:
// 勾中一个区域时只删掉它的叶子,中间层节点全部残留成孤儿(后端删除是级联的,
// 父删了子自然没,只传叶子等于专门把中间层挑出来留着)。
// 也不能把 getCheckedKeys() 原样全塞给后端 —— 祖先和子孙同时在列,后端虽不炸,
// 但会重复递归、deleted_count 失真。剔掉「祖先已在集合里」的冗余项即可。
//
// ★ 新增用「全集」:每个勾中的节点都要长出子级,父与子各自加一层是符合预期的,
// 不做同样的收敛。
const handleTreeCheck = () => {
const tree = treeRef.value
if (!tree) return
const isRealNode = (id: any) => {
const n = tree.getNode(id)
return !!(n && n.data && !n.data.is_group) // 分组是虚拟节点,不是库位
}
const checked = ((tree.getCheckedKeys() as any[]) || []).filter(isRealNode)
checkedIds.value = checked
const set = new Set(checked)
selectedIds.value = checked.filter((id) => {
let parent = tree.getNode(id)?.parent
while (parent) {
const pdata = parent.data
if (!pdata || pdata.is_group) break // 走到分组就停,分组不参与
if (set.has(pdata.id)) return false // 祖先已在集合里 → 由祖先级联删除
parent = parent.parent
}
return true
})
}
// 实际会被删掉的节点数(含子孙)。只显示 selectedIds.length 会严重低报 ——
// 勾一个区域可能实际删掉几百个节点,操作者得知道自己按下去会发生什么。
const batchDeleteTotal = computed(() => {
const tree = treeRef.value
if (!tree || !selectedIds.value.length) return 0
let n = 0
const walk = (id: any) => {
n += 1
const node = tree.getNode(id)
for (const child of node?.childNodes || []) {
if (!child.data?.is_group) walk(child.data.id)
}
}
selectedIds.value.forEach(walk)
return n
})
const handleBatchDelete = async () => {
if (selectedIds.value.length === 0) {
ElMessage.warning('请先选择要删除的库位')
@ -431,7 +668,8 @@ const handleBatchDelete = async () => {
try {
await ElMessageBox.confirm(
`确定要删除选中的 ${selectedIds.value.length} 个库位吗?该操作将同时删除所有子库位。`,
`确定要删除选中的 ${selectedIds.value.length} 个节点吗?` +
`连同其下所有子库位,共 ${batchDeleteTotal.value} 个库位将被删除,且不可恢复。`,
'警告',
{ type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' }
)
@ -439,7 +677,7 @@ const handleBatchDelete = async () => {
const res = await batchDeleteWarehouse(selectedIds.value)
if (res.code === 200) {
ElMessage.success('批量删除成功')
cancelBatchMode()
cancelSelectMode()
await loadWarehouseTree()
} else {
ElMessage.error(res.msg || '删除失败')
@ -454,25 +692,216 @@ const batchGenerateVisible = ref(false)
const batchGenerateForm = reactive({
parent_id: null as number | null,
parentName: '无(顶级)',
// 选择器绑的是 full_path;parent_id 由它反查得出(见 onBatchParentChange)
parentPath: '',
// 起点范围:false = 只在 parent 下;true = 它子树下的所有末级各来一遍
leaf_only: false,
// 起点来自树上勾选(「批量选择」→「批量新增下级」),此时 parent_* 全部不参与
fromSelection: false,
// 打开弹窗那一刻勾选的 id 快照 —— 弹窗打开期间树被遮住没法再改,但快照更保险
seedIds: [] as number[],
// 同 locationForm.company_name:null = 尚未选择,'' = 全局共享
company_name: null as string | null,
rules: [{ prefix: '', start: 1, end: 10, pad: 1 }] as { prefix: string, start: number, end: number, pad: number }[]
})
const batchLoading = ref(false)
const previewCount = computed(() => {
let count = 1
for (const rule of batchGenerateForm.rules) {
if (rule.start && rule.end && rule.start <= rule.end) {
count *= (rule.end - rule.start + 1)
} else {
return 0
// 按 full_path 在已加载的树里反查节点
// (WarehouseSelector 只吐 full_path,后端要的是 id)
const findNodeByPath = (nodes: any[], path: string): any => {
for (const n of nodes) {
if (n.full_path === path) return n
if (n.children?.length) {
const hit = findNodeByPath(n.children, path)
if (hit) return hit
}
}
return count
return null
}
const findNodeById = (nodes: any[], id: number): any => {
for (const n of nodes) {
if (n.id === id) return n
if (n.children?.length) {
const hit = findNodeById(n.children, id)
if (hit) return hit
}
}
return null
}
const onBatchParentChange = (path: string) => {
batchGenerateForm.parentPath = path || ''
if (!path) {
batchGenerateForm.parent_id = null
batchGenerateForm.parentName = '无(顶级)'
batchGenerateForm.leaf_only = false
batchGenerateForm.company_name = hasCrossDomain.value ? null : (userStore.companyName || '')
return
}
const node = findNodeByPath(treeData.value, path)
if (!node) {
ElMessage.warning('该库位不在当前库位树中,请重新选择')
return
}
batchGenerateForm.parent_id = node.id
batchGenerateForm.parentName = node.full_path || node.name
}
// 末级 = 没有子节点(含父级自身),与后端 _subtree_leaf_ids 口径一致。
// ★ 不按「最深一层」判断 —— 那只在规则化生成的树上跟「没有子节点」等价,
// 手建的树深浅不一,按深度算会漏掉浅枝上的末级。
const collectLeaves = (node: any): any[] =>
!node?.children?.length ? [node] : node.children.flatMap((c: any) => collectLeaves(c))
const batchLeafCount = computed(() => {
if (!batchGenerateForm.parent_id || !batchGenerateForm.parentPath) return 0
const node = findNodeByPath(treeData.value, batchGenerateForm.parentPath)
return node ? collectLeaves(node).length : 0
})
const batchSeedCount = computed(() => {
if (batchGenerateForm.fromSelection) return batchGenerateForm.seedIds.length
if (!batchGenerateForm.parent_id) return 1 // 顶级生成:起点是「根」,算一个
return batchGenerateForm.leaf_only ? batchLeafCount.value : 1
})
// 每个起点下「已有的子库位名」集合 —— 用来判断哪些名字会被后端跳过。
// 顶级生成时没有父节点,要和「所有顶级库位」比名字。
const batchExistingNames = computed<Set<string>[]>(() => {
const namesOf = (n: any) => new Set<string>((n.children || []).map((c: any) => c.name))
if (batchGenerateForm.fromSelection) {
return batchGenerateForm.seedIds
.map((id) => findNodeById(treeData.value, id))
.filter(Boolean)
.map((n: any) => namesOf(n))
}
if (!batchGenerateForm.parent_id) {
return [new Set<string>(treeData.value.map((n: any) => n.name))]
}
if (!batchGenerateForm.parentPath) return []
const node = findNodeByPath(treeData.value, batchGenerateForm.parentPath)
if (!node) return []
return batchGenerateForm.leaf_only
? collectLeaves(node).map((n: any) => namesOf(n))
: [namesOf(node)]
})
// 会被跳过(已存在同名)的个数
const batchSkippedCount = computed(() => {
const first = batchGenerateForm.rules[0]
if (!first) return 0
const s = Number(first.start)
const e = Number(first.end)
if (!(s > 0 && e >= s)) return 0
const pad = Number(first.pad) || 1
const wanted: string[] = []
for (let n = s; n <= e; n++) wanted.push(`${first.prefix || ''}${String(n).padStart(pad, '0')}`)
let hit = 0
for (const have of batchExistingNames.value) {
hit += wanted.filter((n) => have.has(n)).length
}
return hit
})
// 逐层的生成结构,供「生成结构预览」用。
// 每层的总量 = 上一层的总量 × 本层规则个数;第 1 层还要扣掉重名跳过的。
// (第 2 层往下的父节点都是本次新建的,不可能撞名。)
// ★ 预览数字唯一来源就是这里 —— 结构表上的每层数字加起来正好等于最终提交后
// 后端会新建的个数,不会出现"预览 10、实际 7"这种对不上的情况。
const previewLevels = computed(() => {
const rules = batchGenerateForm.rules
const seeds = batchSeedCount.value
if (!seeds) return []
const out: { level: number; per: number; total: number; names: string[]; truncated: boolean }[] = []
let prevTotal = 0
for (let i = 0; i < rules.length; i++) {
const r = rules[i]
const s = Number(r.start)
const e = Number(r.end)
if (!(s > 0 && e >= s)) return [] // 规则没填完 → 整块不显示
const per = e - s + 1
const total = i === 0 ? seeds * per - batchSkippedCount.value : prevTotal * per
if (total <= 0) break // 上一层没建成 → 下面也不会有
const pad = Number(r.pad) || 1
const cap = Math.min(per, 6) // 名字只列前 6 个,其余折叠成「等 N 个」
const names: string[] = []
for (let k = s; k < s + cap; k++) names.push(`${r.prefix || ''}${String(k).padStart(pad, '0')}`)
out.push({ level: i + 1, per, total, names, truncated: per > cap })
prevTotal = total
}
return out
})
// 实际会**新建**的节点数 = 各层之和。必须扣掉跳过的那些 ——
// full_path 上有唯一约束 uniq_full_path,同名不跳过的话整个请求会 500 并整体回滚,
// 连本该新建的也一并丢失(实测:已有 1/2/3 时生成 1~10,4~10 那 7 个全没落库)。
const previewCount = computed(() => previewLevels.value.reduce((s, l) => s + l.total, 0))
// 起点本身的 full_path(顶级生成时为空 —— 没有父级可拼)
const batchRootPath = computed(() => {
if (batchGenerateForm.fromSelection) {
const n = findNodeById(treeData.value, batchGenerateForm.seedIds[0])
return n?.full_path || ''
}
if (!batchGenerateForm.parent_id || !batchGenerateForm.parentPath) return ''
const node = findNodeByPath(treeData.value, batchGenerateForm.parentPath)
if (!node) return ''
if (batchGenerateForm.leaf_only) return collectLeaves(node)[0]?.full_path || ''
return node.full_path || ''
})
// 第一条完整路径示例 = 起点 full_path + 每层第一个名字。
// 这就是最终会落进库存表 warehouse_location 的值(图里那种 L3/4/4/1),
// 光看每层的 D1 / 1 / 1 还得自己在脑子里拼。
// ★ 第 1 层要跳过已存在的同名 —— 否则示例会给出一个实际根本没建的路径。
// 第 2 层往下的父节点都是本次新建的,不可能撞名。
const batchSamplePath = computed(() => {
const levels = previewLevels.value
if (!levels.length) return ''
const existing = batchExistingNames.value[0]
const parts: string[] = []
const root = batchRootPath.value
if (root) parts.push(root)
for (let i = 0; i < levels.length; i++) {
const names = levels[i].names
const pick = (i === 0 && existing)
? (names.find((n) => !existing.has(n)) || names[0])
: names[0]
parts.push(pick)
}
return parts.join('/')
})
// 结构预览的根节点文案
const batchRootLabel = computed(() => {
if (batchGenerateForm.fromSelection) return `勾选的 ${batchGenerateForm.seedIds.length} 个库位`
if (!batchGenerateForm.parent_id) return '顶级(无父级)'
if (batchGenerateForm.leaf_only) return `${batchGenerateForm.parentName} 下的 ${batchLeafCount.value} 个末级库位`
return batchGenerateForm.parentName || '—'
})
const openBatchGenerate = () => {
batchGenerateForm.fromSelection = false
batchGenerateForm.seedIds = []
batchGenerateForm.parent_id = null
batchGenerateForm.parentName = '无(顶级)'
batchGenerateForm.parentPath = ''
batchGenerateForm.leaf_only = false
batchGenerateForm.company_name = hasCrossDomain.value ? null : (userStore.companyName || '')
batchGenerateForm.rules = [{ prefix: '', start: 1, end: 10, pad: 1 }]
batchGenerateVisible.value = true
}
// 「批量选择」模式下点「批量新增下级」:把勾中的库位当起点
const openBatchAddForSelection = () => {
if (!checkedIds.value.length) return
batchGenerateForm.fromSelection = true
batchGenerateForm.seedIds = [...checkedIds.value]
batchGenerateForm.parent_id = null
batchGenerateForm.parentName = ''
batchGenerateForm.parentPath = ''
batchGenerateForm.leaf_only = false
batchGenerateForm.rules = [{ prefix: '', start: 1, end: 10, pad: 1 }]
batchGenerateVisible.value = true
}
@ -483,14 +912,40 @@ const handleBatchGenerate = async () => {
return
}
const fromSelection = batchGenerateForm.fromSelection
const isTopLevel = !fromSelection && !batchGenerateForm.parent_id
if (isTopLevel && hasCrossDomain.value && batchGenerateForm.company_name === null) {
ElMessage.warning('请选择公司归属')
return
}
if (fromSelection && !batchGenerateForm.seedIds.length) {
ElMessage.warning('没有勾选任何库位')
return
}
try {
batchLoading.value = true
const res = await batchGenerateWarehouse({
parent_id: batchGenerateForm.parent_id,
rules: batchGenerateForm.rules
})
const payload: any = { rules: batchGenerateForm.rules }
if (fromSelection) {
// 勾选模式下 parent_id / leaf_only / company_name 全部不参与 ——
// 归属由每个起点各自继承,后端按各自的父节点算
payload.parent_ids = batchGenerateForm.seedIds
} else {
payload.parent_id = batchGenerateForm.parent_id
if (isTopLevel && hasCrossDomain.value) payload.company_name = batchGenerateForm.company_name
if (!isTopLevel && batchGenerateForm.leaf_only) payload.leaf_only = true
}
const res = await batchGenerateWarehouse(payload)
if (res.code === 200) {
ElMessage.success(`生成成功,共生成 ${res.data.generated_count} 个库位`)
const seed = res.data.seed_count || 1
let scope = ''
if (fromSelection) scope = `(起点:勾选的 ${seed} 个库位)`
else if (batchGenerateForm.leaf_only) scope = `(起点:${seed} 个末级库位)`
// 直接用后端的 msg —— 它会写明「新建 N 个、跳过 M 个已存在同名」,
// 前端另拼一套容易和真实结果对不上
ElMessage.success(`${res.msg}${scope}`)
// 从勾选进来的,生成后清掉选择,免得同一批被重复点
if (fromSelection) cancelSelectMode()
batchGenerateVisible.value = false
await loadWarehouseTree()
} else {
@ -505,7 +960,7 @@ const handleBatchGenerate = async () => {
// 打开库位管理弹窗
const openWarehouseDialog = async () => {
await loadWarehouseTree()
await Promise.all([loadWarehouseTree(), loadCompanies()])
warehouseDialogVisible.value = true
}
@ -528,6 +983,9 @@ const handleAddTopLevel = () => {
locationForm.parentName = '无(顶级)'
locationForm.name = ''
locationForm.is_enabled = true
// 跨域角色必须显式选(null),不给默认值 —— 默认全局共享会让两司都凭空多出一个库位
locationForm.company_name = hasCrossDomain.value ? null : (userStore.companyName || '')
editingCompany.value = null
locationFormTitle.value = '新增顶级库位'
locationFormVisible.value = true
}
@ -539,6 +997,9 @@ const handleAddChild = (data: any) => {
locationForm.parentName = data.full_path || data.name
locationForm.name = ''
locationForm.is_enabled = true
// 子级归属由后端强制继承父级,这里只为把继承到的值显示出来
locationForm.company_name = data.company_name ?? ''
editingCompany.value = null
locationFormTitle.value = `新增下级库位:${data.name}`
locationFormVisible.value = true
}
@ -550,6 +1011,8 @@ const handleEdit = (data: any) => {
locationForm.parentName = data.parent_id ? data.parent_full_path || '无(顶级)' : '无(顶级)'
locationForm.name = data.name
locationForm.is_enabled = data.is_enabled
locationForm.company_name = data.company_name ?? ''
editingCompany.value = data.company_name ?? ''
locationFormTitle.value = `编辑库位:${data.name}`
locationFormVisible.value = true
}
@ -581,18 +1044,46 @@ const saveLocation = async () => {
ElMessage.warning('请输入库位名称')
return
}
const isTopLevel = !locationForm.parent_id
// 顶级库位由跨域角色指定归属,必须显式选择 ——
// 否则 null 会静默变成「全局共享」(两司都能看见),事后很难发现。
if (isTopLevel && hasCrossDomain.value && locationForm.company_name === null) {
ElMessage.warning('请选择公司归属')
return
}
// 归属变更会级联整棵子树,先让操作者确认影响面
const companyChanged = isTopLevel && hasCrossDomain.value && locationForm.id !== undefined
&& (locationForm.company_name || '') !== (editingCompany.value || '')
if (companyChanged) {
try {
await ElMessageBox.confirm(
`归属将由「${companyLabel(editingCompany.value)}」改为「${companyLabel(locationForm.company_name)}」,` +
`该库位下的所有子库位会一并变更。确定继续?`,
'确认变更归属',
{ type: 'warning', confirmButtonText: '确定变更', cancelButtonText: '取消' }
)
} catch (e) {
return // 用户取消
}
}
try {
locationLoading.value = true
const payload: any = {
name: locationForm.name.trim(),
is_enabled: locationForm.is_enabled
}
if (locationForm.parent_id) {
payload.parent_id = locationForm.parent_id
} else if (hasCrossDomain.value) {
// '' = 全局共享(后端 _normalize_company 折算成 NULL)
payload.company_name = locationForm.company_name
}
let res
if (locationForm.id) {
// 编辑
@ -602,9 +1093,16 @@ const saveLocation = async () => {
// 新增
res = await createWarehouse(payload)
}
if (res.code === 200) {
ElMessage.success('保存成功')
// 把连带影响如实回显 —— 改名会同步台账快照,改归属会级联子树,
// 操作者不被告知的话,会以为只动了一个节点
let msg = '保存成功'
if (res.company_affected > 1) msg += `,已同步变更 ${res.company_affected} 个库位的归属`
const snap = res.snapshot_affected || {}
const snapTotal = Object.values(snap).reduce((a: number, b: any) => a + Number(b), 0)
if (snapTotal > 0) msg += `,并同步了 ${snapTotal} 条台账快照`
ElMessage.success(msg)
locationFormVisible.value = false
await loadWarehouseTree()
} else {
@ -716,6 +1214,23 @@ const handleNav = (path: string) => {
.node-label {
font-size: 14px;
}
/* 公司分组头 */
.tree-group {
flex: 1;
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
font-weight: 600;
}
.group-label {
font-size: 14px;
}
.group-count {
font-size: 12px;
font-weight: 400;
color: #909399;
}
.node-actions {
display: flex;
gap: 5px;
@ -728,6 +1243,103 @@ const handleNav = (path: string) => {
background: #f5f7fa;
border-radius: 4px;
}
/* 生成位置单选:两个选项文案都长,竖排免得挤出弹窗 */
.batch-scope {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.batch-scope .el-radio {
margin-right: 0;
height: auto;
align-items: flex-start;
}
.scope-hint {
color: #909399;
}
/* 选择模式下的「已选 N 个」 */
.select-hint {
font-size: 13px;
color: #909399;
margin: 0 4px;
}
/* 生成结构预览:逐层缩进,一眼看出几层、每层长什么样 */
.batch-structure {
border: 1px solid #e4e7ed;
border-radius: 4px;
padding: 10px 12px;
background: #fafafa;
font-size: 13px;
}
.struct-row {
display: flex;
align-items: baseline;
gap: 8px;
line-height: 22px;
}
.struct-root-label {
font-weight: 600;
color: #303133;
}
.struct-branch {
color: #c0c4cc;
flex: none;
}
.struct-level {
flex: none;
color: #606266;
}
.struct-hint {
color: #c0c4cc;
font-size: 12px;
margin-left: 4px;
}
.struct-names {
color: #409eff;
font-family: monospace;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.struct-count {
flex: none;
color: #909399;
}
.struct-sample {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed #dcdfe6;
color: #909399;
text-align: center;
}
.struct-sample code {
font-family: monospace;
color: #409eff;
background: #ecf5ff;
padding: 1px 8px;
border-radius: 3px;
margin-left: 2px;
}
.struct-total {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed #dcdfe6;
text-align: center;
color: #606266;
}
.struct-total strong {
color: #409eff;
font-size: 15px;
}
.struct-skip {
color: #e6a23c;
}
.batch-preview {
margin-top: 15px;
padding: 10px;

View File

@ -384,7 +384,7 @@
</el-col>
<el-col :span="6">
<el-form-item label="库位" prop="warehouse_location">
<WarehouseSelector v-model="form.warehouse_location" :allowed-top-prefixes="allowedLocPrefixes" />
<WarehouseSelector v-model="form.warehouse_location" />
</el-form-item>
</el-col>
</el-row>
@ -807,7 +807,6 @@ import {getLabelPreview, executePrint} from '@/api/common/print'
import { usePasteUpload } from '@/hooks/usePasteUpload'
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue'
import WarehouseSelector from '@/components/WarehouseSelector.vue'
import { getAllowedLocPrefixes } from '@/utils/warehouseCompany'
import SmartScannerDialog from '@/components/SmartScannerDialog.vue'
import { useUserStore } from '@/stores/user'
@ -1168,9 +1167,6 @@ const handleCheckAllChange = (val: boolean) => {
}
};
// 按物料公司限制可选库位顶层前缀(IRIS→Y,LICA→C/L,未配置→全部)
const allowedLocPrefixes = computed(() => getAllowedLocPrefixes(form.company_name))
const form = reactive({
id: undefined, base_id: undefined as number | undefined,
company_name: '',

View File

@ -354,7 +354,7 @@
<el-row :gutter="24">
<el-col :span="6"><el-form-item label="SKU" prop="sku"><el-input v-model="form.sku" placeholder="自动生成" disabled /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="库位" prop="warehouse_location">
<WarehouseSelector v-model="form.warehouse_location" :allowed-top-prefixes="allowedLocPrefixes" />
<WarehouseSelector v-model="form.warehouse_location" />
</el-form-item></el-col>
<el-col :span="6"><el-form-item label="入库日期"><el-date-picker v-model="form.in_date" type="date" value-format="YYYY-MM-DD" style="width:100%" disabled /></el-form-item></el-col>
</el-row>
@ -614,7 +614,6 @@ import {
import { uploadFile, deleteFile } from '@/api/inbound/buy'
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue'
import WarehouseSelector from '@/components/WarehouseSelector.vue'
import { getAllowedLocPrefixes } from '@/utils/warehouseCompany'
import SmartScannerDialog from '@/components/SmartScannerDialog.vue'
import TrackScanDialog from '@/components/TrackScanDialog.vue'
import { getLabelPreview, executePrint } from '@/api/common/print'
@ -930,9 +929,6 @@ const displayData = computed(() => {
const defaultVisibleCols = ['company_name', 'material_name', 'sku', 'warehouse_loc', 'serial_number', 'in_quantity', 'stock_quantity', 'available_quantity', 'status', 'quality_status', 'product_photo', 'sale_price', 'order_id']
// 按物料公司限制可选库位顶层前缀(IRIS→Y,LICA→C/L,未配置→全部)
const allowedLocPrefixes = computed(() => getAllowedLocPrefixes(form.company_name))
const form = reactive({
id: undefined, base_id: undefined as number | undefined,
company_name: '', // [新增]

View File

@ -393,7 +393,7 @@
<el-col :span="6"><el-form-item label="编码/SKU" prop="sku"><el-input v-model="form.sku" placeholder="系统自动生成" disabled/></el-form-item></el-col>
<el-col :span="6"><el-form-item label="入库日期" prop="in_date"><el-date-picker v-model="form.in_date" type="date" value-format="YYYY-MM-DD" style="width:100%" disabled/></el-form-item></el-col>
<el-col :span="6"><el-form-item label="库位" prop="warehouse_location">
<WarehouseSelector v-model="form.warehouse_location" :allowed-top-prefixes="allowedLocPrefixes" />
<WarehouseSelector v-model="form.warehouse_location" />
</el-form-item></el-col>
</el-row>
@ -658,7 +658,6 @@ import {
import { uploadFile, deleteFile } from '@/api/inbound/buy'
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue'
import WarehouseSelector from '@/components/WarehouseSelector.vue'
import { getAllowedLocPrefixes } from '@/utils/warehouseCompany'
import SmartScannerDialog from '@/components/SmartScannerDialog.vue'
import TrackScanDialog from '@/components/TrackScanDialog.vue'
import {getLabelPreview, executePrint} from '@/api/common/print'
@ -974,9 +973,6 @@ const handleCheckAllChange = (val: boolean) => {
const defaultColumns = ['company_name', 'material_name', 'spec_model', 'unit', 'inbound_date', 'sn_bn', 'status', 'quality_status', 'bom_code', 'work_order_code', 'stock_quantity', 'available_quantity', 'unit_total_cost', 'arrival_photo', 'quality_report_link']
// 按物料公司限制可选库位顶层前缀(IRIS→Y,LICA→C/L,未配置→全部)
const allowedLocPrefixes = computed(() => getAllowedLocPrefixes(form.company_name))
const form = reactive({
id: undefined, base_id: undefined as number | undefined,
company_name: '',

View File

@ -665,7 +665,6 @@
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { getAllStocktakeItems, getDraftMergedList, updateStocktakeQuantity, scanStockByBarcode, getStocktakeCompanies } from '@/api/inbound/stock'
import { getWarehouseTree } from '@/api/common/warehouse'
import { getAllowedLocPrefixes } from '@/utils/warehouseCompany'
import QrScanner from '@/components/QrScanner/index.vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, VideoPlay, VideoPause, List, Checked, Download, ArrowRight, Cloudy, Edit, EditPen, CameraFilled, Close, WarningFilled } from '@element-plus/icons-vue'
@ -839,11 +838,12 @@ const loadLocationTree = async () => {
if (locTreeData.value.length) return
treeLoading.value = true
try {
// ★ 按公司前缀**后端过滤**顶层节点(子树完整保留):IRIS 只拉 Y*,
// LICA 只拉 C*/L*,显著减少节点数与传输量。
// ★ 公司隔离由后端按 JWT claim 完成,前端不再维护前缀白名单。
// 但仍必须显式带上 company_name:跨域角色(超管 / crossDomain)在后端
// get_current_company_filter() 里返回 None,不传就等于拉全量 ——
// 盘点范围会脱离用户在下拉里选的公司。
// 注意不能改成懒加载 —— setCheckedKeys / getCheckedNodes 依赖全树已构建。
const prefixes = getAllowedLocPrefixes(selectedCompany.value)
const res: any = await getWarehouseTree(prefixes)
const res: any = await getWarehouseTree(withCompany())
locTreeData.value = res?.data || []
} catch (e) {
console.error('获取库位树失败', e)
@ -1122,7 +1122,17 @@ const handleCompanyChange = async (val: string) => {
activeSession.value = null
probeFailed.value = false
persistCompany(val)
// ★ 库位树缓存与勾选结果必须一起清掉。
// loadLocationTree 首行是「已加载则短路返回」,不清就会继续沿用上一个公司的树,
// 勾选范围也跟着错 —— 跨域角色切换公司后尤其明显(选的 A 公司,勾的却是 B 的库位)。
resetScopeSelection()
locTreeData.value = []
await checkServerDraft()
// 当前已是抽盘模式时,上面的清空会让树一直空着(只有「全仓→抽盘」的切换
// 才会触发 watch 里的加载),所以这里要立刻重拉。
if (newScopeType.value === 'active') await loadLocationTree()
}
// ★ 探测该公司是否有进行中的盘点 —— 服务端是唯一事实来源,