Compare commits
4 Commits
121d0c9e1f
...
837cc5787b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
837cc5787b | ||
|
|
85d9683754 | ||
|
|
f0722eed00 | ||
|
|
71d1829c29 |
|
|
@ -1,8 +1,8 @@
|
|||
import request from './index'
|
||||
|
||||
export const partnersApi = {
|
||||
list(role) {
|
||||
return request.get('/partners', { params: { role } })
|
||||
list(role, params = {}) {
|
||||
return request.get('/partners', { params: { role, ...params } })
|
||||
},
|
||||
create(data) {
|
||||
return request.post('/partners', data)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
>
|
||||
<slot />
|
||||
<template #empty>
|
||||
<span>暂无数据</span>
|
||||
<span>{{ emptyText }}</span>
|
||||
</template>
|
||||
</el-table>
|
||||
<BasePagination
|
||||
|
|
@ -37,7 +37,11 @@ defineProps({
|
|||
},
|
||||
currentPage: Number,
|
||||
pageSize: Number,
|
||||
total: Number
|
||||
total: Number,
|
||||
emptyText: {
|
||||
type: String,
|
||||
default: '暂无数据'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:currentPage', 'page-change', 'selection-change'])
|
||||
|
|
|
|||
90
frontend/src/components/business/DeleteConfirm.test.js
Normal file
90
frontend/src/components/business/DeleteConfirm.test.js
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import ElementPlus from 'element-plus'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
import DeleteConfirm from './DeleteConfirm.vue'
|
||||
|
||||
const CONFIRM_TEXT = '确认作废'
|
||||
|
||||
function mountConfirm(onConfirm) {
|
||||
return mount(DeleteConfirm, {
|
||||
props: { modelValue: true, confirmText: CONFIRM_TEXT, onConfirm },
|
||||
global: {
|
||||
plugins: [ElementPlus],
|
||||
stubs: { teleport: true }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function confirmButton(wrapper) {
|
||||
return wrapper.findAll('button').find(button => button.text().includes(CONFIRM_TEXT))
|
||||
}
|
||||
|
||||
// 弹窗关闭体现为子组件把 modelValue 置回 false
|
||||
function closedByEmit(wrapper) {
|
||||
return (wrapper.emitted('update:modelValue') || []).some(args => args[0] === false)
|
||||
}
|
||||
|
||||
describe('DeleteConfirm 的关闭时机', () => {
|
||||
test('父操作成功后关闭弹窗', async () => {
|
||||
const onConfirm = vi.fn().mockResolvedValue()
|
||||
const wrapper = mountConfirm(onConfirm)
|
||||
await flushPromises()
|
||||
|
||||
await confirmButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1)
|
||||
expect(closedByEmit(wrapper)).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
test('父操作失败时弹窗保持打开,让用户能直接重试', async () => {
|
||||
const onConfirm = vi.fn().mockRejectedValue(new Error('接口失败'))
|
||||
const wrapper = mountConfirm(onConfirm)
|
||||
await flushPromises()
|
||||
|
||||
await confirmButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1)
|
||||
expect(closedByEmit(wrapper)).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
test('失败后原地点一次即可重试,不必重走确认流程', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
.mockRejectedValueOnce(new Error('接口失败'))
|
||||
.mockResolvedValueOnce()
|
||||
const wrapper = mountConfirm(onConfirm)
|
||||
await flushPromises()
|
||||
|
||||
await confirmButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
expect(closedByEmit(wrapper)).toBe(false)
|
||||
|
||||
// 弹窗还开着,直接再点一次
|
||||
await confirmButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledTimes(2)
|
||||
expect(closedByEmit(wrapper)).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
test('操作进行中按钮转 loading,避免重复提交', async () => {
|
||||
let resolveRequest
|
||||
const onConfirm = vi.fn(() => new Promise((resolve) => { resolveRequest = resolve }))
|
||||
const wrapper = mountConfirm(onConfirm)
|
||||
await flushPromises()
|
||||
|
||||
await confirmButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(confirmButton(wrapper).classes()).toContain('is-loading')
|
||||
|
||||
resolveRequest()
|
||||
await flushPromises()
|
||||
expect(confirmButton(wrapper).classes()).not.toContain('is-loading')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
|
@ -24,10 +24,17 @@ const props = defineProps({
|
|||
content: {
|
||||
type: String,
|
||||
default: '确定要删除此数据吗?此操作不可恢复。'
|
||||
},
|
||||
// 确认后执行的实际操作,由父组件传入并返回 Promise。
|
||||
// 这里刻意不用 emit('confirm'):emit 是同步的,拿不到父组件异步操作的结果,
|
||||
// 会退化成"请求刚发出去弹窗就关了",失败时用户得把确认流程重走一遍。
|
||||
onConfirm: {
|
||||
type: Function,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'confirm'])
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const visible = ref(props.modelValue)
|
||||
const loading = ref(false)
|
||||
|
|
@ -43,8 +50,11 @@ watch(visible, (val) => {
|
|||
async function handleConfirm() {
|
||||
loading.value = true
|
||||
try {
|
||||
emit('confirm')
|
||||
await props.onConfirm()
|
||||
// 只有父组件的操作成功返回才关闭弹窗:失败时留着,用户可以直接再点一次
|
||||
visible.value = false
|
||||
} catch (e) {
|
||||
// 失败原因已由 axios 拦截器统一提示,这里只负责别把弹窗关掉
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,12 @@ export const useStockStore = defineStore('stock', () => {
|
|||
// 入库表单还要读预警阈值,共用同一份缓存避免重复请求。
|
||||
const stockCache = ref({})
|
||||
|
||||
// 列表当前生效的筛选条件。保存预警之类的操作会在原地重新拉取列表,那次拉取必须
|
||||
// 沿用它们,否则列表会悄悄退回全量,而下拉框里还显示着用户选中的筛选值。
|
||||
const stockFilters = ref({ search: '', category: '' })
|
||||
|
||||
async function fetchStock(page = 1, search = '', category = '') {
|
||||
stockFilters.value = { search, category }
|
||||
loading.value = true
|
||||
try {
|
||||
const pageSize = stockPage.value.pageSize
|
||||
|
|
@ -105,7 +110,7 @@ export const useStockStore = defineStore('stock', () => {
|
|||
// 阈值改了,单物料缓存里的旧值必须作废,否则入库表单会预填到过期数据。
|
||||
delete stockCache.value[materialId]
|
||||
await Promise.all([
|
||||
fetchStock(stockPage.value.currentPage),
|
||||
fetchStock(stockPage.value.currentPage, stockFilters.value.search, stockFilters.value.category),
|
||||
fetchStockWarning(warningPage.value.currentPage)
|
||||
])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,34 @@ describe('stock store warning settings', () => {
|
|||
expect(quantity).toBe('12.500000')
|
||||
})
|
||||
|
||||
// 保存预警会在原地重新拉取列表,那次拉取必须沿用用户当前的筛选条件:否则列表会
|
||||
// 悄悄退回全量,而下拉框里还显示着选中的分类,用户以为筛选还在生效。
|
||||
it('保存预警后刷新列表要沿用当前的筛选条件', async () => {
|
||||
const store = useStockStore()
|
||||
|
||||
await store.fetchStock(1, '轴承', 'A01')
|
||||
stockApi.getAll.mockClear()
|
||||
|
||||
await store.updateWarningStock(1, 8)
|
||||
|
||||
expect(stockApi.getAll).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ search: '轴承', category: 'A01' })
|
||||
)
|
||||
})
|
||||
|
||||
// 首次加载没有筛选条件,别把上一次的筛选状态带到新页面上。
|
||||
it('不带筛选条件地重新查询会清掉旧的筛选状态', async () => {
|
||||
const store = useStockStore()
|
||||
|
||||
await store.fetchStock(1, '轴承', 'A01')
|
||||
stockApi.getAll.mockClear()
|
||||
await store.fetchStock(1)
|
||||
|
||||
expect(stockApi.getAll).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ search: '', category: '' })
|
||||
)
|
||||
})
|
||||
|
||||
// 入库表单选物料时会为了预填预警值而查询库存,顺手把当时(入库前)的数量缓存下来。
|
||||
// 入库保存后库存已经变了,缓存若不失效,出库表单会一直显示入库前的旧数字,
|
||||
// 用户得刷新页面才能看到正确值。
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
<el-table-column prop="application_no" label="申请单号" />
|
||||
<el-table-column label="申请人" width="100">
|
||||
<template #default="{ row }">
|
||||
{{ row.applicant || row.username || '-' }}
|
||||
{{ row.username || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="items" label="物料明细" width="200">
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@
|
|||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<DeleteConfirm v-model="deleteDialogVisible" @confirm="confirmDelete" />
|
||||
<DeleteConfirm v-model="deleteDialogVisible" :on-confirm="confirmDelete" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@
|
|||
title="确认作废该物料?"
|
||||
content="作废后该物料不能再用于新业务;已有业务记录仍会保留。"
|
||||
confirm-text="确认作废"
|
||||
@confirm="confirmDelete"
|
||||
:on-confirm="confirmDelete"
|
||||
/>
|
||||
|
||||
<AlertTip :message="alertMessage" :type="alertType" />
|
||||
|
|
@ -394,13 +394,11 @@ function handleDelete(id) {
|
|||
deleteDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 这里不能吞异常:DeleteConfirm 靠它判断该不该关弹窗,吞掉会让失败时误关。
|
||||
// 错误提示本来由 axios 拦截器统一负责,不吞也不会漏给用户看。
|
||||
async function confirmDelete() {
|
||||
try {
|
||||
await materialsStore.obsoleteMaterial(deleteId.value)
|
||||
ElMessage.success('物料已作废')
|
||||
} catch (e) {
|
||||
// 错误已由 API 层处理
|
||||
}
|
||||
await materialsStore.obsoleteMaterial(deleteId.value)
|
||||
ElMessage.success('物料已作废')
|
||||
}
|
||||
|
||||
async function handleRestore(id) {
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@
|
|||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<DeleteConfirm v-model="deleteDialogVisible" @confirm="confirmDelete" />
|
||||
<DeleteConfirm v-model="deleteDialogVisible" :on-confirm="confirmDelete" />
|
||||
|
||||
<el-dialog v-model="detailVisible" title="出库单详情" width="600px">
|
||||
<div v-if="currentRow">
|
||||
|
|
|
|||
|
|
@ -123,3 +123,45 @@ describe('库存预警阈值校验', () => {
|
|||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('库存分类筛选', () => {
|
||||
beforeEach(() => {
|
||||
stockStore.fetchStock.mockReset()
|
||||
stockStore.fetchStockLogs.mockReset()
|
||||
stockStore.fetchStockWarning.mockReset()
|
||||
materialsStore.fetchMaterialOptions.mockReset()
|
||||
materialsStore.fetchHistoricalMaterialOptions.mockReset()
|
||||
})
|
||||
|
||||
// 选中的大类要跟着查询一起发给 store,否则后端收不到 category、条件被静默丢弃,
|
||||
// 用户点「查询」看起来毫无反应。
|
||||
test('选中的大类随查询一起发给 store', async () => {
|
||||
const wrapper = mountStock()
|
||||
await flushPromises()
|
||||
|
||||
wrapper.vm.$.setupState.categoryFilter = 'A01'
|
||||
await flushPromises()
|
||||
|
||||
stockStore.fetchStock.mockClear()
|
||||
await findButton(wrapper, '查询').trigger('click')
|
||||
|
||||
expect(stockStore.fetchStock).toHaveBeenCalledWith(1, '', 'A01')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
// 翻页同样要带着筛选条件,否则第 2 页会退回全量数据。
|
||||
test('翻页时也带着分类筛选', async () => {
|
||||
const wrapper = mountStock()
|
||||
await flushPromises()
|
||||
|
||||
wrapper.vm.$.setupState.categoryFilter = 'A01'
|
||||
await flushPromises()
|
||||
|
||||
stockStore.fetchStock.mockClear()
|
||||
wrapper.vm.$.setupState.handlePageChange(2)
|
||||
await flushPromises()
|
||||
|
||||
expect(stockStore.fetchStock).toHaveBeenCalledWith(2, '', 'A01')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@
|
|||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<DeleteConfirm v-model="deleteDialogVisible" @confirm="confirmDelete" />
|
||||
<DeleteConfirm v-model="deleteDialogVisible" :on-confirm="confirmDelete" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ describe('伙伴表单必填校验', () => {
|
|||
|
||||
beforeEach(() => {
|
||||
warnSpy = vi.spyOn(ElMessage, 'warning').mockImplementation(() => {})
|
||||
partnersApi.list.mockReset().mockResolvedValue({ data: [] })
|
||||
partnersApi.list.mockReset().mockResolvedValue({ data: { items: [], total: 0 } })
|
||||
partnersApi.create.mockReset().mockResolvedValue({ data: {} })
|
||||
partnersApi.update.mockReset().mockResolvedValue({ data: {} })
|
||||
})
|
||||
|
|
@ -126,7 +126,7 @@ describe('伙伴表单必填校验', () => {
|
|||
|
||||
describe('伙伴列表的合作状态显示', () => {
|
||||
beforeEach(() => {
|
||||
partnersApi.list.mockReset().mockResolvedValue({ data: [] })
|
||||
partnersApi.list.mockReset().mockResolvedValue({ data: { items: [], total: 0 } })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -135,7 +135,7 @@ describe('伙伴列表的合作状态显示', () => {
|
|||
|
||||
test('合作状态列显示中文,不把英文枚举值直接给用户', async () => {
|
||||
partnersApi.list.mockResolvedValue({
|
||||
data: [{ id: 1, partner_code: 'SUP-001', legal_name: '某某钢材有限公司', supplier_status: 'active' }]
|
||||
data: { items: [{ id: 1, partner_code: 'SUP-001', legal_name: '某某钢材有限公司', supplier_status: 'active' }], total: 1 }
|
||||
})
|
||||
const wrapper = mountPartner()
|
||||
await flushPromises()
|
||||
|
|
@ -147,7 +147,7 @@ describe('伙伴列表的合作状态显示', () => {
|
|||
|
||||
test('未收录的状态原样显示,不伪装成已知状态', async () => {
|
||||
partnersApi.list.mockResolvedValue({
|
||||
data: [{ id: 2, partner_code: 'SUP-002', legal_name: '某某轴承厂', supplier_status: 'unknown_state' }]
|
||||
data: { items: [{ id: 2, partner_code: 'SUP-002', legal_name: '某某轴承厂', supplier_status: 'unknown_state' }], total: 1 }
|
||||
})
|
||||
const wrapper = mountPartner()
|
||||
await flushPromises()
|
||||
|
|
@ -160,7 +160,7 @@ describe('伙伴列表的合作状态显示', () => {
|
|||
describe('新增伙伴不发生表单残留', () => {
|
||||
beforeEach(() => {
|
||||
partnersApi.list.mockReset().mockResolvedValue({
|
||||
data: [{ id: 1, partner_code: 'SUP-001', legal_name: '某某钢材有限公司', supplier_status: 'active' }]
|
||||
data: { items: [{ id: 1, partner_code: 'SUP-001', legal_name: '某某钢材有限公司', supplier_status: 'active' }], total: 1 }
|
||||
})
|
||||
partnersApi.create.mockReset().mockResolvedValue({ data: {} })
|
||||
partnersApi.update.mockReset().mockResolvedValue({ data: {} })
|
||||
|
|
@ -191,3 +191,57 @@ describe('新增伙伴不发生表单残留', () => {
|
|||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('伙伴列表分页', () => {
|
||||
beforeEach(() => {
|
||||
partnersApi.list.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
test('首次加载取第一页,offset 从 0 开始', async () => {
|
||||
partnersApi.list.mockResolvedValue({ data: { items: [], total: 0 } })
|
||||
const wrapper = mountPartner()
|
||||
await flushPromises()
|
||||
|
||||
expect(partnersApi.list).toHaveBeenLastCalledWith('supplier', { limit: 20, offset: 0 })
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
test('翻到第二页时按页大小换算出 offset', async () => {
|
||||
partnersApi.list.mockResolvedValue({ data: { items: [], total: 40 } })
|
||||
const wrapper = mountPartner()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('.btn-next').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(partnersApi.list).toHaveBeenLastCalledWith('supplier', { limit: 20, offset: 20 })
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
test('切换角色时页码归零,不会停在上一个角色的第 N 页', async () => {
|
||||
partnersApi.list.mockResolvedValue({ data: { items: [], total: 40 } })
|
||||
const wrapper = mountPartner()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('.btn-next').trigger('click')
|
||||
await flushPromises()
|
||||
expect(partnersApi.list).toHaveBeenLastCalledWith('supplier', { limit: 20, offset: 20 })
|
||||
|
||||
await wrapper.setProps({ role: 'customer' })
|
||||
await flushPromises()
|
||||
|
||||
expect(partnersApi.list).toHaveBeenLastCalledWith('customer', { limit: 20, offset: 0 })
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
test('总数透传给分页组件,用户能看到一共多少条', async () => {
|
||||
partnersApi.list.mockResolvedValue({ data: { items: [], total: 42 } })
|
||||
const wrapper = mountPartner()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('共 42 条')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,7 +4,15 @@
|
|||
<div><h2>{{ title }}</h2><p>业务伙伴独立于本公司的组织数据,可按合作组织配置{{ title }}身份和状态。</p></div>
|
||||
<el-button type="primary" @click="openCreate">新增{{ title }}</el-button>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="rows" stripe>
|
||||
<BaseTable
|
||||
:data="rows"
|
||||
:loading="loading"
|
||||
:empty-text="`暂无${title}`"
|
||||
:current-page="pagination.currentPage"
|
||||
:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
@page-change="load"
|
||||
>
|
||||
<el-table-column prop="partner_code" label="伙伴编码" width="140" />
|
||||
<el-table-column prop="legal_name" label="企业名称" min-width="180" />
|
||||
<el-table-column prop="unified_credit_code" label="统一社会信用代码" min-width="180" />
|
||||
|
|
@ -12,8 +20,7 @@
|
|||
<el-table-column prop="contact_phone" label="联系电话" width="140" />
|
||||
<el-table-column :prop="statusField" label="合作状态" width="110" :formatter="statusFormatter" />
|
||||
<el-table-column label="操作" width="100"><template #default="{ row }"><el-button link type="primary" @click="openEdit(row)">编辑</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="!loading && rows.length === 0" :description="`暂无${title}`" />
|
||||
</BaseTable>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="`${editingId ? '编辑' : '新增'}${title}`" width="560px">
|
||||
<el-form :model="form" label-width="130px">
|
||||
|
|
@ -39,6 +46,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 BaseTable from '../../components/business/BaseTable.vue'
|
||||
|
||||
const props = defineProps({ role: { type: String, required: true }, title: { type: String, required: true } })
|
||||
const rows = ref([])
|
||||
|
|
@ -46,6 +54,8 @@ const loading = ref(false)
|
|||
const saving = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref(null)
|
||||
// 每页条数跟物料等列表页保持一致,避免同一个系统里两种翻页手感
|
||||
const pagination = reactive({ currentPage: 1, pageSize: 20, total: 0 })
|
||||
const statusField = computed(() => `${props.role}_status`)
|
||||
// 合作状态英文枚举到中文的对照。列表列与编辑弹窗共用这一份,避免两处各写一遍后失步。
|
||||
const STATUS_LABELS = { active: '启用', suspended: '暂停', eliminated: '淘汰', pending: '待审核', inactive: '停用' }
|
||||
|
|
@ -54,7 +64,20 @@ function statusFormatter(row, column, cellValue) { return STATUS_LABELS[cellValu
|
|||
const form = reactive(emptyForm())
|
||||
|
||||
function emptyForm() { return { partner_code: '', legal_name: '', unified_credit_code: '', contact_name: '', contact_phone: '', address: '', status: 'active', is_supplier: false, is_customer: false } }
|
||||
async function load() { loading.value = true; try { const response = await partnersApi.list(props.role); rows.value = response.data || [] } finally { loading.value = false } }
|
||||
async function load(currentPage = pagination.currentPage) {
|
||||
loading.value = true
|
||||
try {
|
||||
const offset = (currentPage - 1) * pagination.pageSize
|
||||
const response = await partnersApi.list(props.role, { limit: pagination.pageSize, offset })
|
||||
rows.value = response.data?.items || []
|
||||
pagination.total = response.data?.total || 0
|
||||
pagination.currentPage = currentPage
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
// 换角色等于换了一份数据集,页码必须归零,否则会停在上一个角色的第 N 页上
|
||||
watch(() => props.role, () => load(1))
|
||||
// 用 replaceAllKeys 而不是 Object.assign:后者删不掉上次编辑残留的 id,
|
||||
// 新增时会把别人的 id 一起提交上去。
|
||||
function openCreate() { editingId.value = null; replaceAllKeys(form, emptyForm()); dialogVisible.value = true }
|
||||
|
|
@ -71,10 +94,10 @@ async function save() {
|
|||
if (!form.partner_code.trim()) { ElMessage.warning('请填写伙伴编码'); return }
|
||||
if (!form.legal_name.trim()) { ElMessage.warning('请填写企业名称'); return }
|
||||
saving.value = true
|
||||
// 保存后停在第几页由 load 的默认参数决定:留在当前页,不把用户甩回第一页
|
||||
try { if (editingId.value) await partnersApi.update(editingId.value, payload()); else await partnersApi.create(payload()); ElMessage.success('保存成功'); dialogVisible.value = false; await load() } finally { saving.value = false }
|
||||
}
|
||||
watch(() => props.role, load)
|
||||
onMounted(load)
|
||||
onMounted(() => load(1))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
|||
|
|
@ -283,12 +283,32 @@ func (h *Handler) ListPartners(c *gin.Context) {
|
|||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := h.partnerSvc.List(orgID, c.Query("role"))
|
||||
// 分页参数的默认值与上限跟物料等列表接口保持一致,前端不必记两套规则
|
||||
limit := 20
|
||||
offset := 0
|
||||
if l := c.Query("limit"); l != "" {
|
||||
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
|
||||
limit = parsed
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
}
|
||||
}
|
||||
if o := c.Query("offset"); o != "" {
|
||||
if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 {
|
||||
offset = parsed
|
||||
}
|
||||
}
|
||||
|
||||
items, total, err := h.partnerSvc.List(orgID, c.Query("role"), limit, offset)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Success(c, items)
|
||||
if items == nil {
|
||||
items = []model.BusinessPartner{}
|
||||
}
|
||||
response.PaginatedSuccess(c, items, limit, offset, total)
|
||||
}
|
||||
func (h *Handler) CreatePartner(c *gin.Context) {
|
||||
orgID, ok := organizationID(c)
|
||||
|
|
|
|||
|
|
@ -737,8 +737,10 @@ func (h *Handler) ListStock(c *gin.Context) {
|
|||
}
|
||||
|
||||
search := c.Query("search")
|
||||
// 大类编码筛选,前端下拉选中的就是 material_categories 里的大类 code
|
||||
category := c.Query("category")
|
||||
|
||||
stocks, total, err := h.stockSvc.GetAllStock(search, limit, offset)
|
||||
stocks, total, err := h.stockSvc.GetAllStock(search, category, limit, offset)
|
||||
if err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
|
|
|
|||
|
|
@ -11,28 +11,39 @@ type PartnerRepository struct{}
|
|||
|
||||
func NewPartnerRepository() *PartnerRepository { return &PartnerRepository{} }
|
||||
|
||||
func (r *PartnerRepository) List(organizationID int64, role string) ([]model.BusinessPartner, error) {
|
||||
func (r *PartnerRepository) List(organizationID int64, role string, limit, offset int) ([]model.BusinessPartner, int64, error) {
|
||||
// 角色过滤写成一份,由计数和取数共用:两处各写一遍,改一处漏一处就会让
|
||||
// 总数和列表对不上(总数算的是全部,列表却只有供应商)。
|
||||
filter := `FROM organization_partners op
|
||||
JOIN business_partners bp ON bp.id = op.business_partner_id
|
||||
WHERE op.organization_id = ?`
|
||||
args := []interface{}{organizationID}
|
||||
if role == "supplier" {
|
||||
filter += ` AND op.is_supplier = TRUE`
|
||||
}
|
||||
if role == "customer" {
|
||||
filter += ` AND op.is_customer = TRUE`
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := database.DB.QueryRow(`SELECT COUNT(*) `+filter, args...).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
query := `SELECT op.id, op.organization_id, bp.id, op.partner_code, bp.legal_name,
|
||||
COALESCE(bp.unified_credit_code, ''), COALESCE(bp.contact_name, ''),
|
||||
COALESCE(bp.contact_phone, ''), COALESCE(bp.address, ''),
|
||||
op.is_supplier, op.is_customer, COALESCE(op.supplier_status, ''),
|
||||
COALESCE(op.customer_status, ''), COALESCE(op.settlement_currency, ''),
|
||||
COALESCE(op.payment_terms, '')
|
||||
FROM organization_partners op
|
||||
JOIN business_partners bp ON bp.id = op.business_partner_id
|
||||
WHERE op.organization_id = ?`
|
||||
args := []interface{}{organizationID}
|
||||
if role == "supplier" {
|
||||
query += ` AND op.is_supplier = TRUE`
|
||||
}
|
||||
if role == "customer" {
|
||||
query += ` AND op.is_customer = TRUE`
|
||||
}
|
||||
query += ` ORDER BY op.partner_code`
|
||||
` + filter + `
|
||||
ORDER BY op.partner_code
|
||||
LIMIT ? OFFSET ?`
|
||||
|
||||
rows, err := database.DB.Query(query, args...)
|
||||
// 计数查询已经用完 args,这里追加分页参数不会再影响到它
|
||||
rows, err := database.DB.Query(query, append(args, limit, offset)...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]model.BusinessPartner, 0)
|
||||
|
|
@ -42,11 +53,11 @@ func (r *PartnerRepository) List(organizationID int64, role string) ([]model.Bus
|
|||
&item.LegalName, &item.UnifiedCreditCode, &item.ContactName, &item.ContactPhone,
|
||||
&item.Address, &item.IsSupplier, &item.IsCustomer, &item.SupplierStatus,
|
||||
&item.CustomerStatus, &item.SettlementCurrency, &item.PaymentTerms); err != nil {
|
||||
return nil, err
|
||||
return nil, 0, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
return items, total, rows.Err()
|
||||
}
|
||||
|
||||
func (r *PartnerRepository) Create(item *model.BusinessPartner) (err error) {
|
||||
|
|
|
|||
|
|
@ -44,8 +44,16 @@ func NewStockRepo() *StockRepo {
|
|||
return &StockRepo{}
|
||||
}
|
||||
|
||||
func (r *StockRepo) GetAllStock(search string, limit, offset int) ([]model.Stock, int64, error) {
|
||||
countQuery := `SELECT COUNT(*) FROM materials m WHERE 1=1` + stockedMaterialFilterSQL
|
||||
// GetAllStock 的 category 收的是大类编码(material_categories 里 parent_id 为空的那层)。
|
||||
// 按大类 code 而不是 category_id 过滤,是为了和前端下拉直接选出的大类选项对上。
|
||||
func (r *StockRepo) GetAllStock(search, category string, limit, offset int) ([]model.Stock, int64, error) {
|
||||
// COUNT 和主查询是两条独立 SQL,筛选条件必须成对出现,否则分页总数会和实际行数
|
||||
// 对不上、末尾多出空白页。主查询本来就 JOIN 了分类表,这里为按大类过滤补上同样的 JOIN;
|
||||
// 两张表都按主键关联,不会放大行数,COUNT 的语义不变。
|
||||
countQuery := `SELECT COUNT(*) FROM materials m
|
||||
LEFT JOIN material_categories c ON c.id = m.category_id
|
||||
LEFT JOIN material_categories pc ON pc.id = c.parent_id
|
||||
WHERE 1=1` + stockedMaterialFilterSQL
|
||||
var countArgs []interface{}
|
||||
|
||||
if search != "" {
|
||||
|
|
@ -53,6 +61,10 @@ func (r *StockRepo) GetAllStock(search string, limit, offset int) ([]model.Stock
|
|||
searchPattern := "%" + search + "%"
|
||||
countArgs = append(countArgs, searchPattern, searchPattern)
|
||||
}
|
||||
if category != "" {
|
||||
countQuery += ` AND pc.code = ?`
|
||||
countArgs = append(countArgs, category)
|
||||
}
|
||||
|
||||
var total int64
|
||||
err := database.DB.QueryRow(countQuery, countArgs...).Scan(&total)
|
||||
|
|
@ -78,6 +90,10 @@ func (r *StockRepo) GetAllStock(search string, limit, offset int) ([]model.Stock
|
|||
searchPattern := "%" + search + "%"
|
||||
args = append(args, searchPattern, searchPattern)
|
||||
}
|
||||
if category != "" {
|
||||
query += ` AND pc.code = ?`
|
||||
args = append(args, category)
|
||||
}
|
||||
|
||||
query += ` GROUP BY m.id, m.material_code, m.name, m.spec, m.unit, pc.code, c.code ORDER BY m.id LIMIT ? OFFSET ?`
|
||||
args = append(args, limit, offset)
|
||||
|
|
|
|||
|
|
@ -246,7 +246,7 @@ func TestStockRepoGetAllStockFiltersBothCountAndList(t *testing.T) {
|
|||
database.DB = db
|
||||
|
||||
filter := regexp.QuoteMeta(stockedMaterialFilterSQL)
|
||||
mock.ExpectQuery(`(?s)SELECT COUNT\(\*\) FROM materials m WHERE 1=1.*` + filter).
|
||||
mock.ExpectQuery(`(?s)SELECT COUNT\(\*\) FROM materials m.*` + filter).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
mock.ExpectQuery(`(?s)SELECT m\.id, m\.material_code.*`+filter+`.*ORDER BY m\.id LIMIT \? OFFSET \?`).
|
||||
WithArgs(20, 0).
|
||||
|
|
@ -254,7 +254,7 @@ func TestStockRepoGetAllStockFiltersBothCountAndList(t *testing.T) {
|
|||
"id", "material_code", "name", "spec", "unit", "on_hand", "minimum_available", "enabled", "warning_configured", "major_category", "minor_category",
|
||||
}).AddRow(7, "MAT-007", "轴承", "6204", "个", "4.000000", "10.000000", true, true, "A01", "A01-01"))
|
||||
|
||||
stocks, total, err := NewStockRepo().GetAllStock("", 20, 0)
|
||||
stocks, total, err := NewStockRepo().GetAllStock("", "", 20, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("get all stock: %v", err)
|
||||
}
|
||||
|
|
@ -269,6 +269,45 @@ func TestStockRepoGetAllStockFiltersBothCountAndList(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// 大类筛选同样要成对落到 COUNT 和主查询上,参数顺序也得与占位符一致:只改一边的话,
|
||||
// 筛选后的列表会配上一个没筛选的总数,翻到最后一页只会看到空白。
|
||||
func TestStockRepoGetAllStockAppliesCategoryToBothQueries(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("open sql mock: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
database.DB = nil
|
||||
db.Close()
|
||||
})
|
||||
database.DB = db
|
||||
|
||||
filter := regexp.QuoteMeta(stockedMaterialFilterSQL)
|
||||
// 主查询本来就 JOIN 了分类表,COUNT 那条也必须补上,否则拿 pc.code 过滤会直接报未知列
|
||||
mock.ExpectQuery(`(?s)SELECT COUNT\(\*\) FROM materials m.*LEFT JOIN material_categories pc.*` + filter + `.*AND pc\.code = \?`).
|
||||
WithArgs("A01").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
mock.ExpectQuery(`(?s)SELECT m\.id, m\.material_code.*` + filter + `.*AND pc\.code = \?.*ORDER BY m\.id LIMIT \? OFFSET \?`).
|
||||
WithArgs("A01", 20, 0).
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"id", "material_code", "name", "spec", "unit", "on_hand", "minimum_available", "enabled", "warning_configured", "major_category", "minor_category",
|
||||
}).AddRow(7, "MAT-007", "轴承", "6204", "个", "4.000000", "10.000000", true, true, "A01", "A01-01"))
|
||||
|
||||
stocks, total, err := NewStockRepo().GetAllStock("", "A01", 20, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("get all stock by category: %v", err)
|
||||
}
|
||||
if total != 1 || len(stocks) != 1 {
|
||||
t.Fatalf("unexpected result: total=%d stocks=%+v", total, stocks)
|
||||
}
|
||||
if stocks[0].MajorCategory != "A01" {
|
||||
t.Fatalf("unexpected major category: %+v", stocks[0])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// 预警列表同样有 COUNT 和主查询两条 SQL,而且两条都必须带上与 service.ClassifyStock
|
||||
// 对应的 HAVING 判定。
|
||||
func TestStockRepoGetWarningStockAppliesThresholdRuleToBothQueries(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -199,11 +199,11 @@ type PartnerService struct{ repo *repository.PartnerRepository }
|
|||
func NewPartnerService() *PartnerService {
|
||||
return &PartnerService{repo: repository.NewPartnerRepository()}
|
||||
}
|
||||
func (s *PartnerService) List(orgID int64, role string) ([]model.BusinessPartner, error) {
|
||||
func (s *PartnerService) List(orgID int64, role string, limit, offset int) ([]model.BusinessPartner, int64, error) {
|
||||
if role != "" && role != "supplier" && role != "customer" {
|
||||
return nil, errors.New("role must be supplier or customer")
|
||||
return nil, 0, errors.New("role must be supplier or customer")
|
||||
}
|
||||
return s.repo.List(orgID, role)
|
||||
return s.repo.List(orgID, role, limit, offset)
|
||||
}
|
||||
|
||||
// Create 新建伙伴。请求体可能带着前端表单残留的 id,这里不读取 item.ID,
|
||||
|
|
|
|||
|
|
@ -40,8 +40,8 @@ func classifyAll(stocks []model.Stock) {
|
|||
}
|
||||
}
|
||||
|
||||
func (s *StockService) GetAllStock(search string, limit, offset int) ([]model.Stock, int64, error) {
|
||||
stocks, total, err := s.stockRepo.GetAllStock(search, limit, offset)
|
||||
func (s *StockService) GetAllStock(search, category string, limit, offset int) ([]model.Stock, int64, error) {
|
||||
stocks, total, err := s.stockRepo.GetAllStock(search, category, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user