fix: 切换公司时重置组织级页面状态

- 组织级 store 统一提供 resetForOrganization,清空缓存、查询参数、分页与 loading
- 加载方法提交状态前校验捕获的公司 ID 与上下文版本,旧公司延迟响应不再覆盖新公司
- 页面重挂载清理局部状态(物料选择、单据 ID、申请详情、审批弹窗)
- 新增切换守卫,脏表单时取消切换不清空数据、保存期间禁止切换
- 接上库存事件消费与写请求的保存互斥标记
- 仓库配置页在已有仓库/库位时隐藏新增入口,仅保留编辑与启停
This commit is contained in:
文睿 张 2026-10-09 14:19:17 +08:00
parent f6a4fc6cb6
commit 1a4b48cf9a
37 changed files with 1286 additions and 95 deletions

View File

@ -0,0 +1,29 @@
// 页面级“切换公司前的未保存内容确认”复用点。
//
// 设计约定 3:编辑中切换公司要提示“切换公司将放弃未保存内容”,用户取消则保持当前公司。
// 页面把自己的“脏”判定传进来,本组合式函数向组织 store 注册一个守卫:
// 脏 → 弹确认框,确认返回 true(放行切换),取消返回 false(保持当前公司);
// 不脏 → 直接放行。
// 组件卸载时自动撤销守卫,避免守卫指向已卸载页面的状态。
import { onUnmounted } from 'vue'
import { ElMessageBox } from 'element-plus'
import { useOrganizationStore } from '../stores/organization'
export function useOrganizationSwitchGuard(isDirty) {
const organizationStore = useOrganizationStore()
const guard = () => {
if (typeof isDirty !== 'function' || !isDirty()) return true
return ElMessageBox.confirm('切换公司将放弃未保存内容', '提示', {
confirmButtonText: '放弃并切换',
cancelButtonText: '取消',
type: 'warning'
})
.then(() => true)
// 点取消或按 ESC 关掉都会 reject:视为用户取消切换。
.catch(() => false)
}
organizationStore.setOrganizationSwitchGuard(guard)
onUnmounted(() => organizationStore.setOrganizationSwitchGuard(null))
}

View File

@ -1,6 +1,12 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { applicationApi } from '../api/application'
import { useOrganizationStore } from './organization'
import {
captureOrganizationContext,
isCurrentOrganizationContext,
shouldDiscardResponse
} from '../utils/organizationContext'
export const useApplicationStore = defineStore('application', () => {
const myApplications = ref([])
@ -13,35 +19,63 @@ export const useApplicationStore = defineStore('application', () => {
async function fetchMyApplications(status = '', pageNum = 1) {
filters.value = { status }
// 捕获发起时的公司上下文快照:响应回来时用它判断是否已被切换作废。
const snapshot = captureOrganizationContext()
loading.value = true
try {
const res = await applicationApi.getMy(status, pageNum, myPage.value.pageSize)
if (!isCurrentOrganizationContext(snapshot)) return
myApplications.value = res.data?.items || res.data || []
myPage.value.total = res.data?.total || myApplications.value.length
myPage.value.currentPage = pageNum
myPage.value.totalPages = Math.ceil(myPage.value.total / myPage.value.pageSize) || 1
} catch (error) {
if (shouldDiscardResponse(error)) return
throw error
} finally {
loading.value = false
if (isCurrentOrganizationContext(snapshot)) loading.value = false
}
}
async function createApplication(data) {
const res = await applicationApi.create(data)
// 这处刻意清空筛选并回到第 1 页,和上面「沿用筛选」的规矩相反:刚提交的是
// 待审批单,若沿用「已通过」之类的筛选就看不见它,用户会以为没提交成功。
// 清空的是 store 这份状态,页面那份(下拉框)由 Application.vue 同步重置。
await fetchMyApplications('', 1)
return res
const organization = useOrganizationStore()
organization.beginSaving()
try {
const res = await applicationApi.create(data)
// 这处刻意清空筛选并回到第 1 页,和上面「沿用筛选」的规矩相反:刚提交的是
// 待审批单,若沿用「已通过」之类的筛选就看不见它,用户会以为没提交成功。
// 清空的是 store 这份状态,页面那份(下拉框)由 Application.vue 同步重置。
await fetchMyApplications('', 1)
return res
} finally {
organization.endSaving()
}
}
async function cancelApplication(id) {
const res = await applicationApi.cancel(id)
await fetchMyApplications(filters.value.status, myPage.value.currentPage)
return res
const organization = useOrganizationStore()
organization.beginSaving()
try {
const res = await applicationApi.cancel(id)
await fetchMyApplications(filters.value.status, myPage.value.currentPage)
return res
} finally {
organization.endSaving()
}
}
// 公司范围变更时清空共享缓存:我的申请、筛选、分页与加载态。
function resetForOrganization() {
myApplications.value = []
loading.value = false
myPage.value = { currentPage: 1, pageSize: 20, total: 0, totalPages: 0 }
filters.value = { status: '' }
}
useOrganizationStore().registerOrganizationReset(resetForOrganization)
return {
myApplications, loading, myPage,
fetchMyApplications, createApplication, cancelApplication
fetchMyApplications, createApplication, cancelApplication, resetForOrganization
}
})

View File

@ -61,3 +61,30 @@ describe('application store 的筛选保持', () => {
expect(applicationApi.getMy).toHaveBeenCalledWith('', 1, 20)
})
})
describe('application store 的公司切换重置', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
applicationApi.getMy.mockResolvedValue({ data: { items: [], total: 0 } })
})
it('resetForOrganization 清空我的申请、筛选与分页', async () => {
const store = useApplicationStore()
store.myApplications = [{ id: 1 }]
store.myPage = { currentPage: 3, pageSize: 20, total: 60, totalPages: 3 }
store.loading = true
await store.fetchMyApplications('approved', 2)
store.resetForOrganization()
expect(store.myApplications).toEqual([])
expect(store.myPage).toEqual({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 })
expect(store.loading).toBe(false)
// 筛选清空:切换公司后的查询不带旧公司选的筛选值。
applicationApi.getMy.mockClear()
await store.fetchMyApplications()
expect(applicationApi.getMy).toHaveBeenCalledWith('', 1, 20)
})
})

View File

@ -1,6 +1,12 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { applicationApi } from '../api/application'
import { useOrganizationStore } from './organization'
import {
captureOrganizationContext,
isCurrentOrganizationContext,
shouldDiscardResponse
} from '../utils/organizationContext'
export const useApprovalStore = defineStore('approval', () => {
const applications = ref([])
@ -13,32 +19,60 @@ export const useApprovalStore = defineStore('approval', () => {
async function fetchPendingApplications(status = '', pageNum = 1) {
filters.value = { status }
// 捕获发起时的公司上下文快照:响应回来时用它判断是否已被切换作废。
const snapshot = captureOrganizationContext()
loading.value = true
try {
const res = await applicationApi.getAll(status, pageNum, page.value.pageSize)
if (!isCurrentOrganizationContext(snapshot)) return
applications.value = res.data?.items || res.data || []
page.value.total = res.data?.total || applications.value.length
page.value.currentPage = pageNum
page.value.totalPages = Math.ceil(page.value.total / page.value.pageSize) || 1
} catch (error) {
if (shouldDiscardResponse(error)) return
throw error
} finally {
loading.value = false
if (isCurrentOrganizationContext(snapshot)) loading.value = false
}
}
async function approveApplication(id, data = {}) {
const res = await applicationApi.approve(id, data)
await fetchPendingApplications(filters.value.status, page.value.currentPage)
return res
const organization = useOrganizationStore()
organization.beginSaving()
try {
const res = await applicationApi.approve(id, data)
await fetchPendingApplications(filters.value.status, page.value.currentPage)
return res
} finally {
organization.endSaving()
}
}
async function rejectApplication(id, data = {}) {
const res = await applicationApi.reject(id, data)
await fetchPendingApplications(filters.value.status, page.value.currentPage)
return res
const organization = useOrganizationStore()
organization.beginSaving()
try {
const res = await applicationApi.reject(id, data)
await fetchPendingApplications(filters.value.status, page.value.currentPage)
return res
} finally {
organization.endSaving()
}
}
// 公司范围变更时清空共享缓存:待审批列表、筛选、分页与加载态。
function resetForOrganization() {
applications.value = []
loading.value = false
page.value = { currentPage: 1, pageSize: 20, total: 0, totalPages: 0 }
filters.value = { status: '' }
}
useOrganizationStore().registerOrganizationReset(resetForOrganization)
return {
applications, loading, page,
fetchPendingApplications, approveApplication, rejectApplication
fetchPendingApplications, approveApplication, rejectApplication, resetForOrganization
}
})

View File

@ -68,3 +68,29 @@ describe('approval store 的筛选保持', () => {
expect(applicationApi.getAll).toHaveBeenCalledWith('approved', 3, 20)
})
})
describe('approval store 的公司切换重置', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
applicationApi.getAll.mockResolvedValue({ data: { items: [], total: 0 } })
})
it('resetForOrganization 清空待审批列表、筛选与分页', async () => {
const store = useApprovalStore()
store.applications = [{ id: 1 }]
store.page = { currentPage: 3, pageSize: 20, total: 60, totalPages: 3 }
store.loading = true
await store.fetchPendingApplications('pending', 2)
store.resetForOrganization()
expect(store.applications).toEqual([])
expect(store.page).toEqual({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 })
expect(store.loading).toBe(false)
applicationApi.getAll.mockClear()
await store.fetchPendingApplications()
expect(applicationApi.getAll).toHaveBeenCalledWith('', 1, 20)
})
})

View File

