This commit is contained in:
2026-08-05 15:54:59 +08:00
parent eede351038
commit 4b31eb7786
43 changed files with 2117 additions and 851 deletions
@@ -136,10 +136,7 @@ async function mountView() {
}
async function selectScope(wrapper: VueWrapper) {
let toolbar = wrapper.findComponent(QueryToolbar)
toolbar.vm.$emit('update:modelValue', { ...(toolbar.props('modelValue') as QueryModel), customerId: tenantId })
await flushPromises()
toolbar = wrapper.findComponent(QueryToolbar)
const toolbar = wrapper.findComponent(QueryToolbar)
toolbar.vm.$emit('update:modelValue', { ...(toolbar.props('modelValue') as QueryModel), warehouseId })
await flushPromises()
const searchButton = toolbar.findAll('button').find((button) => button.text().trim() === '查询')
@@ -168,20 +165,19 @@ describe('LocationsView', () => {
})
})
it('starts with all customers and queries the full list without empty IDs', async () => {
it('loads all warehouses and queries the full location list without requiring a customer or warehouse', async () => {
const wrapper = await mountView()
const toolbar = wrapper.findComponent(QueryToolbar)
const fields = toolbar.props('fields') as QueryField[]
expect(listPlatformTenants).toHaveBeenCalledWith({ page: 1, size: 200 })
expect(wrapper.find('[aria-label="所属企业"]').exists()).toBe(true)
expect(fields[0]?.placeholder).toBe('全部客户')
expect((toolbar.props('modelValue') as QueryModel).customerId).toBe('')
expect(wrapper.find('[aria-label="所属企业"]').exists()).toBe(false)
expect(fields[0]?.placeholder).toBe('全部仓库')
expect(fields.map(({ key, width }) => [key, width])).toEqual([
['customerId', 260], ['warehouseId', 240], ['zoneCode', 210],
['warehouseId', 300], ['zoneCode', 210],
['locationCode', 220], ['locationType', 170], ['status', 160],
])
expect(listPlatformWarehouses).not.toHaveBeenCalled()
expect(listPlatformWarehouses).toHaveBeenCalledWith(undefined, 1, 200)
expect(listPlatformLocations).toHaveBeenCalledWith({
page: 1,
size: 20,
@@ -200,16 +196,15 @@ describe('LocationsView', () => {
expect(wrapper.text()).not.toContain('企业 ID')
})
it('loads the selected customer, warehouse, and location hierarchy without narrowing snowflake IDs', async () => {
it('filters by the selected warehouse without exposing or sending a customer id', async () => {
const wrapper = await mountView()
await selectScope(wrapper)
expect(wrapper.find('.table-panel .panel-heading').exists()).toBe(false)
expect(listPlatformWarehouses).toHaveBeenCalledWith(tenantId, 1, 200)
expect(listPlatformWarehouses).toHaveBeenCalledWith(undefined, 1, 200)
expect(listPlatformLocations).toHaveBeenLastCalledWith({
page: 1,
size: 20,
tenantId,
warehouseId,
zoneCode: '',
locationCode: '',
@@ -223,6 +218,19 @@ describe('LocationsView', () => {
])
})
it('opens creation without a list filter and requires the warehouse inside the form', async () => {
const wrapper = await mountView()
const createButton = wrapper.findAll('button').find((button) => button.text().trim() === '新增库位')
expect(createButton?.attributes('disabled')).toBeUndefined()
await createButton!.trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('新增库位')
expect(document.body.textContent).toContain('所属仓库')
expect(document.body.textContent).toContain('请选择所属仓库')
})
it('keeps the operation column first and hides edit actions for occupied resources', async () => {
const wrapper = await mountView()
await selectScope(wrapper)
@@ -267,7 +275,7 @@ describe('LocationsView', () => {
expect(document.body.textContent).toContain('当前库位正在被业务使用,只允许查看。')
})
it('clears customer and warehouse on reset and immediately restores the full query', async () => {
it('clears the warehouse on reset and immediately restores the full query', async () => {
const wrapper = await mountView()
await selectScope(wrapper)
const toolbar = wrapper.findComponent(QueryToolbar)
@@ -277,7 +285,6 @@ describe('LocationsView', () => {
await flushPromises()
const model = wrapper.findComponent(QueryToolbar).props('modelValue') as QueryModel
expect(model.customerId).toBe('')
expect(model.warehouseId).toBe('')
expect(listPlatformLocations).toHaveBeenLastCalledWith({
page: 1,
@@ -28,7 +28,6 @@ import { listPlatformWarehouses, type PlatformWarehouseResource } from '../../..
type DrawerMode = 'view' | 'create' | 'edit'
type LocationFilters = QueryModel & {
customerId: string
warehouseId: string
zoneCode: string
locationCode: string
@@ -36,6 +35,7 @@ type LocationFilters = QueryModel & {
status: PlatformLocationStatus | ''
}
type AppliedLocationFilters = {
warehouseId: string
zoneCode: string
locationCode: string
locationType: PlatformLocationType | ''
@@ -46,7 +46,6 @@ const access = useAccessPreviewStore()
const customers = ref<PlatformTenantResource[]>([])
const customerLoading = ref(false)
const customerError = ref<ApiErrorDetails | null>(null)
const loadedCustomerId = ref<string | null>(null)
const warehouses = ref<PlatformWarehouseResource[]>([])
const warehouseLoading = ref(false)
const warehouseError = ref<ApiErrorDetails | null>(null)
@@ -64,39 +63,26 @@ const detail = ref<PlatformLocationDetail | null>(null)
const saving = ref(false)
const actionLoadingIds = ref(new Set<string>())
const formRef = ref<FormInstance>()
let customerLoadSequence = 0
let warehouseLoadSequence = 0
let locationLoadSequence = 0
const filters = ref<LocationFilters>({ customerId: '', warehouseId: '', zoneCode: '', locationCode: '', locationType: '', status: '' })
const appliedFilters = ref<AppliedLocationFilters>({ zoneCode: '', locationCode: '', locationType: '', status: '' })
const selectedCustomerId = computed(() => String(filters.value.customerId || ''))
const filters = ref<LocationFilters>({ warehouseId: '', zoneCode: '', locationCode: '', locationType: '', status: '' })
const appliedFilters = ref<AppliedLocationFilters>({ warehouseId: '', zoneCode: '', locationCode: '', locationType: '', status: '' })
const selectedWarehouseId = computed(() => String(filters.value.warehouseId || ''))
const queryFields = computed<QueryField[]>(() => [
{
key: 'customerId',
type: 'select',
width: 260,
placeholder: customerError.value ? '客户列表加载失败' : '全部客户',
props: { 'aria-label': '所属企业', filterable: true, disabled: customerLoading.value || Boolean(customerError.value) || selectionDisabled.value },
options: customers.value.map((customer) => ({
value: String(customer.id),
label: `${customer.tenantName}(${customer.tenantCode || customer.id})`,
})),
},
{
key: 'warehouseId',
type: 'select',
width: 240,
placeholder: warehouseLoading.value ? '仓库加载中' : '所属仓库',
width: 300,
placeholder: warehouseLoading.value ? '仓库加载中' : '全部仓库',
props: {
'aria-label': '所属仓库',
filterable: true,
disabled: selectionDisabled.value || warehouseLoading.value || !customerReady.value || warehouses.value.length === 0,
disabled: selectionDisabled.value || warehouseLoading.value || Boolean(warehouseError.value),
},
options: warehouses.value.map((warehouse) => ({
value: String(warehouse.id),
label: `${warehouse.warehouseName}(${warehouse.warehouseCode})`,
disabled: warehouse.status !== 'ACTIVE',
label: `${warehouse.warehouseName}(${warehouse.warehouseCode}) · ${customerLabel(warehouse.tenantId)}`,
})),
},
{ key: 'zoneCode', type: 'input', width: 210, placeholder: '库区编码', props: { maxlength: 50, 'aria-label': '库区编码' } },
@@ -123,8 +109,8 @@ const queryFields = computed<QueryField[]>(() => [
const emptyForm = () => ({
id: '',
customerId: selectedCustomerId.value,
warehouseId: selectedWarehouseId.value,
customerId: '',
warehouseId: '',
zoneCode: '',
zoneName: '',
locationCode: '',
@@ -137,6 +123,7 @@ const emptyForm = () => ({
})
const form = reactive(emptyForm())
const rules: FormRules<typeof form> = {
warehouseId: [{ required: true, message: '请选择所属仓库', trigger: 'change' }],
zoneCode: [{ required: true, message: '请输入库区编码', trigger: 'blur' }],
zoneName: [{ required: true, message: '请输入库区名称', trigger: 'blur' }],
locationCode: [
@@ -153,9 +140,7 @@ const rules: FormRules<typeof form> = {
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
}
const selectedWarehouse = computed(() => warehouses.value.find((item) => String(item.id) === selectedWarehouseId.value) ?? null)
const customerReady = computed(() => Boolean(selectedCustomerId.value) && loadedCustomerId.value === selectedCustomerId.value && !warehouseLoading.value)
const createReady = computed(() => customerReady.value && Boolean(selectedWarehouse.value) && !loading.value)
const selectedWarehouse = computed(() => warehouses.value.find((item) => String(item.id) === form.warehouseId) ?? null)
const editorOpen = computed(() => drawerOpen.value && drawerMode.value !== 'view')
const editorTitle = computed(() => drawerMode.value === 'create' ? '新增库位' : '编辑库位')
const drawerTitle = computed(() => drawerMode.value === 'view' ? '库位详情' : editorTitle.value)
@@ -236,11 +221,11 @@ function actionError(error: unknown, fallback: string) {
}
function requireCreateScope() {
if (!createReady.value || !selectedCustomerId.value || !selectedWarehouseId.value) {
ElMessage.warning('所属企业和仓库数据尚未加载完成,请稍后重试')
if (!selectedWarehouse.value || !form.warehouseId) {
ElMessage.warning('请选择所属仓库')
return null
}
return { customerId: apiId(selectedCustomerId.value), warehouseId: apiId(selectedWarehouseId.value) }
return { customerId: apiId(String(selectedWarehouse.value.tenantId)), warehouseId: apiId(form.warehouseId) }
}
function recordScope(row: PlatformLocationResource) {
@@ -266,24 +251,20 @@ async function loadCustomers() {
}
}
async function loadWarehouses(customerId = selectedCustomerId.value) {
if (!customerId) return
const sequence = ++customerLoadSequence
async function loadWarehouses() {
const sequence = ++warehouseLoadSequence
warehouseLoading.value = true
warehouseError.value = null
filters.value = { ...filters.value, warehouseId: '' }
warehouses.value = []
try {
const result = await listPlatformWarehouses(apiId(customerId), 1, 200)
if (sequence !== customerLoadSequence) return
const result = await listPlatformWarehouses(undefined, 1, 200)
if (sequence !== warehouseLoadSequence) return
warehouses.value = result.records
loadedCustomerId.value = customerId
} catch (error) {
if (sequence !== customerLoadSequence) return
loadedCustomerId.value = null
if (sequence !== warehouseLoadSequence) return
warehouseError.value = getApiErrorDetails(error, '仓库列表加载失败,请稍后重试')
} finally {
if (sequence === customerLoadSequence) warehouseLoading.value = false
if (sequence === warehouseLoadSequence) warehouseLoading.value = false
}
}
@@ -300,8 +281,7 @@ async function loadLocations() {
locationType: appliedFilters.value.locationType,
status: appliedFilters.value.status,
}
if (selectedCustomerId.value) query.tenantId = apiId(selectedCustomerId.value)
if (selectedWarehouseId.value) query.warehouseId = apiId(selectedWarehouseId.value)
if (appliedFilters.value.warehouseId) query.warehouseId = apiId(appliedFilters.value.warehouseId)
const result = await listPlatformLocations(query)
if (sequence !== locationLoadSequence) return
rows.value = result.records
@@ -318,6 +298,7 @@ async function loadLocations() {
function applyFilters(model: QueryModel) {
appliedFilters.value = {
warehouseId: String(model.warehouseId || ''),
zoneCode: String(model.zoneCode || ''),
locationCode: String(model.locationCode || ''),
locationType: (model.locationType || '') as PlatformLocationType | '',
@@ -345,10 +326,10 @@ function assignForm(resource: PlatformLocationResource) {
}
function openCreate() {
if (!requireCreateScope()) return
Object.assign(form, emptyForm(), {
status: selectedWarehouse.value?.status === 'ACTIVE' ? 'AVAILABLE' : 'DISABLED',
})
Object.assign(form, emptyForm())
form.warehouseId = selectedWarehouseId.value
form.customerId = selectedWarehouse.value ? String(selectedWarehouse.value.tenantId) : ''
form.status = selectedWarehouse.value?.status === 'INACTIVE' ? 'DISABLED' : 'AVAILABLE'
detail.value = null
drawerError.value = null
drawerMode.value = 'create'
@@ -395,12 +376,12 @@ function buildPayload(): SavePlatformLocationRequest {
}
async function saveLocation() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
const scope = form.id
? { customerId: apiId(form.customerId), warehouseId: apiId(form.warehouseId) }
: requireCreateScope()
if (!scope) return
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
saving.value = true
try {
const payload = buildPayload()
@@ -493,19 +474,16 @@ watch(
)
watch([page, pageSize], () => { void loadLocations() })
watch(selectedCustomerId, (customerId) => {
drawerOpen.value = false
loadedCustomerId.value = null
warehouses.value = []
filters.value = { ...filters.value, warehouseId: '' }
if (customerId) void loadWarehouses(customerId)
})
watch(selectedWarehouseId, () => {
drawerOpen.value = false
watch(() => form.warehouseId, (warehouseId) => {
if (drawerMode.value !== 'create') return
const warehouse = warehouses.value.find((item) => String(item.id) === warehouseId)
form.customerId = warehouse ? String(warehouse.tenantId) : ''
form.status = warehouse?.status === 'INACTIVE' ? 'DISABLED' : 'AVAILABLE'
})
onMounted(() => {
void loadCustomers()
void loadWarehouses()
void loadLocations()
})
</script>
@@ -522,7 +500,6 @@ onMounted(() => {
<el-button
v-if="access.can('platform:location:create')"
type="primary"
:disabled="!createReady"
@click="openCreate"
><Plus :size="16" />新增库位</el-button>
</template>
@@ -540,7 +517,7 @@ onMounted(() => {
<ApiErrorMeta :details="warehouseError" />
<el-button size="small" @click="loadWarehouses()">重新加载仓库</el-button>
</el-alert>
<el-alert v-else-if="customerReady && warehouses.length === 0" type="info" :closable="false" show-icon title="当前所属企业暂无仓库">
<el-alert v-else-if="!warehouseLoading && warehouses.length === 0" type="info" :closable="false" show-icon title="暂无可选仓库">
请先在仓库管理中建立仓库,再维护库位。
</el-alert>
<el-alert v-if="loadError" type="error" :closable="false" show-icon class="state-alert">
@@ -610,8 +587,8 @@ onMounted(() => {
</template>
<template #warehouse="{ row }">
<div class="stacked-value">
<strong>{{ (row as PlatformLocationResource).warehouseName || selectedWarehouse?.warehouseName || '—' }}</strong>
<small>{{ (row as PlatformLocationResource).warehouseCode || selectedWarehouse?.warehouseCode || '—' }}</small>
<strong>{{ (row as PlatformLocationResource).warehouseName || warehouses.find((item) => String(item.id) === String((row as PlatformLocationResource).warehouseId))?.warehouseName || '—' }}</strong>
<small>{{ (row as PlatformLocationResource).warehouseCode || warehouses.find((item) => String(item.id) === String((row as PlatformLocationResource).warehouseId))?.warehouseCode || '—' }}</small>
</div>
</template>
<template #locationType="{ row }">{{ typeLabels[(row as PlatformLocationResource).locationType] }}</template>
@@ -625,7 +602,7 @@ onMounted(() => {
<template #empty>
<el-empty v-if="!loading" :description="emptyDescription">
<el-button
v-if="selectedWarehouseId && !filtered && access.can('platform:location:create')"
v-if="!filtered && access.can('platform:location:create')"
type="primary"
@click="openCreate"
><Plus :size="16" />新增第一个库位</el-button>
@@ -698,10 +675,20 @@ onMounted(() => {
class="drawer-form"
:disabled="saving"
>
<div class="editor-scope">
<div v-if="drawerMode === 'edit'" class="editor-scope">
<MapPin :size="18" />
<div><strong>{{ detail?.warehouseName || selectedWarehouse?.warehouseName }}({{ detail?.warehouseCode || selectedWarehouse?.warehouseCode }})</strong><span>所属企业:{{ customerLabel(form.customerId) }} · 保存后不可跨仓移动</span></div>
</div>
<el-form-item v-if="drawerMode === 'create'" label="所属仓库" prop="warehouseId" required>
<el-select v-model="form.warehouseId" filterable placeholder="请选择所属仓库" :loading="warehouseLoading">
<el-option
v-for="warehouse in warehouses"
:key="String(warehouse.id)"
:label="`${warehouse.warehouseName}(${warehouse.warehouseCode}) · ${customerLabel(warehouse.tenantId)}`"
:value="String(warehouse.id)"
/>
</el-select>
</el-form-item>
<section class="form-section">
<h3>库位标识</h3>
<p class="section-description">库位编码在当前企业和仓库内唯一;编辑后不能移动到其他仓库。</p>