Compare commits

...

4 Commits

Author SHA1 Message Date
文睿 张
837cc5787b 前端问题优化 2026-09-21 16:53:44 +08:00
文睿 张
85d9683754 前端提示优化 2026-09-21 16:40:43 +08:00
文睿 张
f0722eed00 fix: 审批页申请人列去掉永远取不到的 row.applicant
后端返回里没有 applicant 键。model.ApplyOrder 确实有 Applicant 字段,但那是
另一张旧表的模型;审批页走的 MaterialApplication 只 SELECT u.username 并扫进
&app.Username(json tag 为 username)。列显示一直正常,全靠 || 后半段兜住,
左分支是死代码,去掉以免误导成还有第二个数据来源。

改为 row.username || '-'。
2026-09-21 16:19:22 +08:00
文睿 张
71d1829c29 fix: 库存列表分类筛选失效,后端补上 category 参数
前端 stores/stock.js 一直在发 category,但 handler 只读 search,service 与
repository 的方法签名里也没有这个参数,整条链路把它静默丢弃,于是选完分类
点「查询」没有任何反应。前端本来就传对了,无需改动。

repository 侧按大类 code 过滤(material_categories 里 parent_id 为空的那层,
即 SQL 里的 pc.code),与列表「大类类型」列同口径,前端下拉的 value 正是大类
code。注意 GetAllStock 的 COUNT 查询原本没有 JOIN 分类表,加 pc.code 前必须
补上与主查询一致的 LEFT JOIN,否则分页总数会和实际行数对不上、末尾多出空白
页;两张表都按主键关联,实测计数不变。小类码不会误命中(category=A01 返回 0)。

顺带修掉同一功能里的耦合缺陷:updateWarningStock 刷新列表时不传筛选参数,
筛选一旦生效,保存预警就会把列表刷回全量、而下拉框仍显示选中的分类。store
新增 stockFilters 记住当前条件,刷新时沿用。

stock_test.go 里 COUNT 的正则随 SQL 变形同步放宽,新增
TestStockRepoGetAllStockAppliesCategoryToBothQueries 覆盖两条 SQL 的成对条件
与参数顺序;前端补 4 条用例(store 沿用/清空筛选、组件查询与翻页传递分类)。
2026-09-21 16:19:18 +08:00
21 changed files with 399 additions and 57 deletions

View File

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

View File

@ -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'])

View 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()
})
})

View File

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

View File

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

View File

@ -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: '' })
)
})
// 入库表单选物料时会为了预填预警值而查询库存,顺手把当时(入库前)的数量缓存下来。
// 入库保存后库存已经变了,缓存若不失效,出库表单会一直显示入库前的旧数字,
// 用户得刷新页面才能看到正确值。

View File

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

View File

@ -125,7 +125,7 @@
</div>
</el-dialog>
<DeleteConfirm v-model="deleteDialogVisible" @confirm="confirmDelete" />
<DeleteConfirm v-model="deleteDialogVisible" :on-confirm="confirmDelete" />
</div>
</template>

View File

@ -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) {

View File

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

View File

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

View File

@ -80,7 +80,7 @@
</template>
</el-dialog>
<DeleteConfirm v-model="deleteDialogVisible" @confirm="confirmDelete" />
<DeleteConfirm v-model="deleteDialogVisible" :on-confirm="confirmDelete" />
</div>
</template>

View File

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

View File

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

View File

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

View File

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

View File

@ -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) {

View File

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

View File

@ -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) {

View File

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

View File

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