From ee8f80f987e386c75fa05752e77b0524687d0d87 Mon Sep 17 00:00:00 2001
From: QiuSW <105186638@qq.com>
Date: Tue, 29 Sep 2026 14:57:23 +0800
Subject: [PATCH] fix: submit only frozen eligible inner-code apply selections
(#351)
---
.../views/goauto/syb-inner-codes/index.vue | 52 +++++++++++--
.../syb-inner-code-apply-selection.spec.ts | 57 ++++++++++++++
.../syb-inner-code-apply-selection.test.cjs | 77 +++++++++++++++++++
3 files changed, 178 insertions(+), 8 deletions(-)
create mode 100644 web/tests/e2e/syb-inner-code-apply-selection.spec.ts
create mode 100644 web/tests/unit/syb-inner-code-apply-selection.test.cjs
diff --git a/web/src/views/goauto/syb-inner-codes/index.vue b/web/src/views/goauto/syb-inner-codes/index.vue
index 8717c18..46c5efe 100644
--- a/web/src/views/goauto/syb-inner-codes/index.vue
+++ b/web/src/views/goauto/syb-inner-codes/index.vue
@@ -12,7 +12,7 @@
搜索
匹配 {{ matchEligibleCount }}
- 回写({{ applyEligibleCount }})
+ 回写({{ applyEligibleCount }})
删除({{ deleteEligibleCount }})
已选择:{{ selectedFile.name }}({{ fileSize(selectedFile.size) }})请选择“标签入库码映射”Excel 文件,最大 10MB。
@@ -35,13 +35,14 @@
-
+
+
选中 {{ applyDialog.selectedCount }} 条,可回写 {{ applyDialog.ids.length }} 条,跳过 {{ applyDialog.selectedCount - applyDialog.ids.length }} 条;只提交本次确认的可回写记录。
业务记录数{{ applyDialog.preview.records || 0 }}
入库码总数{{ applyDialog.preview.inboundCodes || 0 }}
预计占位明细数{{ applyDialog.preview.placeholderDetails || 0 }}
替换旧码数{{ applyDialog.preview.replaceOldCodes || 0 }}
-
+
- 取消回写 {{ applyDialog.preview.records || 0 }} 条
+ 取消回写 {{ applyDialog.ids.length }} 条
@@ -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) },
diff --git a/web/tests/e2e/syb-inner-code-apply-selection.spec.ts b/web/tests/e2e/syb-inner-code-apply-selection.spec.ts
new file mode 100644
index 0000000..f788c17
--- /dev/null
+++ b/web/tests/e2e/syb-inner-code-apply-selection.spec.ts
@@ -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([])
+})
diff --git a/web/tests/unit/syb-inner-code-apply-selection.test.cjs b/web/tests/unit/syb-inner-code-apply-selection.test.cjs
new file mode 100644
index 0000000..fd1631d
--- /dev/null
+++ b/web/tests/unit/syb-inner-code-apply-selection.test.cjs
@@ -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('')[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)
+})