fix: submit only frozen eligible inner-code apply selections (#351)

This commit is contained in:
QiuSW
2026-09-29 14:57:23 +08:00
parent 4959f3d17f
commit ee8f80f987
3 changed files with 178 additions and 8 deletions
+44 -8
View File
@@ -12,7 +12,7 @@
<el-button type="primary" :icon="Search" @click="search">搜索</el-button>
<span class="toolbar-spacer" />
<el-button :loading="matchSubmitting" :disabled="!matchEligibleCount || loading || !!loadError || importing || busy || matchingBusy || !!rowActionId" @click="matchSelected">匹配 {{ matchEligibleCount }}</el-button>
<el-button type="primary" :disabled="selected.length === 0 || busy" @click="openApply">回写({{ applyEligibleCount }})</el-button>
<el-button type="primary" :disabled="!applyEligibleCount || busy || importing || matchingBusy || loading || !!loadError || applyDialog.open" @click="openApply">回写({{ applyEligibleCount }})</el-button>
<el-button type="danger" plain :disabled="selected.length === 0 || busy || matchingBusy" @click="openDelete">删除({{ deleteEligibleCount }})</el-button>
</div>
<div class="file-state" aria-live="polite"><template v-if="selectedFile">已选择:<strong>{{ selectedFile.name }}</strong>({{ fileSize(selectedFile.size) }})</template><template v-else>请选择“标签入库码映射”Excel 文件,最大 10MB。</template></div>
@@ -35,13 +35,14 @@
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :page-sizes="[20, 50, 100, 200]" :total="total" @pagination="load" />
</el-card>
<el-dialog v-model="applyDialog.open" :title="`确认回写已选择的 ${selected.length} 条业务记录吗?`" width="640px" :close-on-click-modal="false">
<el-dialog v-model="applyDialog.open" :title="`确认回写 ${applyDialog.ids.length} 条业务记录吗?`" width="640px" :close-on-click-modal="false" :close-on-press-escape="!applyDialog.saving" :show-close="!applyDialog.saving">
<div v-loading="applyDialog.loading">
<el-alert title="远端写入将全局串行执行;超时或结果不明确时不会自动重试,只能只读复核。" type="warning" show-icon :closable="false" class="notice" />
<p aria-live="polite">选中 {{ applyDialog.selectedCount }} 条,可回写 {{ applyDialog.ids.length }} 条,跳过 {{ applyDialog.selectedCount - applyDialog.ids.length }} 条;只提交本次确认的可回写记录。</p>
<div class="metric-grid" aria-live="polite"><div><span>业务记录数</span><strong>{{ applyDialog.preview.records || 0 }}</strong></div><div><span>入库码总数</span><strong>{{ applyDialog.preview.inboundCodes || 0 }}</strong></div><div><span>预计占位明细数</span><strong>{{ applyDialog.preview.placeholderDetails || 0 }}</strong></div><div><span>替换旧码数</span><strong>{{ applyDialog.preview.replaceOldCodes || 0 }}</strong></div></div>
<el-alert v-if="applyDialog.preview.blocked?.length" :title="`${applyDialog.preview.blocked.length} 条当前不可回写,请返回列表重新选择。`" type="error" show-icon :closable="false" />
<el-alert v-if="applyDialog.error" :title="applyDialog.error" type="error" show-icon :closable="false" />
</div>
<template #footer><el-button :disabled="applyDialog.saving" @click="applyDialog.open = false">取消</el-button><el-button type="primary" :loading="applyDialog.saving" :disabled="applyDialog.loading || !!applyDialog.preview.blocked?.length" @click="confirmApply">回写 {{ applyDialog.preview.records || 0 }} 条</el-button></template>
<template #footer><el-button :disabled="applyDialog.saving" @click="applyDialog.open = false">取消</el-button><el-button type="primary" :loading="applyDialog.saving" :disabled="applyDialog.loading || applyDialog.saving || !applyDialog.ready || !applyDialog.ids.length" @click="confirmApply">回写 {{ applyDialog.ids.length }} 条</el-button></template>
</el-dialog>
<el-dialog v-model="deleteDialog.open" :title="`确定删除已选择的 ${selected.length} 条数据吗?`" width="600px" :close-on-click-modal="false">
@@ -67,12 +68,14 @@ import { createRequestId } from '@/utils/request-id'
export default {
name: 'GoAutoSybInnerCodes',
setup() { return { FolderOpened, RefreshLeft, Search, Upload } },
data() { const now = new Date(); const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; return { loading: false, importing: false, busy: false, matchSubmitting: false, loadError: '', items: [], total: 0, selected: [], selectedFile: null, dateRange: [today, today], rowActionId: 0, pollTimer: null, activeJob: null, activeBatch: null, query: { page: 1, pageSize: 100, dateFrom: today, dateTo: today, keyword: '' }, applyDialog: { open: false, loading: false, saving: false, preview: {}}, deleteDialog: { open: false, saving: false }, detail: { open: false, loading: false, item: null }} },
data() { const now = new Date(); const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; return { loading: false, importing: false, busy: false, matchSubmitting: false, loadError: '', items: [], total: 0, selected: [], selectedFile: null, dateRange: [today, today], rowActionId: 0, pollTimer: null, activeJob: null, activeBatch: null, query: { page: 1, pageSize: 100, dateFrom: today, dateTo: today, keyword: '' }, applyDialog: { open: false, loading: false, saving: false, ready: false, error: '', ids: [], selectedCount: 0, preview: {}}, deleteDialog: { open: false, saving: false }, detail: { open: false, loading: false, item: null }} },
computed: {
matchingBusy() { return this.matchSubmitting || ['pending', 'running'].includes(this.activeJob?.status) },
matchEligibleRows() { const ids = new Set(this.selected.map(item => item.id)); return this.items.filter(item => ids.has(item.id) && ['pending', 'failed', 'skipped'].includes(item.status)) },
matchEligibleCount() { return this.matchEligibleRows.length },
applyEligibleCount() { return this.selected.filter(item => item.status === 'ready').length },
applySelectionRows() { const ids = new Set(this.selected.map(item => item.id)); return this.items.filter(item => ids.has(item.id)) },
applyEligibleRows() { return this.applySelectionRows.filter(item => item.status === 'ready') },
applyEligibleCount() { return this.applyEligibleRows.length },
deleteBlocked() { return this.selected.filter(item => ['matching', 'queued', 'applying', 'needs_check'].includes(item.status)) },
deleteEligibleCount() { return this.deleteBlocked.length ? 0 : this.selected.length },
jobPercent() { return this.activeJob?.total ? Math.round(this.activeJob.processed * 100 / this.activeJob.total) : 0 },
@@ -89,8 +92,41 @@ export default {
async load() { this.loading = true; this.loadError = ''; try { const r = await listSybInnerCodes(this.query); this.items = r.data.items; this.total = r.data.total } catch (error) { this.loadError = error?.response?.data?.message || error?.message || '列表加载失败' } finally { this.loading = false } },
search() { [this.query.dateFrom, this.query.dateTo] = this.dateRange || ['', '']; this.query.page = 1; this.load() }, clearFilters() { this.dateRange = null; Object.assign(this.query, { page: 1, dateFrom: '', dateTo: '', keyword: '' }); this.load() },
async submitImport() { if (!this.selectedFile) return; this.importing = true; try { const r = await importSybInnerCodes(this.selectedFile, createRequestId()); ElMessage.success(`导入 ${r.data.recordCount} 条业务记录,已自动开始匹配`); this.activeJob = { id: r.data.matchJobId, status: 'pending', total: r.data.recordCount, processed: 0, ready: 0, failed: 0 }; this.startPolling(); await this.load() } finally { this.importing = false } },
async openApply() { this.applyDialog = { open: true, loading: true, saving: false, preview: {}}; try { const r = await previewSybInnerCodeApply(this.selected.map(item => item.id)); this.applyDialog.preview = r.data } finally { this.applyDialog.loading = false } },
async confirmApply() { this.applyDialog.saving = true; this.busy = true; try { const r = await applySybInnerCodes({ requestId: createRequestId(), ids: this.selected.map(item => item.id) }); this.activeBatch = { id: r.data.batchId, status: 'queued', requested: r.data.queued, processed: 0 }; this.applyDialog.open = false; ElMessage.success('回写已提交,页面可以继续使用'); this.startPolling(); await this.load() } finally { this.applyDialog.saving = false; this.busy = false } },
async openApply() {
if (!this.applyEligibleCount || this.busy || this.importing || this.matchingBusy || this.loading || this.loadError || this.applyDialog.open) return
this.applyDialog = { open: true, loading: true, saving: false, ready: false, error: '', ids: [...new Set(this.applyEligibleRows.map(item => item.id))], selectedCount: new Set(this.applySelectionRows.map(item => item.id)).size, preview: {}}
const dialog = this.applyDialog
try {
// One read-only refresh handles candidates whose status changed since listing.
for (let attempt = 0; attempt < 2; attempt++) {
const r = await previewSybInnerCodeApply([...dialog.ids])
if (this.applyDialog !== dialog || !dialog.open) return
const preview = r.data
const blocked = preview?.blocked || []
if (preview?.records !== dialog.ids.length || !Array.isArray(blocked) || blocked.some(item => !dialog.ids.includes(item.id))) throw new Error('回写预览范围不一致,请关闭后重新选择')
if (!blocked.length) { dialog.preview = preview; dialog.ready = true; return }
const blockedIds = new Set(blocked.map(item => item.id))
dialog.ids = dialog.ids.filter(id => !blockedIds.has(id))
if (!dialog.ids.length) { dialog.error = '当前没有可回写记录,请刷新后重新选择'; return }
}
dialog.error = '记录状态再次变化,请关闭后重新预览'
} catch (error) {
if (this.applyDialog === dialog && dialog.open) dialog.error = error?.response?.data?.message || error?.message || '回写预览失败,请关闭后重试'
} finally { dialog.loading = false }
},
async confirmApply() {
const dialog = this.applyDialog
if (!dialog.open || dialog.loading || dialog.saving || !dialog.ready || !dialog.ids.length || this.busy) return
dialog.saving = true; this.busy = true
try {
const r = await applySybInnerCodes({ requestId: createRequestId(), ids: [...dialog.ids] })
this.activeBatch = { id: r.data.batchId, status: 'queued', requested: r.data.queued, processed: 0 }
dialog.open = false; ElMessage.success('回写已提交,页面可以继续使用'); this.startPolling(); await this.load()
} catch (error) {
dialog.ready = false
dialog.error = error?.response?.data?.message || '回写提交未确认,请刷新列表或只读复核;不会自动重试'
} finally { dialog.saving = false; this.busy = false }
},
openDelete() { this.deleteDialog.open = true }, async confirmDelete() { this.deleteDialog.saving = true; this.busy = true; try { const r = await deleteSybInnerCodes({ requestId: createRequestId(), ids: this.selected.map(item => item.id) }); ElMessage.success(`已物理删除 ${r.data.deleted} 条数据`); this.deleteDialog.open = false; await this.load() } finally { this.deleteDialog.saving = false; this.busy = false } },
async recheck(row) { this.rowActionId = row.id; try { await recheckSybInnerCode(row.id); ElMessage.success('只读复核完成'); await this.load() } finally { this.rowActionId = 0 } },
async matchSelected() { await this.startMatch(this.matchEligibleRows) },
@@ -0,0 +1,57 @@
import { expect, test, type Page, type BrowserContext } from '@playwright/test'
async function setup(page: Page, context: BrowserContext, mode = 'success') {
await page.setViewportSize({ width: 1920, height: 1080 })
await context.addCookies([{ name: 'Admin-Token', value: 'isolated-test-token', domain: 'localhost', path: '/' }])
const previews: number[][] = []; const writes: number[][] = []
await page.route('**/api/**', async route => {
const url = new URL(route.request().url()); const pathname = url.pathname
if (pathname.startsWith('/src/api/')) return route.continue()
const reply = (data: unknown) => route.fulfill({ json: { code: 200, data } })
if (pathname.endsWith('/api/v1/menurole')) return reply([{ path: '/workbench', component: 'Layout', menuName: 'Workbench', title: '工作台', visible: '0', children: [{ path: '/syb-inner-codes', component: '/goauto/syb-inner-codes/index', menuName: 'GoAutoSybInnerCodes', title: '档口入库码', visible: '0' }] }])
if (pathname.endsWith('/api/v1/getinfo')) return reply({ roles: ['purchaser'], name: '模拟采购员', avatar: '', permissions: [] })
if (pathname.endsWith('/syb-inner-codes') && route.request().method() === 'GET') return reply({ items: ['updated', 'ready', 'failed', 'ready'].map((status, i) => ({ id: i + 1, businessDate: '2026-09-29', orderNumber: `MOCK-${i + 1}`, status, items: [{ code: `MOCK-CODE-${i}`, ordinal: 1 }], specRaw: '模拟规格' })), total: 4 })
if (pathname.endsWith('/apply-preview')) {
const ids = route.request().postDataJSON().ids; previews.push(ids)
if (mode === 'preview-error') return route.fulfill({ status: 500, json: { code: 'INTERNAL', message: '模拟预览失败' } })
return reply({ records: ids.length, inboundCodes: ids.length === 1 ? 3 : 2, blocked: mode === 'changed' && ids.length === 2 ? [{ id: 4, status: 'updated' }] : [] })
}
if (pathname.endsWith('/apply')) {
writes.push(route.request().postDataJSON().ids)
return reply({ batchId: 'mock-batch', queued: writes.at(-1)!.length })
}
if (pathname.includes('/apply-batches/')) return reply({ batch: { id: 'mock-batch', status: 'running', requested: writes.at(-1)!.length, processed: 0 } })
return reply({ items: [], total: 0 })
})
await page.goto('/#/syb-inner-codes')
await expect(page.getByRole('cell', { name: 'MOCK-1', exact: true })).toBeVisible()
await page.locator('.el-table__header-wrapper .el-checkbox').first().click()
await page.getByRole('button', { name: '回写(2)', exact: true }).click()
return { previews, writes, dialog: page.getByRole('dialog') }
}
test('mixed selection reports skipped items and submits only ready records', async ({ page, context }) => {
const { previews, writes, dialog } = await setup(page, context)
await expect(dialog.getByText('选中 4 条,可回写 2 条,跳过 2 条;只提交本次确认的可回写记录。')).toBeVisible()
await expect(dialog.getByRole('button', { name: '回写 2 条', exact: true })).toBeEnabled()
expect(previews).toEqual([[2, 4]]); expect(writes).toEqual([])
await dialog.getByRole('button', { name: '回写 2 条', exact: true }).click()
await expect.poll(() => writes.length).toBe(1); expect(writes[0]).toEqual([2, 4])
await expect(dialog).not.toBeVisible()
})
test('preview status changes update count and only submit remaining candidate', async ({ page, context }) => {
const { previews, writes, dialog } = await setup(page, context, 'changed')
await expect(dialog.getByText('选中 4 条,可回写 1 条,跳过 3 条;只提交本次确认的可回写记录。')).toBeVisible()
await expect(dialog.getByRole('button', { name: '回写 1 条', exact: true })).toBeEnabled()
expect(previews).toEqual([[2, 4], [2]])
await dialog.getByRole('button', { name: '回写 1 条', exact: true }).click()
await expect.poll(() => writes.length).toBe(1); expect(writes[0]).toEqual([2])
})
test('preview failure is visible and cannot submit', async ({ page, context }) => {
const { writes, dialog } = await setup(page, context, 'preview-error')
await expect(dialog.locator('.el-alert--error')).toBeVisible()
await expect(dialog.getByRole('button', { name: '回写 2 条', exact: true })).toBeDisabled()
expect(writes).toEqual([])
})
@@ -0,0 +1,77 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const source = fs.readFileSync(path.join(__dirname, '../../src/views/goauto/syb-inner-codes/index.vue'), 'utf8').split('<script>')[1].split('</script>')[0].replace(/^import .*$/gm, '').replace('export default', 'return')
function instance(preview = async ids => ({ data: { records: ids.length, inboundCodes: ids.length, blocked: [] } }), apply = async body => ({ data: { batchId: 'mock-batch', queued: body.ids.length } })) {
const previews = []; const writes = []
const options = new Function('previewSybInnerCodeApply', 'applySybInnerCodes', 'createRequestId', 'ElMessage', source)(async ids => { previews.push(ids); return preview(ids) }, async body => { writes.push(body); return apply(body) }, () => 'mock-request', { success() {} })
const vm = { ...options.data(), ...options.methods, load: async () => {}, startPolling() {} }
for (const [key, get] of Object.entries(options.computed)) Object.defineProperty(vm, key, { get: () => get.call(vm) })
vm.items = ['updated', 'ready', 'failed', 'ready'].map((status, i) => ({ id: i + 1, status }))
return { vm, previews, writes }
}
test('mixed selection only previews and submits frozen current-page ready IDs', async () => {
const { vm, previews, writes } = instance()
vm.selected = [...vm.items, vm.items[1], { id: 999, status: 'ready' }]
assert.equal(vm.applyEligibleCount, 2)
await vm.openApply()
assert.deepEqual(previews, [[2, 4]])
assert.equal(vm.applyDialog.selectedCount, 4)
assert.equal(vm.applyDialog.ready, true)
vm.selected = [{ id: 123, status: 'ready' }]; vm.items = vm.selected
await vm.confirmApply()
assert.deepEqual(writes[0].ids, [2, 4]); assert.equal(writes.length, 1)
assert.equal(vm.applyDialog.open, false)
})
test('blocked preview candidates are removed and metrics reloaded for remaining IDs', async () => {
const { vm, previews, writes } = instance(async ids => ({ data: { records: ids.length, inboundCodes: ids.length === 2 ? 7 : 3, blocked: ids.length === 2 ? [{ id: 4, status: 'updated' }] : [] } }))
vm.selected = vm.items
await vm.openApply()
assert.deepEqual(previews, [[2, 4], [2]])
assert.deepEqual(vm.applyDialog.ids, [2]); assert.equal(vm.applyDialog.preview.inboundCodes, 3)
assert.equal(vm.applyDialog.selectedCount - vm.applyDialog.ids.length, 3)
await vm.confirmApply(); assert.deepEqual(writes[0].ids, [2])
})
test('all blocked candidates or preview failures cannot submit', async () => {
for (const preview of [async ids => ({ data: { records: ids.length, blocked: ids.map(id => ({ id })) } }), async () => { throw new Error('mock network error') }, async () => ({ data: { records: 999, blocked: [] } })]) {
const { vm, writes } = instance(preview); vm.selected = vm.items
await vm.openApply(); assert.equal(vm.applyDialog.ready, false); assert.ok(vm.applyDialog.error)
await vm.confirmApply(); assert.equal(writes.length, 0)
}
})
test('zero eligible, loading, error, busy and active matching do not preview', async () => {
const empty = instance(); await empty.vm.openApply(); assert.equal(empty.previews.length, 0)
for (const field of ['loading', 'loadError', 'busy', 'importing', 'matchSubmitting']) {
const { vm, previews } = instance(); vm.selected = vm.items; vm[field] = field === 'loadError' ? 'mock' : true
await vm.openApply(); assert.equal(previews.length, 0)
}
})
test('canceled preview cannot authorize a later dialog and double clicks do not submit twice', async () => {
let resolvePreview
const { vm, writes } = instance(() => new Promise(resolve => { resolvePreview = resolve }))
vm.selected = vm.items; const pending = vm.openApply(); const original = vm.applyDialog
original.open = false; vm.applyDialog = { ...original, ready: false }
resolvePreview({ data: { records: 2, blocked: [] } }); await pending
assert.equal(vm.applyDialog.ready, false); await vm.confirmApply(); assert.equal(writes.length, 0)
let resolveApply
const running = instance(undefined, () => new Promise(resolve => { resolveApply = resolve }))
running.vm.selected = running.vm.items; await running.vm.openApply()
const submit = running.vm.confirmApply(); await running.vm.confirmApply()
assert.equal(running.writes.length, 1)
resolveApply({ data: { batchId: 'mock-batch', queued: 2 } }); await submit
})
test('submission conflict or unknown result disables retry without changing selection', async () => {
const { vm, writes } = instance(undefined, async () => { throw new Error('mock unknown result') })
vm.selected = vm.items; await vm.openApply(); await vm.confirmApply(); await vm.confirmApply()
assert.equal(writes.length, 1); assert.equal(vm.applyDialog.ready, false); assert.ok(vm.applyDialog.error)
assert.equal(vm.busy, false); assert.equal(vm.applyDialog.saving, false); assert.equal(vm.selected.length, 4)
})