@ -2,6 +2,12 @@ import { defineStore } from 'pinia'
import { ref } from 'vue'
import { inboundApi } from '../api/inbound'
import { useStockStore } from './stock'
import { useOrganizationStore } from './organization'
import {
captureOrganizationContext,
isCurrentOrganizationContext,
shouldDiscardResponse
} from '../utils/organizationContext'
export const useInboundStore = defineStore('inbound', () => {
const inboundList = ref([])
@ -9,17 +15,23 @@ export const useInboundStore = defineStore('inbound', () => {
const page = ref({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 })
async function fetchInboundList(pageNum = 1) {
// 捕获发起时的公司上下文快照:响应回来时用它判断是否已被切换作废。
const snapshot = captureOrganizationContext()
loading.value = true
try {
const pageSize = page.value.pageSize
const offset = (pageNum - 1) * pageSize
const res = await inboundApi.getAll({ limit: pageSize, offset })
if (!isCurrentOrganizationContext(snapshot)) return
inboundList.value = res.data?.items || res.data || []
page.value.total = res.data?.total || inboundList.value.length
page.value.currentPage = pageNum
page.value.totalPages = Math.ceil(page.value.total / pageSize) || 1
} catch (error) {
if (shouldDiscardResponse(error)) return
throw error
} finally {
loading.value = false
if (isCurrentOrganizationContext(snapshot)) loading.value = false
}
}
@ -30,24 +42,42 @@ export const useInboundStore = defineStore('inbound', () => {
}
async function createInbound(data) {
const res = await inboundApi.create(data)
invalidateStock()
await fetchInboundList(1)
return res
const organization = useOrganizationStore()
organization.beginSaving()
try {
const res = await inboundApi.create(data)
invalidateStock()
await fetchInboundList(1)
return res
} finally {
organization.endSaving()
}
}
async function updateInbound(id, data) {
const res = await inboundApi.update(id, data)
invalidateStock()
await fetchInboundList(page.value.currentPage)
return res
const organization = useOrganizationStore()
organization.beginSaving()
try {
const res = await inboundApi.update(id, data)
invalidateStock()
await fetchInboundList(page.value.currentPage)
return res
} finally {
organization.endSaving()
}
}
async function deleteInbound(id) {
const res = await inboundApi.delete(id)
invalidateStock()
await fetchInboundList(page.value.currentPage)
return res
const organization = useOrganizationStore()
organization.beginSaving()
try {
const res = await inboundApi.delete(id)
invalidateStock()
await fetchInboundList(page.value.currentPage)
return res
} finally {
organization.endSaving()
}
}
async function fetchInboundDetail(id) {
@ -55,8 +85,18 @@ export const useInboundStore = defineStore('inbound', () => {
return res.data || res
}
// 公司范围变更时清空共享缓存:单据列表、分页与加载态。
function resetForOrganization() {
inboundList.value = []
loading.value = false
page.value = { currentPage: 1, pageSize: 20, total: 0, totalPages: 0 }
}
useOrganizationStore().registerOrganizationReset(resetForOrganization)
return {
inboundList, loading, page,
fetchInboundList, createInbound, updateInbound, deleteInbound, fetchInboundDetail
fetchInboundList, createInbound, updateInbound, deleteInbound, fetchInboundDetail,
resetForOrganization
}
})

View File

@ -42,3 +42,23 @@ describe('inbound store details', () => {
])
})
})
describe('inbound store 的公司切换重置', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('resetForOrganization 清空单据列表、分页与加载态', () => {
const store = useInboundStore()
store.inboundList = [{ id: 1, inbound_no: 'IN-001' }]
store.page = { currentPage: 2, pageSize: 20, total: 40, totalPages: 2 }
store.loading = true
store.resetForOrganization()
expect(store.inboundList).toEqual([])
expect(store.page).toEqual({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 })
expect(store.loading).toBe(false)
})
})

View File

@ -1,6 +1,12 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { logsApi } from '../api/logs'
import {
captureOrganizationContext,
isCurrentOrganizationContext,
shouldDiscardResponse
} from '../utils/organizationContext'
import { useOrganizationStore } from './organization'
export const useLogsStore = defineStore('logs', () => {
const logs = ref([])
@ -9,6 +15,8 @@ export const useLogsStore = defineStore('logs', () => {
const filters = ref({ operationType: '', startDate: '', endDate: '' })
async function fetchLogs(pageNum = 1) {
// 捕获发起时的公司上下文快照:响应回来时用它判断是否已被切换作废。
const snapshot = captureOrganizationContext()
loading.value = true
try {
const pageSize = page.value.pageSize
@ -29,12 +37,17 @@ export const useLogsStore = defineStore('logs', () => {
}
}
const res = await logsApi.getAll(params)
// 公司已切换:丢弃旧公司的日志,绝不覆盖新公司的列表。
if (!isCurrentOrganizationContext(snapshot)) return
logs.value = res.data?.items || res.data || []
page.value.total = res.data?.total || logs.value.length
page.value.currentPage = pageNum
page.value.totalPages = Math.ceil(page.value.total / pageSize) || 1
} catch (error) {
if (shouldDiscardResponse(error)) return
throw error
} finally {
loading.value = false
if (isCurrentOrganizationContext(snapshot)) loading.value = false
}
}
@ -46,8 +59,18 @@ export const useLogsStore = defineStore('logs', () => {
filters.value = { operationType: '', startDate: '', endDate: '' }
}
// 公司范围变更时清空共享缓存:日志、筛选、分页与加载态。
function resetForOrganization() {
logs.value = []
loading.value = false
page.value = { currentPage: 1, pageSize: 20, total: 0, totalPages: 0 }
filters.value = { operationType: '', startDate: '', endDate: '' }
}
useOrganizationStore().registerOrganizationReset(resetForOrganization)
return {
logs, loading, page, filters,
fetchLogs, setFilters, resetFilters
fetchLogs, setFilters, resetFilters, resetForOrganization
}
})

View File

@ -64,3 +64,30 @@ describe('logs store 查询参数', () => {
expect(logsApi.getAll).toHaveBeenCalledWith({ limit: 20, offset: 0 })
})
})
describe('logs store 的公司切换重置', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
logsApi.getAll.mockResolvedValue({ data: { items: [], total: 0 } })
})
it('resetForOrganization 清空日志、筛选与分页', async () => {
const store = useLogsStore()
store.logs = [{ id: 1 }]
store.page = { currentPage: 3, pageSize: 20, total: 60, totalPages: 3 }
store.loading = true
store.setFilters({ operationType: 'CREATE', materialId: 7 })
store.resetForOrganization()
expect(store.logs).toEqual([])
expect(store.page).toEqual({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 })
expect(store.loading).toBe(false)
// 筛选一并清空:切换公司后的查询不带旧公司选的筛选项。
logsApi.getAll.mockClear()
await store.fetchLogs()
expect(logsApi.getAll).toHaveBeenCalledWith({ limit: 20, offset: 0 })
})
})

View File

@ -1,6 +1,12 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { materialsApi } from '../api/materials'
import {
captureOrganizationContext,
isCurrentOrganizationContext,
shouldDiscardResponse
} from '../utils/organizationContext'
import { useOrganizationStore } from './organization'
export const useMaterialsStore = defineStore('materials', () => {
const materials = ref([])
@ -36,22 +42,32 @@ export const useMaterialsStore = defineStore('materials', () => {
async function fetchMaterials(page = 1, search = '', status = 'all') {
filters.value = { search, status }
// 捕获发起时的公司上下文快照:响应回来时用它判断是否已被切换作废。
const snapshot = captureOrganizationContext()
loading.value = true
try {
const pageSize = pagination.value.pageSize
const offset = (page - 1) * pageSize
const res = await materialsApi.getAll({ search, status, limit: pageSize, offset })
// 公司已切换:丢弃旧公司的响应,绝不覆盖新公司的列表。
if (!isCurrentOrganizationContext(snapshot)) return
materials.value = res.data?.items || res.data || []
pagination.value.total = res.data?.total || materials.value.length
pagination.value.currentPage = page
pagination.value.totalPages = Math.ceil(pagination.value.total / pageSize) || 1
} catch (error) {
// 切换公司导致的作废/取消不是失败:静默丢弃,不显示为失败。
if (shouldDiscardResponse(error)) return
throw error
} finally {
loading.value = false
// 旧请求作废时不清理 loading,避免覆盖新公司请求正在展示的加载态。
if (isCurrentOrganizationContext(snapshot)) loading.value = false
}
}
// 接口单页上限是 100,循环翻页把符合条件的物料全部取回来。
async function fetchOptionsByStatus(status) {
const snapshot = captureOrganizationContext()
const pageSize = 100
let offset = 0
let total = 0
@ -59,6 +75,8 @@ export const useMaterialsStore = defineStore('materials', () => {
do {
const res = await materialsApi.getAll({ status, limit: pageSize, offset })
// 公司已切换:返回空数组,绝不让半份旧公司选项被提交到新公司。
if (!isCurrentOrganizationContext(snapshot)) return []
const items = res.data?.items || res.data || []
options.push(...items)
total = res.data?.total ?? options.length
@ -69,51 +87,96 @@ export const useMaterialsStore = defineStore('materials', () => {
}
async function fetchMaterialOptions() {
materialOptions.value = await fetchOptionsByStatus('active')
const snapshot = captureOrganizationContext()
const options = await fetchOptionsByStatus('active')
if (!isCurrentOrganizationContext(snapshot)) return
materialOptions.value = options
}
async function fetchHistoricalMaterialOptions() {
historicalMaterialOptions.value = await fetchOptionsByStatus('all')
const snapshot = captureOrganizationContext()
const options = await fetchOptionsByStatus('all')
if (!isCurrentOrganizationContext(snapshot)) return
historicalMaterialOptions.value = options
}
async function refreshMaterialOptions() {
await Promise.all([fetchMaterialOptions(), fetchHistoricalMaterialOptions()])
}
// 公司范围变更时清空共享缓存:列表、两份选项、筛选、分页与加载态。
function resetForOrganization() {
materials.value = []
materialOptions.value = []
historicalMaterialOptions.value = []
loading.value = false
pagination.value = { currentPage: 1, pageSize: 20, total: 0, totalPages: 0 }
filters.value = { search: '', status: 'all' }
}
// 把本 store 的清理注册给组织 store:切换公司/登出时由它统一调用。
useOrganizationStore().registerOrganizationReset(resetForOrganization)
async function createMaterial(data) {
const res = await materialsApi.create(data)
// 这处刻意清空筛选并回到第 1 页,和下面「沿用筛选」的规矩相反:新建的物料不一定
// 匹配当前关键字,沿用搜索就看不见它,用户会以为没建成功。列表按 id 倒序,回到
// 第 1 页正好能看见它。清空的是 store 这份状态,页面那份(搜索框)由 Materials.vue
// 同步重置。
await fetchMaterials(1, '', 'all')
return res
const organization = useOrganizationStore()
// 写请求飞行中把组织上下文标记为“忙”,禁止切换公司,避免归属不明。
organization.beginSaving()
try {
const res = await materialsApi.create(data)
// 这处刻意清空筛选并回到第 1 页,和下面「沿用筛选」的规矩相反:新建的物料不一定
// 匹配当前关键字,沿用搜索就看不见它,用户会以为没建成功。列表按 id 倒序,回到
// 第 1 页正好能看见它。清空的是 store 这份状态,页面那份(搜索框)由 Materials.vue
// 同步重置。
await fetchMaterials(1, '', 'all')
return res
} finally {
organization.endSaving()
}
}
async function updateMaterial(id, data) {
const res = await materialsApi.update(id, data)
await fetchMaterials(pagination.value.currentPage, filters.value.search, filters.value.status)
return res
const organization = useOrganizationStore()
organization.beginSaving()
try {
const res = await materialsApi.update(id, data)
await fetchMaterials(pagination.value.currentPage, filters.value.search, filters.value.status)
return res
} finally {
organization.endSaving()
}
}
async function obsoleteMaterial(id) {
const res = await materialsApi.obsolete(id)
await fetchMaterials(pagination.value.currentPage, filters.value.search, filters.value.status)
// 作废会改变物料在「有效」和「全部」两份选项里的归属,两份都要刷新
await refreshMaterialOptions()
return res
const organization = useOrganizationStore()
organization.beginSaving()
try {
const res = await materialsApi.obsolete(id)
await fetchMaterials(pagination.value.currentPage, filters.value.search, filters.value.status)
// 作废会改变物料在「有效」和「全部」两份选项里的归属,两份都要刷新
await refreshMaterialOptions()
return res
} finally {
organization.endSaving()
}
}
async function restoreMaterial(id) {
const res = await materialsApi.restore(id)
await fetchMaterials(pagination.value.currentPage, filters.value.search, filters.value.status)
await refreshMaterialOptions()
return res
const organization = useOrganizationStore()
organization.beginSaving()
try {
const res = await materialsApi.restore(id)
await fetchMaterials(pagination.value.currentPage, filters.value.search, filters.value.status)
await refreshMaterialOptions()
return res
} finally {
organization.endSaving()
}
}
return {
materials, materialOptions, historicalMaterialOptions, loading, pagination, categoryMap,
getCategoryDisplay, fetchMaterials, fetchMaterialOptions, fetchHistoricalMaterialOptions,
resetForOrganization,
createMaterial, updateMaterial, obsoleteMaterial, restoreMaterial
}
})

View File

@ -2,6 +2,7 @@ import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { materialsApi } from '../api/materials'
import { useMaterialsStore } from './materials'
import { commitOrganizationId, resetOrganizationContext } from '../utils/organizationContext'
vi.mock('../api/materials', () => ({
materialsApi: {
@ -195,3 +196,91 @@ describe('materials store 的筛选保持', () => {
})
})
})
describe('materials store 的公司切换重置与竞态', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
resetOrganizationContext()
commitOrganizationId(1)
materialsApi.getAll.mockResolvedValue({ data: { items: [], total: 0 } })
})
// resetForOrganization 清空共享缓存:列表、两份选项、分页与加载态。
it('resetForOrganization 清空列表、选项与分页', async () => {
const store = useMaterialsStore()
store.materials = [{ id: 1, material_code: 'A-001' }]
store.materialOptions = [{ id: 1 }]
store.historicalMaterialOptions = [{ id: 2 }]
store.pagination = { currentPage: 3, pageSize: 50, total: 120, totalPages: 3 }
store.loading = true
store.resetForOrganization()
expect(store.materials).toEqual([])
expect(store.materialOptions).toEqual([])
expect(store.historicalMaterialOptions).toEqual([])
expect(store.pagination).toEqual({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 })
expect(store.loading).toBe(false)
})
// A 公司的延迟响应在切到 B 之后返回:用捕获的 {organizationId, version} 判定作废,
// 绝不把 A 的列表覆盖到 B 上。
it('A→B 时 A 的延迟响应不覆盖 B', async () => {
let resolveA
materialsApi.getAll.mockImplementationOnce(
() => new Promise(resolve => { resolveA = resolve })
)
const store = useMaterialsStore()
const pendingA = store.fetchMaterials(1, 'A')
// 切到 B:公司 ID 变为 2,版本递增。
commitOrganizationId(2)
resolveA({ data: { items: [{ id: 99, material_code: 'A-OLD' }], total: 1 } })
await pendingA
expect(store.materials).toEqual([])
})
// A→B→A 时仅比较公司 ID 不够:第一次 A 的响应版本已过期,仍要丢弃。
it('A→B→A 时第一次 A 的延迟响应仍然丢弃', async () => {
let resolveA
materialsApi.getAll.mockImplementationOnce(
() => new Promise(resolve => { resolveA = resolve })
)
const store = useMaterialsStore()
const pendingA = store.fetchMaterials(1, 'first')
commitOrganizationId(2)
commitOrganizationId(1) // 又切回 A
resolveA({ data: { items: [{ id: 88, material_code: 'STALE' }], total: 1 } })
await pendingA
expect(store.materials).toEqual([])
})
// 旧请求的 finally 不得清掉新请求正在展示的 loading。
it('旧请求返回时若新请求仍在飞行,不覆盖新请求的 loading', async () => {
let resolveA
let resolveB
materialsApi.getAll
.mockImplementationOnce(() => new Promise(resolve => { resolveA = resolve }))
.mockImplementationOnce(() => new Promise(resolve => { resolveB = resolve }))
const store = useMaterialsStore()
const pendingA = store.fetchMaterials(1, 'A')
commitOrganizationId(2)
const pendingB = store.fetchMaterials(1, 'B')
resolveA({ data: { items: [], total: 0 } })
await pendingA
// B 仍在飞行:loading 必须保持 true(A 的 finally 不该清掉它)
expect(store.loading).toBe(true)
resolveB({ data: { items: [], total: 0 } })
await pendingB
expect(store.loading).toBe(false)
})
})

View File

@ -64,6 +64,32 @@ export const useOrganizationStore = defineStore('organization', () => {
const saving = ref(false)
// 任何会导致“公司上下文变更或归属不明”的操作进行中都算忙:切换中或保存中。
const busy = computed(() => switching.value || saving.value)
// 组织范围变更(切换公司、进入空状态、登出)时,各业务 store 注册的清理回调统一在此调用。
// 业务 store 在自己的 setup 里 registerOrganizationReset(resetForOrganization),
// 由组织 store 充当协调者,避免“页面各自记得手动清理”造成遗漏,也避免业务 store 反向导入组织 store 成环。
const organizationResetters = new Set()
function registerOrganizationReset(resetter) {
if (typeof resetter !== 'function') return () => {}
organizationResetters.add(resetter)
return () => organizationResetters.delete(resetter)
}
// 复制一份再遍历:回调内部若再注册/注销也不会破坏本次遍历。
function resetOrganizationScopedStores() {
for (const resetter of [...organizationResetters]) {
resetter()
}
}
// 切换公司前的“未保存内容”确认钩子:由当前页面注册,返回 false 表示用户取消切换。
// 未注册时行为与原来完全一致(直接切换),不影响已有语义。
let switchGuard = null
function setOrganizationSwitchGuard(guard) {
switchGuard = typeof guard === 'function' ? guard : null
}
// 是否已完成一次公司列表加载,路由屏障据此决定是否需要先初始化。
const initialized = ref(false)
// 最近一次 initialize 是否因临时故障失败:用于把“加载失败”与“确实没有可用公司”区分开,
@ -99,6 +125,8 @@ export const useOrganizationStore = defineStore('organization', () => {
applyWarehouseContext(data?.warehouse || null)
syncVersion()
if (organizationId) writeStoredOrganizationId(organizationId)
// 公司已确定:先清空各业务 store 的共享缓存(列表/详情/筛选/分页),页面随后重新加载。
resetOrganizationScopedStores()
// 当前公司已确定:关闭旧公司连接并为新公司取得票据重连。connect 不阻塞、不抛错。
organizationSocket.connect()
}
@ -110,6 +138,8 @@ export const useOrganizationStore = defineStore('organization', () => {
currentOrganizationId.value = null
applyWarehouseContext(null)
syncVersion()
// 空状态同样清空业务缓存,避免没有可用公司时页面还留着上一个公司(或上一标签页)的数据。
resetOrganizationScopedStores()
// 无可用公司:不建立实时连接。
organizationSocket.close()
}
@ -182,8 +212,14 @@ export const useOrganizationStore = defineStore('organization', () => {
// 保存飞行中切换会让保存的响应被版本判定作废并被静默吞掉,用户既看不到成功也看不到失败。
if (busy.value) return
if (!organizationId || organizationId === currentOrganizationId.value) return
// 切换前先进入“切换中”,既覆盖确认框停留期间的重入,也让切换器在确认期间保持不可操作。
switching.value = true
try {
// 未保存内容确认:页面注册的守卫返回 false 表示用户取消,保持当前公司不变。
if (switchGuard) {
const allowed = await switchGuard(organizationId)
if (!allowed) return
}
const res = await authApi.switchOrganizationContext(
organizationId,
currentOrganizationId.value ?? 0
@ -209,6 +245,9 @@ export const useOrganizationStore = defineStore('organization', () => {
saving.value = false
initialized.value = false
loadError.value = false
// 账号变化也不能把上一个账号(或上一个公司)的业务缓存留给下个会话。
resetOrganizationScopedStores()
switchGuard = null
syncVersion()
}
@ -216,6 +255,7 @@ export const useOrganizationStore = defineStore('organization', () => {
organizations, currentOrganizationId, currentWarehouseId, currentLocationId,
currentWarehouse, contextVersion, switching, saving, busy, initialized, loadError,
beginSaving, endSaving,
registerOrganizationReset, setOrganizationSwitchGuard,
initialize, switchOrganization, reset
}
})

View File

@ -370,3 +370,83 @@ describe('公司级实时连接接线', () => {
expect(socketMock.close).toHaveBeenCalled()
})
})
// 组织范围变更时的业务 store 清理注册表,以及切换前的“未保存内容”确认守卫。
describe('公司范围变更的清理注册与切换守卫', () => {
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
resetOrganizationContext()
setActivePinia(createPinia())
authApi.listOrganizations.mockReset()
authApi.switchOrganizationContext.mockReset()
socketMock.connect.mockReset()
socketMock.close.mockReset()
})
async function enterCompany1(store) {
loginAs(1)
authApi.listOrganizations.mockResolvedValue(orgList([{ id: 1 }, { id: 2 }], 1))
authApi.switchOrganizationContext.mockResolvedValueOnce(switchResult(1))
await store.initialize()
}
test('切换公司时调用注册的重置回调', async () => {
const store = useOrganizationStore()
await enterCompany1(store)
const reset = vi.fn()
store.registerOrganizationReset(reset)
authApi.switchOrganizationContext.mockResolvedValueOnce(switchResult(2))
await store.switchOrganization(2)
expect(reset).toHaveBeenCalledTimes(1)
expect(store.currentOrganizationId).toBe(2)
})
test('登出(reset)时也调用注册的重置回调', async () => {
const store = useOrganizationStore()
await enterCompany1(store)
const reset = vi.fn()
store.registerOrganizationReset(reset)
store.reset()
expect(reset).toHaveBeenCalledTimes(1)
})
test('守卫返回 false 时取消切换,保持当前公司且不发起校验', async () => {
const store = useOrganizationStore()
await enterCompany1(store)
authApi.switchOrganizationContext.mockClear()
store.setOrganizationSwitchGuard(async () => false)
await store.switchOrganization(2)
expect(store.currentOrganizationId).toBe(1)
expect(authApi.switchOrganizationContext).not.toHaveBeenCalled()
// 取消后不能把“切换中”标志卡住
expect(store.switching).toBe(false)
})
test('守卫返回 true 时按原流程切换', async () => {
const store = useOrganizationStore()
await enterCompany1(store)
store.setOrganizationSwitchGuard(async () => true)
authApi.switchOrganizationContext.mockResolvedValueOnce(switchResult(2))
await store.switchOrganization(2)
expect(store.currentOrganizationId).toBe(2)
})
test('没有注册守卫时行为不变,直接切换', async () => {
const store = useOrganizationStore()
await enterCompany1(store)
authApi.switchOrganizationContext.mockResolvedValueOnce(switchResult(2))
await store.switchOrganization(2)
expect(store.currentOrganizationId).toBe(2)
})
})

View File

@ -2,6 +2,12 @@ import { defineStore } from 'pinia'
import { ref } from 'vue'
import { outboundApi } from '../api/outbound'
import { useStockStore } from './stock'
import { useOrganizationStore } from './organization'
import {
captureOrganizationContext,
isCurrentOrganizationContext,
shouldDiscardResponse
} from '../utils/organizationContext'
export const useOutboundStore = defineStore('outbound', () => {
const outboundList = ref([])
@ -9,17 +15,23 @@ export const useOutboundStore = defineStore('outbound', () => {
const page = ref({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 })
async function fetchOutboundList(pageNum = 1) {
// 捕获发起时的公司上下文快照:响应回来时用它判断是否已被切换作废。
const snapshot = captureOrganizationContext()
loading.value = true
try {
const pageSize = page.value.pageSize
const offset = (pageNum - 1) * pageSize
const res = await outboundApi.getAll({ limit: pageSize, offset })
if (!isCurrentOrganizationContext(snapshot)) return
outboundList.value = res.data?.items || res.data || []
page.value.total = res.data?.total || outboundList.value.length
page.value.currentPage = pageNum
page.value.totalPages = Math.ceil(page.value.total / pageSize) || 1
} catch (error) {
if (shouldDiscardResponse(error)) return
throw error
} finally {
loading.value = false
if (isCurrentOrganizationContext(snapshot)) loading.value = false
}
}
@ -30,24 +42,42 @@ export const useOutboundStore = defineStore('outbound', () => {
}
async function createOutbound(data) {
const res = await outboundApi.create(data)
invalidateStock()
await fetchOutboundList(1)
return res
const organization = useOrganizationStore()
organization.beginSaving()
try {
const res = await outboundApi.create(data)
invalidateStock()
await fetchOutboundList(1)
return res
} finally {
organization.endSaving()
}
}
async function updateOutbound(id, data) {
const res = await outboundApi.update(id, data)
invalidateStock()
await fetchOutboundList(page.value.currentPage)
return res
const organization = useOrganizationStore()
organization.beginSaving()
try {
const res = await outboundApi.update(id, data)
invalidateStock()
await fetchOutboundList(page.value.currentPage)
return res
} finally {
organization.endSaving()
}
}
async function deleteOutbound(id) {
const res = await outboundApi.delete(id)
invalidateStock()
await fetchOutboundList(page.value.currentPage)
return res
const organization = useOrganizationStore()
organization.beginSaving()
try {
const res = await outboundApi.delete(id)
invalidateStock()
await fetchOutboundList(page.value.currentPage)
return res
} finally {
organization.endSaving()
}
}
async function fetchOutboundDetail(id) {
@ -55,8 +85,18 @@ export const useOutboundStore = defineStore('outbound', () => {
return res.data || res
}
// 公司范围变更时清空共享缓存:单据列表、分页与加载态。
function resetForOrganization() {
outboundList.value = []
loading.value = false
page.value = { currentPage: 1, pageSize: 20, total: 0, totalPages: 0 }
}
useOrganizationStore().registerOrganizationReset(resetForOrganization)
return {
outboundList, loading, page,
fetchOutboundList, createOutbound, updateOutbound, deleteOutbound, fetchOutboundDetail
fetchOutboundList, createOutbound, updateOutbound, deleteOutbound, fetchOutboundDetail,
resetForOrganization
}
})

View File

@ -1,6 +1,12 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { stockApi } from '../api/stock'
import {
captureOrganizationContext,
isCurrentOrganizationContext,
shouldDiscardResponse
} from '../utils/organizationContext'
import { useOrganizationStore } from './organization'
export const useStockStore = defineStore('stock', () => {
const stockList = ref([])
@ -20,27 +26,39 @@ export const useStockStore = defineStore('stock', () => {
async function fetchStock(page = 1, search = '', category = '') {
stockFilters.value = { search, category }
// 捕获发起时的公司上下文快照:响应回来时用它判断是否已被切换作废。
const snapshot = captureOrganizationContext()
loading.value = true
try {
const pageSize = stockPage.value.pageSize
const offset = (page - 1) * pageSize
const res = await stockApi.getAll({ limit: pageSize, offset, search, category })
// 公司已切换:丢弃旧公司的库存,绝不覆盖新公司的列表。
if (!isCurrentOrganizationContext(snapshot)) return
stockList.value = res.data?.items || res.data || []
stockPage.value.total = res.data?.total || 0
stockPage.value.currentPage = page
} catch (error) {
if (shouldDiscardResponse(error)) return
throw error
} finally {
loading.value = false
if (isCurrentOrganizationContext(snapshot)) loading.value = false
}
}
async function fetchAllStock() {
const snapshot = captureOrganizationContext()
loading.value = true
try {
const res = await stockApi.getAll({ limit: 1000, offset: 0 })
if (!isCurrentOrganizationContext(snapshot)) return
stockList.value = res.data?.items || res.data || []
stockPage.value.total = res.data?.total || 0
} catch (error) {
if (shouldDiscardResponse(error)) return
throw error
} finally {
loading.value = false
if (isCurrentOrganizationContext(snapshot)) loading.value = false
}
}
@ -50,8 +68,11 @@ export const useStockStore = defineStore('stock', () => {
if (stockCache.value[materialId] !== undefined) {
return stockCache.value[materialId]
}
const snapshot = captureOrganizationContext()
try {
const res = await stockApi.getById(materialId)
// 公司已切换:不把旧公司的库存写进新公司的缓存。
if (!isCurrentOrganizationContext(snapshot)) return null
const detail = res.data ?? null
stockCache.value[materialId] = detail
return detail
@ -76,20 +97,26 @@ export const useStockStore = defineStore('stock', () => {
}
async function fetchStockWarning(page = 1) {
const snapshot = captureOrganizationContext()
loading.value = true
try {
const pageSize = warningPage.value.pageSize
const offset = (page - 1) * pageSize
const res = await stockApi.getWarning({ limit: pageSize, offset })
if (!isCurrentOrganizationContext(snapshot)) return
warningList.value = res.data?.items || res.data || []
warningPage.value.total = res.data?.total || 0
warningPage.value.currentPage = page
} catch (error) {
if (shouldDiscardResponse(error)) return
throw error
} finally {
loading.value = false
if (isCurrentOrganizationContext(snapshot)) loading.value = false
}
}
async function fetchStockLogs(materialId, page = 1) {
const snapshot = captureOrganizationContext()
loading.value = true
try {
const pageSize = logsPage.value.pageSize
@ -97,29 +124,65 @@ export const useStockStore = defineStore('stock', () => {
const res = materialId
? await stockApi.getLogs(materialId, { limit: pageSize, offset })
: await stockApi.getAllLogs({ limit: pageSize, offset })
if (!isCurrentOrganizationContext(snapshot)) return
logs.value = res.data?.items || res.data || []
logsPage.value.total = res.data?.total || 0
logsPage.value.currentPage = page
} catch (error) {
if (shouldDiscardResponse(error)) return
throw error
} finally {
loading.value = false
if (isCurrentOrganizationContext(snapshot)) loading.value = false
}
}
async function updateWarningStock(materialId, warningStock, enabled = true) {
await stockApi.updateWarning(materialId, warningStock, enabled)
// 阈值改了,单物料缓存里的旧值必须作废,否则入库表单会预填到过期数据。
delete stockCache.value[materialId]
const organization = useOrganizationStore()
// 写请求飞行中把组织上下文标记为“忙”,禁止切换公司,避免归属不明。
organization.beginSaving()
try {
await stockApi.updateWarning(materialId, warningStock, enabled)
// 阈值改了,单物料缓存里的旧值必须作废,否则入库表单会预填到过期数据。
delete stockCache.value[materialId]
await Promise.all([
fetchStock(stockPage.value.currentPage, stockFilters.value.search, stockFilters.value.category),
fetchStockWarning(warningPage.value.currentPage)
])
} finally {
organization.endSaving()
}
}
// 实时库存事件到达时由 Stock 页面调用:沿用当前筛选与页码刷新列表与预警,
// 不退回全量,也不把用户甩回第一页。
async function refreshStock() {
await Promise.all([
fetchStock(stockPage.value.currentPage, stockFilters.value.search, stockFilters.value.category),
fetchStockWarning(warningPage.value.currentPage)
])
}
// 公司范围变更时清空共享缓存:库存、预警、流水、单物料缓存、筛选、分页与加载态。
function resetForOrganization() {
stockList.value = []
warningList.value = []
logs.value = []
loading.value = false
stockPage.value = { currentPage: 1, pageSize: 20, total: 0, totalPages: 0 }
warningPage.value = { currentPage: 1, pageSize: 20, total: 0, totalPages: 0 }
logsPage.value = { currentPage: 1, pageSize: 20, total: 0, totalPages: 0 }
stockFilters.value = { search: '', category: '' }
stockCache.value = {}
}
useOrganizationStore().registerOrganizationReset(resetForOrganization)
return {
stockList, warningList, logs, loading,
stockPage, warningPage, logsPage,
stockPage, warningPage, logsPage, stockFilters,
fetchStock, fetchAllStock, fetchStockByMaterialId, fetchStockDetailByMaterialId,
fetchStockWarning, fetchStockLogs, clearStockCache,
fetchStockWarning, fetchStockLogs, clearStockCache, refreshStock,
resetForOrganization,
updateWarningStock
}
})

View File

@ -123,3 +123,41 @@ describe('stock store warning settings', () => {
expect(await stockStore.fetchStockByMaterialId(7)).toBe('500')
})
})
describe('stock store 的公司切换重置', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
stockApi.getAll.mockResolvedValue({ data: { items: [], total: 0 } })
stockApi.getWarning.mockResolvedValue({ data: { items: [], total: 0 } })
})
it('resetForOrganization 清空库存、预警、流水、筛选、分页与单物料缓存', async () => {
const store = useStockStore()
store.stockList = [{ material_id: 1 }]
store.warningList = [{ material_id: 2 }]
store.logs = [{ id: 3 }]
store.stockPage = { currentPage: 2, pageSize: 20, total: 40, totalPages: 2 }
store.warningPage = { currentPage: 2, pageSize: 20, total: 40, totalPages: 2 }
store.logsPage = { currentPage: 2, pageSize: 20, total: 40, totalPages: 2 }
store.stockFilters = { search: '轴承', category: 'A01' }
store.loading = true
stockApi.getById.mockResolvedValue({ data: { material_id: 7, total_on_hand: '5' } })
await store.fetchStockDetailByMaterialId(7)
stockApi.getById.mockClear()
store.resetForOrganization()
expect(store.stockList).toEqual([])
expect(store.warningList).toEqual([])
expect(store.logs).toEqual([])
expect(store.stockPage).toEqual({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 })
expect(store.stockFilters).toEqual({ search: '', category: '' })
expect(store.loading).toBe(false)
// 单物料缓存也被清空:再次查询必须重新请求,而不是命中旧公司的缓存。
await store.fetchStockDetailByMaterialId(7)
expect(stockApi.getById).toHaveBeenCalledTimes(1)
})
})

View File

@ -1,5 +1,6 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useOrganizationStore } from './organization'
export const useUiStore = defineStore('ui', () => {
const loading = ref(false)
@ -15,8 +16,17 @@ export const useUiStore = defineStore('ui', () => {
sidebarCollapsed.value = !sidebarCollapsed.value
}
// 公司范围变更时清掉可能卡住的全局 loading 遮罩,避免旧公司某次请求留下的遮罩
// 挡在新公司页面上。侧栏折叠是用户的界面偏好,不属于公司数据,保持不变。
function resetForOrganization() {
loading.value = false
loadingText.value = ''
}
useOrganizationStore().registerOrganizationReset(resetForOrganization)
return {
loading, loadingText, sidebarCollapsed,
setLoading, toggleSidebar
setLoading, toggleSidebar, resetForOrganization
}
})

View File

@ -55,6 +55,18 @@ export function isOrganizationContextSuperseded(error) {
return error?.name === 'OrganizationContextSupersededError'
}
// 切换公司时共享 AbortController 会取消旧公司在途的读请求,axios 抛出 CanceledError。
// 这类取消不是失败:store 的加载方法据此静默吞掉,不向上抛出、也不显示为失败。
export function isCancelledRequest(error) {
return error?.code === 'ERR_CANCELED' || error?.name === 'CanceledError'
}
// 加载方法统一用来判断“这个错误是否属于切换公司造成的作废/取消”:是则静默丢弃,
// 既不提交状态也不当作失败向上抛。合并两个判定,避免每个 store 各写一遍。
export function shouldDiscardResponse(error) {
return isOrganizationContextSuperseded(error) || isCancelledRequest(error)
}
// 登出或切换账号时清空上下文,避免下游把旧公司的 ID 继续发出去。
export function resetOrganizationContext() {
abortReadRequests()

View File

@ -25,6 +25,10 @@ const stockStore = vi.hoisted(() => ({
vi.mock('../stores/application', () => ({ useApplicationStore: () => applicationStore }))
vi.mock('../stores/materials', () => ({ useMaterialsStore: () => materialsStore }))
vi.mock('../stores/stock', () => ({ useStockStore: () => stockStore }))
// 切换公司的未保存确认由组织 store 承载;这里只关心页面本身,替身掉守卫注册。
vi.mock('../composables/useOrganizationSwitchGuard', () => ({
useOrganizationSwitchGuard: vi.fn()
}))
// 真实的 ElSelect 在 jsdom 下会因状态双向绑定反复触发自身更新而报
// "Maximum recursive updates exceeded"。这里用替身直接派发 change,

View File

@ -110,6 +110,7 @@ import { useMaterialsStore } from '../stores/materials'
import { useStockStore } from '../stores/stock'
import BaseTable from '../components/business/BaseTable.vue'
import StatusBadge from '../components/business/StatusBadge.vue'
import { useOrganizationSwitchGuard } from '../composables/useOrganizationSwitchGuard'
import { ElMessage, ElMessageBox } from 'element-plus'
import { searchMaterialOptions } from '../utils/materialSearch'
import { formatDate } from '../utils/datetime'
@ -127,6 +128,11 @@ const applyForm = reactive({
items: [{ material_id: null, materialSearchText: '', quantity: 1 }]
})
// 申请表单填了用途或选了物料就是未保存内容,切换公司会丢弃,先确认。
useOrganizationSwitchGuard(
() => applyForm.purpose.trim() !== '' || applyForm.items.some(item => !!item.material_id)
)
// Per-item stock cache (item -> stock count)
const itemStockMap = reactive(new Map())

View File

@ -13,6 +13,10 @@ const approvalStore = vi.hoisted(() => ({
}))
vi.mock('../stores/approval', () => ({ useApprovalStore: () => approvalStore }))
// 切换公司的未保存确认由组织 store 承载;这里只关心页面本身,替身掉守卫注册。
vi.mock('../composables/useOrganizationSwitchGuard', () => ({
useOrganizationSwitchGuard: vi.fn()
}))
function findButton(wrapper, text) {
return wrapper.findAll('button').find(button => button.text().includes(text))

View File

@ -88,6 +88,7 @@ import { ref, reactive, onMounted } from 'vue'
import { useApprovalStore } from '../stores/approval'
import BaseTable from '../components/business/BaseTable.vue'
import StatusBadge from '../components/business/StatusBadge.vue'
import { useOrganizationSwitchGuard } from '../composables/useOrganizationSwitchGuard'
import { ElMessage } from 'element-plus'
import { formatDate } from '../utils/datetime'
@ -98,6 +99,9 @@ const approveDialogVisible = ref(false)
const rejectDialogVisible = ref(false)
const currentApplication = ref(null)
// 审批/拒绝弹窗开着时切换公司会丢掉正在填写的内容,先确认。
useOrganizationSwitchGuard(() => approveDialogVisible.value || rejectDialogVisible.value)
// 审批默认勾选自动出库:通过即扣减库存并生成出库单,省去再去出库页手工建单。
// 初始值和每次打开弹窗时的重置都引用它,避免默认值改动时只改一处。
const AUTO_OUTBOUND_DEFAULT = true

View File

@ -29,6 +29,10 @@ vi.mock('../stores/inbound', () => ({ useInboundStore: () => inboundStore }))
vi.mock('../stores/materials', () => ({ useMaterialsStore: () => materialsStore }))
vi.mock('../stores/stock', () => ({ useStockStore: () => stockStore }))
vi.mock('../stores/auth', () => ({ useAuthStore: () => ({ isAdmin: true }) }))
// 切换公司的未保存确认由组织 store 承载;这里只关心页面本身,替身掉守卫注册。
vi.mock('../composables/useOrganizationSwitchGuard', () => ({
useOrganizationSwitchGuard: vi.fn()
}))
// 真实的 ElSelect 在 jsdom 下会因 filter-method 反复触发自身更新而报
// "Maximum recursive updates exceeded",这里只关心表单校验,用轻量替身绕开。

View File

@ -138,6 +138,7 @@ import { useAuthStore } from '../stores/auth'
import BaseTable from '../components/business/BaseTable.vue'
import StatusBadge from '../components/business/StatusBadge.vue'
import DeleteConfirm from '../components/business/DeleteConfirm.vue'
import { useOrganizationSwitchGuard } from '../composables/useOrganizationSwitchGuard'
import { ElMessage } from 'element-plus'
import { errorText } from '../utils/errorText'
import { formatDate } from '../utils/datetime'
@ -156,6 +157,9 @@ const editingId = ref(null)
const deleteId = ref(null)
const currentRow = ref(null)
// 单据弹窗开着时切换公司会丢弃未保存内容,先确认。
useOrganizationSwitchGuard(() => dialogVisible.value)
// 明细行的初始结构。预警阈值和启用状态分别放在 warning_stock / warning_enabled 上,
// 并共用一个"用户是否改过"的标记:只有真正被改动过的明细才会把预警字段提交给后端,
// 避免编辑旧入库单时把物料当前的阈值和启用状态覆盖回表单里的旧数。

View File

@ -22,6 +22,10 @@ const materialsStore = vi.hoisted(() => ({
vi.mock('../stores/materials', () => ({ useMaterialsStore: () => materialsStore }))
vi.mock('../stores/auth', () => ({ useAuthStore: () => ({ isAdmin: true }) }))
// 切换公司的未保存确认由组织 store 承载;这里只关心页面本身,替身掉守卫注册。
vi.mock('../composables/useOrganizationSwitchGuard', () => ({
useOrganizationSwitchGuard: vi.fn()
}))
vi.mock('../api/masterData', () => ({
materialCategoriesApi: { list: vi.fn() },
unitsApi: { list: vi.fn() },
@ -525,3 +529,30 @@ describe('停用分类下的物料编辑', () => {
wrapper.unmount()
})
})
describe('物料页无可用分类时的配置入口提示', () => {
test('没有可用分类时,新建弹窗给出具体配置入口', async () => {
materialCategoriesApi.list.mockResolvedValue({ data: [] })
unitsApi.list.mockResolvedValue({ data: [] })
const wrapper = mountMaterials()
await flushPromises()
await openAddDialog(wrapper)
expect(wrapper.text()).toContain('本公司尚未配置物料分类')
expect(wrapper.text()).toContain('基础配置')
wrapper.unmount()
})
test('有可用分类时不显示配置入口提示', async () => {
materialCategoriesApi.list.mockResolvedValue({ data: categories })
unitsApi.list.mockResolvedValue({ data: [] })
const wrapper = mountMaterials()
await flushPromises()
await openAddDialog(wrapper)
expect(wrapper.text()).not.toContain('本公司尚未配置物料分类')
wrapper.unmount()
})
})

View File

@ -67,6 +67,15 @@
</BaseTable>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px" @close="handleDialogClose">
<!-- 本公司没有任何可用分类时,建档会卡在“选不出大类/小类”,这里给出明确的配置入口。 -->
<el-alert
v-if="isCreating && majorCategories.length === 0"
class="material-config-hint"
type="warning"
:closable="false"
title="本公司尚未配置物料分类"
description="请先到「基础配置 → 物料分类」添加分类,选好大类与小类后再填写物料编码建档。"
/>
<el-form v-if="isCreating || isEditing" :model="form" label-width="100px">
<el-form-item label="物料编码" required>
<div class="material-code-field">
@ -161,6 +170,7 @@
import { computed, ref, reactive, onMounted } from 'vue'
import { useMaterialsStore } from '../stores/materials'
import { useAuthStore } from '../stores/auth'
import { useOrganizationSwitchGuard } from '../composables/useOrganizationSwitchGuard'
import BaseTable from '../components/business/BaseTable.vue'
import DeleteConfirm from '../components/business/DeleteConfirm.vue'
import AlertTip from '../components/business/AlertTip.vue'
@ -171,6 +181,7 @@ import { getMajorCategories, getMinorCategories, resolveCategoryPath } from '../
import { searchMaterialOptions } from '../utils/materialSearch'
import { errorText } from '../utils/errorText'
import { formatDate } from '../utils/datetime'
import { getOrganizationId } from '../utils/organizationContext'
// 物料详情的展示沿用原先不带秒的格式
const MATERIAL_DATE_OPTIONS = {
@ -408,9 +419,14 @@ async function handleSave() {
if (file) {
const uploadForm = new FormData()
uploadForm.append('image', file)
// 这里绕开了 axios 拦截器,组织头得手工补上,否则图片上传会缺少 X-Organization-ID。
const organizationId = getOrganizationId()
const res = await fetch('/api/upload', {
method: 'POST',
headers: { 'Authorization': `Bearer ${authStore.token}` },
headers: {
'Authorization': `Bearer ${authStore.token}`,
...(organizationId ? { 'X-Organization-ID': String(organizationId) } : {})
},
body: uploadForm
})
if (!res.ok) {
@ -466,6 +482,13 @@ async function handleRestore(id) {
}
}
// 切换公司会重新挂载页面,正在编辑的弹窗连同其中的改动都会丢失。有未保存内容时先确认,
// 用户取消则保持当前公司不变(守卫返回 false)。
const hasUnsavedChanges = computed(
() => dialogVisible.value && (isCreating.value || isEditing.value)
)
useOrganizationSwitchGuard(() => hasUnsavedChanges.value)
</script>
<style scoped>
@ -494,6 +517,9 @@ async function handleRestore(id) {
background: #fff;
box-sizing: border-box;
}
.material-config-hint {
margin-bottom: 12px;
}
.search-native-input:focus {
border-color: #409eff;
}

View File

@ -0,0 +1,174 @@
// 代表性业务页面(物料页)的公司切换生命周期集成测试。
//
// 与 Materials.test.js 不同:这里刻意使用「真实的 materials / organization store + 替身 API」,
// 才能覆盖页面级的三件事——未保存提示、保存期间禁止切换、切换后的状态清理。
// 其余库存/单据/申请/审批页面复用同一套公共机制(组织 store 的守卫 + 清理注册表 + 挂载 key 重挂载),
// 不再各自复制整套切换测试。
import { flushPromises, mount } from '@vue/test-utils'
import ElementPlus, { ElMessage, ElMessageBox } from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import Materials from './Materials.vue'
import { materialsApi } from '../api/materials'
import { materialCategoriesApi, unitsApi } from '../api/masterData'
import { authApi } from '../api/auth'
import { useOrganizationStore } from '../stores/organization'
import { useMaterialsStore } from '../stores/materials'
import { resetOrganizationContext } from '../utils/organizationContext'
vi.mock('../api/materials', () => ({
materialsApi: {
getAll: vi.fn(),
create: vi.fn(),
update: vi.fn(),
obsolete: vi.fn(),
restore: vi.fn()
}
}))
vi.mock('../api/masterData', () => ({
materialCategoriesApi: { list: vi.fn() },
unitsApi: { list: vi.fn() },
codeRulesApi: { next: vi.fn(), list: vi.fn(), create: vi.fn(), update: vi.fn() }
}))
vi.mock('../api/auth', () => ({
authApi: {
listOrganizations: vi.fn(),
switchOrganizationContext: vi.fn(),
createWebSocketTicket: vi.fn()
}
}))
// 实时连接在本用例无关,替身掉避免真实建连。
vi.mock('../utils/companySocket', () => ({
createCompanySocket: () => ({ connect: vi.fn(), close: vi.fn() })
}))
function findButton(wrapper, text) {
return wrapper.findAll('button').find(button => button.text().includes(text))
}
function switchResult(organizationId) {
return { data: { organization_id: organizationId, organization: { id: organizationId }, warehouse: null } }
}
// 让当前账号以公司 1 登录并完成一次初始化。
async function enterCompany1() {
localStorage.setItem('wmms_token', 'token-1')
localStorage.setItem('wmms_user', JSON.stringify({
id: 1, username: 'admin', roles: [{ code: 'super_admin', enabled: true }]
}))
authApi.listOrganizations.mockResolvedValue({
data: { organizations: [{ id: 1, name: '北京' }, { id: 2, name: '武汉' }], default_organization_id: 1 }
})
authApi.switchOrganizationContext.mockResolvedValueOnce(switchResult(1))
await useOrganizationStore().initialize()
}
function mountMaterials() {
return mount(Materials, { global: { plugins: [ElementPlus] } })
}
describe('物料页在公司切换下的生命周期', () => {
beforeEach(() => {
resetOrganizationContext()
localStorage.clear()
localStorage.setItem('wmms_token', 'token-1')
localStorage.setItem('wmms_user', JSON.stringify({
id: 1, username: 'admin', roles: [{ code: 'super_admin', enabled: true }]
}))
setActivePinia(createPinia())
vi.clearAllMocks()
materialsApi.getAll.mockResolvedValue({ data: { items: [], total: 0 } })
materialCategoriesApi.list.mockResolvedValue({ data: [] })
unitsApi.list.mockResolvedValue({ data: [] })
authApi.listOrganizations.mockReset()
authApi.switchOrganizationContext.mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
// 编辑中切换公司要提示放弃未保存内容;用户取消则保持当前公司,页面数据不清空。
test('有未保存内容时取消切换会保留当前公司与页面状态', async () => {
await enterCompany1()
const wrapper = mountMaterials()
await flushPromises()
// 打开新建弹窗即为「编辑中」(存在未保存内容)
await findButton(wrapper, '新增物料').trigger('click')
await flushPromises()
expect(wrapper.vm.$.setupState.dialogVisible).toBe(true)
const confirmSpy = vi.spyOn(ElMessageBox, 'confirm').mockRejectedValue('cancel')
const org = useOrganizationStore()
await org.switchOrganization(2)
expect(confirmSpy).toHaveBeenCalledWith('切换公司将放弃未保存内容', '提示', expect.any(Object))
// 取消:仍是公司 1,弹窗还在
expect(org.currentOrganizationId).toBe(1)
expect(org.switching).toBe(false)
expect(wrapper.vm.$.setupState.dialogVisible).toBe(true)
wrapper.unmount()
})
// 确认切换后:切到目标公司,且物料 store 的共享缓存被清理(状态清理)。
test('确认切换后进入目标公司并清空物料共享缓存', async () => {
await enterCompany1()
const materialsStore = useMaterialsStore()
const wrapper = mountMaterials()
await flushPromises()
// 挂载时的加载已完成,此时填入公司 1 的缓存,验证切换后被清空。
materialsStore.materials = [{ id: 1, material_code: 'A-001' }]
materialsStore.materialOptions = [{ id: 1 }]
await findButton(wrapper, '新增物料').trigger('click')
await flushPromises()
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm')
authApi.switchOrganizationContext.mockResolvedValueOnce(switchResult(2))
const org = useOrganizationStore()
await org.switchOrganization(2)
expect(org.currentOrganizationId).toBe(2)
expect(materialsStore.materials).toEqual([])
expect(materialsStore.materialOptions).toEqual([])
wrapper.unmount()
})
// 保存请求飞行中禁止切换:既不发起校验,也不改变当前公司。
test('保存期间禁止切换公司', async () => {
await enterCompany1()
const org = useOrganizationStore()
authApi.switchOrganizationContext.mockClear()
org.beginSaving()
expect(org.busy).toBe(true)
await org.switchOrganization(2)
expect(authApi.switchOrganizationContext).not.toHaveBeenCalled()
expect(org.currentOrganizationId).toBe(1)
// 保存结束后恢复可切换,避免忙标志被永久卡住
org.endSaving()
expect(org.busy).toBe(false)
})
// 物料写请求飞行期间组织 store 处于 busy:验证 beginSaving/endSaving 真的接线了。
test('物料写请求期间组织 store 处于忙,结束后恢复', async () => {
await enterCompany1()
const materialsStore = useMaterialsStore()
const org = useOrganizationStore()
materialsApi.create.mockResolvedValue({ data: {} })
const pending = materialsStore.createMaterial({ name: '新物料' })
// 请求发出后(尚未 settle)应处于保存中
expect(org.saving).toBe(true)
expect(org.busy).toBe(true)
await pending
expect(org.saving).toBe(false)
expect(org.busy).toBe(false)
})
})

View File

@ -14,6 +14,10 @@ vi.mock('../stores/outbound', () => ({ useOutboundStore: () => outboundStore }))
vi.mock('../stores/materials', () => ({ useMaterialsStore: () => materialsStore }))
vi.mock('../stores/stock', () => ({ useStockStore: () => stockStore }))
vi.mock('../stores/auth', () => ({ useAuthStore: () => ({ isAdmin: true }) }))
// 切换公司的未保存确认由组织 store 承载;这里只关心页面本身,替身掉守卫注册。
vi.mock('../composables/useOrganizationSwitchGuard', () => ({
useOrganizationSwitchGuard: vi.fn()
}))
function mountOutbound() {
return mount(Outbound, {

View File

@ -112,6 +112,7 @@ import { useAuthStore } from '../stores/auth'
import BaseTable from '../components/business/BaseTable.vue'
import StatusBadge from '../components/business/StatusBadge.vue'
import DeleteConfirm from '../components/business/DeleteConfirm.vue'
import { useOrganizationSwitchGuard } from '../composables/useOrganizationSwitchGuard'
import { ElMessage } from 'element-plus'
import { formatDate } from '../utils/datetime'
@ -129,6 +130,9 @@ const currentRow = ref(null)
const isEdit = ref(false)
const editId = ref(null)
// 单据弹窗开着时切换公司会丢弃未保存内容,先确认。
useOrganizationSwitchGuard(() => dialogVisible.value)
const form = reactive({
receiver: '',
remark: '',

View File

@ -2,6 +2,7 @@ import { flushPromises, mount } from '@vue/test-utils'
import ElementPlus, { ElMessage } from 'element-plus'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import Stock from './Stock.vue'
import { commitOrganizationId, resetOrganizationContext } from '../utils/organizationContext'
const stockStore = vi.hoisted(() => ({
stockList: [],
@ -14,7 +15,8 @@ const stockStore = vi.hoisted(() => ({
fetchStock: vi.fn(),
fetchStockLogs: vi.fn(),
fetchStockWarning: vi.fn(),
updateWarningStock: vi.fn()
updateWarningStock: vi.fn(),
refreshStock: vi.fn()
}))
const materialsStore = vi.hoisted(() => ({
@ -24,9 +26,17 @@ const materialsStore = vi.hoisted(() => ({
fetchHistoricalMaterialOptions: vi.fn()
}))
// 捕获 Stock 页面注册的库存事件处理器,供测试直接驱动。
const stockUpdateHandlerRef = vi.hoisted(() => ({ handler: null }))
vi.mock('../stores/stock', () => ({ useStockStore: () => stockStore }))
vi.mock('../stores/materials', () => ({ useMaterialsStore: () => materialsStore }))
vi.mock('../stores/auth', () => ({ useAuthStore: () => ({ isAdmin: true }) }))
vi.mock('../stores/organization', () => ({
setStockUpdateHandler: vi.fn(handler => {
stockUpdateHandlerRef.handler = typeof handler === 'function' ? handler : null
})
}))
vi.mock('../api/stock', () => ({
stockApi: { getInventoryBalanceDetails: vi.fn().mockResolvedValue({ data: [] }) }
}))
@ -40,13 +50,30 @@ function findButton(wrapper, text) {
const TabPaneStub = { template: '<div><slot /></div>' }
// 真实的 ElSelect 在 jsdom 下会因状态双向绑定反复触发自身更新而报
// "Maximum recursive updates exceeded"。页面这里只关心筛选值如何传递,
// 用轻量替身绕开,避免每个用例都产生成片的递归更新噪声。
const SelectStub = {
props: ['modelValue', 'placeholder', 'filterable', 'filterMethod', 'noDataText', 'clearable'],
emits: ['update:modelValue', 'change'],
template: '<div data-test="select"><slot /></div>'
}
const OptionStub = { props: ['label', 'value'], template: '<span />' }
function mountStock() {
return mount(Stock, {
global: {
plugins: [ElementPlus],
// WarehouseSettings 及其依赖与本用例无关,直接替身掉;
// ElTabPane 在 jsdom 下会让表格列渲染不出来,用直通替身绕开。
stubs: { teleport: true, WarehouseSettings: true, ElTabPane: TabPaneStub }
stubs: {
teleport: true,
WarehouseSettings: true,
ElTabPane: TabPaneStub,
ElSelect: SelectStub,
ElOption: OptionStub
}
}
})
}
@ -165,3 +192,59 @@ describe('库存分类筛选', () => {
wrapper.unmount()
})
})
describe('库存实时事件消费', () => {
beforeEach(() => {
resetOrganizationContext()
// 挂载时页面会捕获当前上下文;先确定为公司 7。
commitOrganizationId(7)
stockStore.fetchStock.mockReset()
stockStore.fetchStockLogs.mockReset()
stockStore.fetchStockWarning.mockReset()
stockStore.refreshStock.mockReset()
materialsStore.fetchMaterialOptions.mockReset()
materialsStore.fetchHistoricalMaterialOptions.mockReset()
})
test('同公司事件到达时刷新库存', async () => {
const wrapper = mountStock()
await flushPromises()
expect(typeof stockUpdateHandlerRef.handler).toBe('function')
stockUpdateHandlerRef.handler({ organization_id: 7 })
expect(stockStore.refreshStock).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
test('其它公司的事件被忽略,不刷新页面', async () => {
const wrapper = mountStock()
await flushPromises()
stockUpdateHandlerRef.handler({ organization_id: 8 })
expect(stockStore.refreshStock).not.toHaveBeenCalled()
wrapper.unmount()
})
test('上下文版本失效后连接回调不再更新页面', async () => {
const wrapper = mountStock()
await flushPromises()
// 再次提交公司 7 会递增版本:挂载时捕获的快照随之失效(模拟切换/重连后的旧回调)。
commitOrganizationId(7)
stockUpdateHandlerRef.handler({ organization_id: 7 })
expect(stockStore.refreshStock).not.toHaveBeenCalled()
wrapper.unmount()
})
test('页面卸载时撤销库存事件处理器', async () => {
const wrapper = mountStock()
await flushPromises()
wrapper.unmount()
expect(stockUpdateHandlerRef.handler).toBeNull()
})
})

View File

@ -203,11 +203,13 @@
</template>
<script setup>
import { ref, onMounted, watch, computed } from 'vue'
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
import { useStockStore } from '../stores/stock'
import { stockApi } from '../api/stock'
import { useMaterialsStore } from '../stores/materials'
import { useAuthStore } from '../stores/auth'
import { setStockUpdateHandler } from '../stores/organization'
import { captureOrganizationContext, isCurrentOrganizationContext } from '../utils/organizationContext'
import BaseTable from '../components/business/BaseTable.vue'
import StatusBadge from '../components/business/StatusBadge.vue'
import { ElMessage } from 'element-plus'
@ -254,10 +256,22 @@ onMounted(async () => {
// 库存流水则要能查已作废物料的历史,所以额外拉一份含作废的选项。
materialsStore.fetchMaterialOptions()
materialsStore.fetchHistoricalMaterialOptions()
// 接上公司级实时连接:其它终端的入出库改变了库存时刷新本页的库存列表与预警。
// 连接层已核对事件公司标识与当前上下文,这里再用挂载时捕获的上下文核对一次,
// 保证公司切换后(本组件即将卸载)失效的回调不会把旧公司的数据刷进新页面。
const snapshot = captureOrganizationContext()
setStockUpdateHandler((payload) => {
if (!isCurrentOrganizationContext(snapshot)) return
if (payload?.organization_id !== snapshot.organizationId) return
stockStore.refreshStock()
})
const catRes = await materialCategoriesApi.list()
categories.value = catRes.data || []
})
// 离开本页撤销库存事件处理器,避免处理器指向已卸载页面的状态。
onUnmounted(() => setStockUpdateHandler(null))
function getMajorCategoryDisplay(majorCode) {
if (!majorCode) return '-'
const major = categories.value.find(c => c.code === majorCode && !c.parent_id)

View File

@ -14,6 +14,11 @@ vi.mock('../../api/warehouses', () => ({
}
}))
// 切换公司的未保存确认由组织 store 承载;这里只关心仓库页本身,替身掉守卫注册。
vi.mock('../../composables/useOrganizationSwitchGuard', () => ({
useOrganizationSwitchGuard: vi.fn()
}))
// 真实的 ElSelect 在 jsdom 下会因状态双向绑定反复触发自身更新而报
// "Maximum recursive updates exceeded"。这里只关心状态下拉提交的内容,用轻量替身绕开。
// 与 MasterDataSettings.test.js 里的同款处理保持一致。
@ -208,7 +213,12 @@ describe('仓库与库位停用/启用', () => {
wrapper.unmount()
})
test('编辑过之后再点新增,走的仍是 create', async () => {
test('编辑过之后再点新增库位,走的仍是 createLocation 而不是 update', async () => {
// 本期每公司一个仓库:已有仓库时不再有「新增仓库」入口,改用库位的新增入口
// 验证「编辑残留的 id 不会漏进新增表单」。
warehousesApi.list.mockResolvedValue({
data: [{ id: 1, code: 'DEFAULT', name: '默认仓库', status: 'active', locations: [] }]
})
const wrapper = mountPage()
await flushPromises()
@ -217,15 +227,18 @@ describe('仓库与库位停用/启用', () => {
await clickSave(wrapper)
expect(warehousesApi.update).toHaveBeenCalledTimes(1)
await findButton(wrapper, '新增仓库').trigger('click')
// 已有仓库:新增仓库入口隐藏(后端同样拒绝第二个仓库)
expect(findButton(wrapper, '新增仓库')).toBeUndefined()
await findButton(wrapper, '新增库位').trigger('click')
await flushPromises()
await inputByLabel(wrapper, '编码').setValue('WH-004')
await inputByLabel(wrapper, '名称').setValue('四号仓')
await inputByLabel(wrapper, '编码').setValue('LOC-002')
await inputByLabel(wrapper, '名称').setValue('二号库位')
await clickSave(wrapper)
// 编辑残留的 id 若漏进 form,这里就会变成第二次 update(覆盖别人的记录)
expect(warehousesApi.create).toHaveBeenCalledWith({ code: 'WH-004', name: '四号仓', status: 'active' })
expect(warehousesApi.update).toHaveBeenCalledTimes(1)
// 编辑残留的 id 若漏进 form,这里就会变成 updateLocation(覆盖别人的库位)
expect(warehousesApi.createLocation).toHaveBeenCalledWith(1, { code: 'LOC-002', name: '二号库位', status: 'active' })
expect(warehousesApi.updateLocation).not.toHaveBeenCalled()
wrapper.unmount()
})
@ -295,3 +308,74 @@ describe('仓库与库位停用/启用', () => {
wrapper.unmount()
})
})
describe('本期每公司一仓库、每仓库一库位的入口可见性', () => {
beforeEach(() => {
vi.spyOn(ElMessage, 'warning').mockImplementation(() => {})
vi.spyOn(ElMessage, 'success').mockImplementation(() => {})
warehousesApi.list.mockReset()
warehousesApi.create.mockReset().mockResolvedValue({ data: {} })
warehousesApi.update.mockReset().mockResolvedValue({ data: {} })
warehousesApi.createLocation.mockReset().mockResolvedValue({ data: {} })
warehousesApi.updateLocation.mockReset().mockResolvedValue({ data: {} })
})
afterEach(() => {
vi.restoreAllMocks()
})
test('没有仓库时显示「新增仓库」,并提示未配置', async () => {
warehousesApi.list.mockResolvedValue({ data: [] })
const wrapper = mountPage()
await flushPromises()
expect(findButton(wrapper, '新增仓库')).toBeTruthy()
expect(wrapper.text()).toContain('本公司尚未配置仓库')
wrapper.unmount()
})
test('已有仓库时隐藏「新增仓库」(后端同样拒绝第二个)', async () => {
warehousesApi.list.mockResolvedValue({
data: [{ id: 1, code: 'DEFAULT', name: '默认仓库', status: 'active', locations: [] }]
})
const wrapper = mountPage()
await flushPromises()
expect(findButton(wrapper, '新增仓库')).toBeUndefined()
// 仓库名称与状态照常展示,编辑入口保留
expect(wrapper.text()).toContain('默认仓库')
expect(findButtonIn(wrapper.find('.el-collapse-item__header'), '编辑')).toBeTruthy()
wrapper.unmount()
})
test('仓库没有库位时显示「新增库位」,已有库位时隐藏', async () => {
warehousesApi.list.mockResolvedValue({
data: [{ id: 1, code: 'DEFAULT', name: '默认仓库', status: 'active', locations: [] }]
})
const wrapper = mountPage()
await flushPromises()
expect(findButton(wrapper, '新增库位')).toBeTruthy()
wrapper.unmount()
warehousesApi.list.mockResolvedValue(WAREHOUSE_WITH_LOCATION)
const withLocation = mountPage()
await flushPromises()
expect(findButton(withLocation, '新增库位')).toBeUndefined()
// 已有库位仍展示名称与状态,并保留编辑入口
expect(withLocation.text()).toContain('主库位')
expect(findButtonIn(withLocation.find('.el-table__row'), '编辑')).toBeTruthy()
withLocation.unmount()
})
test('停用的仓库仍展示并标注停用状态', async () => {
warehousesApi.list.mockResolvedValue({
data: [{ id: 1, code: 'DEFAULT', name: '停用仓', status: 'inactive', locations: [] }]
})
const wrapper = mountPage()
await flushPromises()
expect(wrapper.text()).toContain('停用仓')
expect(wrapper.text()).toContain('停用')
wrapper.unmount()
})
})

View File

@ -1,6 +1,10 @@
<template>
<div>
<div class="toolbar"><span>仓库与库位属于库存域,不在物料主数据中维护。</span><el-button type="primary" @click="openWarehouse">新增仓库</el-button></div>
<div class="toolbar">
<span>仓库与库位属于库存域,不在物料主数据中维护。本期每个公司一个仓库、每个仓库一个库位。</span>
<!-- 已有仓库时不再显示新增入口:本期每个公司最多一个仓库,后端也会拒绝第二个。 -->
<el-button v-if="warehouses.length === 0" type="primary" @click="openWarehouse">新增仓库</el-button>
</div>
<el-collapse v-loading="loading">
<el-collapse-item v-for="warehouse in warehouses" :key="warehouse.id" :name="warehouse.id">
<template #title>
@ -9,7 +13,16 @@
<!-- 整条 header 都挂着展开/收起的监听,按钮不 .stop 会连带把面板收起来 -->
<el-button link type="primary" class="status" @click.stop="openWarehouseEdit(warehouse.id)">编辑</el-button>
</template>
<div class="location-header"><span>库位</span><el-button link type="primary" @click.stop="openLocation(warehouse.id)">新增库位</el-button></div>
<div class="location-header">
<span>库位</span>
<!-- 已有库位时不再显示新增入口:每个仓库最多一个库位。 -->
<el-button
v-if="!warehouse.locations || warehouse.locations.length === 0"
link
type="primary"
@click.stop="openLocation(warehouse.id)"
>新增库位</el-button>
</div>
<el-table :data="warehouse.locations || []" size="small">
<el-table-column prop="code" label="库位编码" />
<el-table-column prop="name" label="库位名称" />
@ -26,7 +39,7 @@
</el-table>
</el-collapse-item>
</el-collapse>
<el-empty v-if="!loading && warehouses.length === 0" description="暂无仓库" />
<el-empty v-if="!loading && warehouses.length === 0" description="本公司尚未配置仓库" />
<el-dialog v-model="formVisible" :title="formTitle" width="420px" append-to-body>
<el-form :model="form" label-width="90px">
@ -49,11 +62,15 @@ import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { warehousesApi } from '../../api/warehouses'
import { replaceAllKeys } from '../../utils/replaceAllKeys'
import { useOrganizationSwitchGuard } from '../../composables/useOrganizationSwitchGuard'
const warehouses = ref([])
const loading = ref(false)
const saving = ref(false)
const formVisible = ref(false)
// 仓库/库位弹窗开着时切换公司会丢弃未保存内容,先确认。
useOrganizationSwitchGuard(() => formVisible.value)
const formType = ref('warehouse')
// 编辑目标的 id,null 表示新增。刻意单独存放、不进 form:
// form 上残留的 id 靠 Object.assign 是删不掉的,会导致「点新增却变成覆盖更新」。

View File

@ -182,4 +182,18 @@ describe('基础配置表单必填校验', () => {
expect(warnSpy).not.toHaveBeenCalledWith('请填写名称')
wrapper.unmount()
})
// 本公司还没有任何分类时,编码规则挂不上去,先引导去「物料分类」页添加。
test('编码规则 tab 没有分类时给出配置入口', async () => {
materialCategoriesApi.list.mockResolvedValue({ data: [] })
const wrapper = mountSettings()
await flushPromises()
await switchTab(wrapper, '编码规则')
await openCreateDialog(wrapper)
expect(wrapper.text()).toContain('本公司尚未配置物料分类')
expect(wrapper.text()).toContain('物料分类')
wrapper.unmount()
})
})

View File

@ -44,6 +44,15 @@
<el-empty v-if="!loading && rows.length === 0" description="暂无配置数据" />
<el-dialog v-model="dialogVisible" :title="`${editingId ? '编辑' : '新增'}${current.label}`" width="480px">
<!-- 编码规则必须挂到一个小类上;本公司还没有分类时先引导去分类页添加,否则这里的下拉是空的。 -->
<el-alert
v-if="activeTab === 'rules' && categories.length === 0"
class="settings-config-hint"
type="warning"
:closable="false"
title="本公司尚未配置物料分类"
description="编码规则要挂到一个小类上,请先在「物料分类」标签页添加分类,再回到这里配置规则。"
/>
<el-form :model="form" label-width="100px">
<el-form-item v-if="activeTab !== 'rules'" label="编码" required>
<el-input v-model="form.code" :disabled="Boolean(editingId)" />
@ -211,4 +220,5 @@ onMounted(loadCurrent)
.page-header p { margin: 0; color: #909399; }
.description-detail__subject { margin: 0 0 10px; font-weight: 600; }
.description-detail__body { margin: 0; color: #606266; line-height: 1.7; white-space: pre-wrap; }
.settings-config-hint { margin-bottom: 12px; }
</style>

View File

@ -12,6 +12,11 @@ vi.mock('../../api/partners', () => ({
}
}))
// 切换公司的未保存确认由组织 store 承载;这里只关心伙伴页本身,替身掉守卫注册。
vi.mock('../../composables/useOrganizationSwitchGuard', () => ({
useOrganizationSwitchGuard: vi.fn()
}))
// 真实的 ElSelect 在 jsdom 下会因状态双向绑定反复触发自身更新而报
// "Maximum recursive updates exceeded"。这里只关心表单校验,用轻量替身绕开。
const SelectStub = {

View File

@ -65,6 +65,7 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { partnersApi } from '../../api/partners'
import { replaceAllKeys } from '../../utils/replaceAllKeys'
import { useOrganizationSwitchGuard } from '../../composables/useOrganizationSwitchGuard'
import BaseTable from '../../components/business/BaseTable.vue'
const props = defineProps({ role: { type: String, required: true }, title: { type: String, required: true } })
@ -73,6 +74,9 @@ const loading = ref(false)
const saving = ref(false)
const dialogVisible = ref(false)
const editingId = ref(null)
// 伙伴新增/编辑弹窗开着时切换公司会丢弃未保存内容,先确认。
useOrganizationSwitchGuard(() => dialogVisible.value)
// 每页条数跟物料等列表页保持一致,避免同一个系统里两种翻页手感
const pagination = reactive({ currentPage: 1, pageSize: 20, total: 0 })
const statusField = computed(() => `${props.role}_status`)