merge: filter and freeze eligible inner-code apply selection (#351)
This commit is contained in:
@@ -2,8 +2,8 @@
|
|||||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||||
wiki_page: Business-Rules-and-Glossary
|
wiki_page: Business-Rules-and-Glossary
|
||||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
|
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
|
||||||
wiki_revision: c483a1f3fff3144dfad1ae24e6ca0b1de643ac12
|
wiki_revision: 32c6b496de47b54ca04e081271114c28ff439e0c
|
||||||
synchronized_at: 2026-09-29T06:14:25Z
|
synchronized_at: 2026-09-29T06:57:51Z
|
||||||
<!-- gitea-wiki-mirror:end -->
|
<!-- gitea-wiki-mirror:end -->
|
||||||
|
|
||||||
<!-- gitea-wiki-mirror:start -->
|
<!-- gitea-wiki-mirror:start -->
|
||||||
@@ -744,3 +744,8 @@ SYB 回填与资料完整性独立:正式SYB任务已有单号且仍关联有
|
|||||||
|
|
||||||
### 档口入库码排版字符兼容(#350 v2)
|
### 档口入库码排版字符兼容(#350 v2)
|
||||||
匹配比较时,双方原始 SKU、variationSku 及档口名称仅去除 CR/LF/TAB 排版控制字符,并沿用首尾空白 trim;普通内部空格、大小写、标点与货号不变。导入原始值和回写计划中的 SYB 原始证据保持原样。归一化后重复候选、SKU/档口冲突仍拒绝,原精确规格、货号、数量与占用校验不放宽。成功匹配只形成可回写计划,不自动执行 SYB 写入。
|
匹配比较时,双方原始 SKU、variationSku 及档口名称仅去除 CR/LF/TAB 排版控制字符,并沿用首尾空白 trim;普通内部空格、大小写、标点与货号不变。导入原始值和回写计划中的 SYB 原始证据保持原样。归一化后重复候选、SKU/档口冲突仍拒绝,原精确规格、货号、数量与占用校验不放宽。成功匹配只形成可回写计划,不自动执行 SYB 写入。
|
||||||
|
|
||||||
|
|
||||||
|
### 档口入库码混选回写范围(#351)
|
||||||
|
列表“回写 N”仅统计当前页勾选且 ready 的唯一记录,混选其他状态不阻塞合法项。打开确认弹窗时冻结本次选中数量及合格 ID;只预览、确认提交这些 ID,不随列表刷新或勾选变化扩大范围。弹窗明确显示选中/可回写/跳过数量,入库码、占位及替换旧码指标仅针对待提交记录。
|
||||||
|
预览发现候选状态变化时剔除 blocked 项,最多再进行一次只读预览以刷新指标;无合格项、预览失败/范围不一致或再次变化时禁用确认,提示重新预览。确认提交中防重复,未知提交结果不自动重试,沿用批次查询与只读复核。服务端原 ready 状态、唯一匹配计划及执行校验不变;仅用户人工确认后才进入回写,不自动写入。
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<el-button type="primary" :icon="Search" @click="search">搜索</el-button>
|
<el-button type="primary" :icon="Search" @click="search">搜索</el-button>
|
||||||
<span class="toolbar-spacer" />
|
<span class="toolbar-spacer" />
|
||||||
<el-button :loading="matchSubmitting" :disabled="!matchEligibleCount || loading || !!loadError || importing || busy || matchingBusy || !!rowActionId" @click="matchSelected">匹配 {{ matchEligibleCount }}</el-button>
|
<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>
|
<el-button type="danger" plain :disabled="selected.length === 0 || busy || matchingBusy" @click="openDelete">删除({{ deleteEligibleCount }})</el-button>
|
||||||
</div>
|
</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>
|
<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" />
|
<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-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">
|
<div v-loading="applyDialog.loading">
|
||||||
<el-alert title="远端写入将全局串行执行;超时或结果不明确时不会自动重试,只能只读复核。" type="warning" show-icon :closable="false" class="notice" />
|
<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>
|
<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>
|
</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>
|
||||||
|
|
||||||
<el-dialog v-model="deleteDialog.open" :title="`确定删除已选择的 ${selected.length} 条数据吗?`" width="600px" :close-on-click-modal="false">
|
<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 {
|
export default {
|
||||||
name: 'GoAutoSybInnerCodes',
|
name: 'GoAutoSybInnerCodes',
|
||||||
setup() { return { FolderOpened, RefreshLeft, Search, Upload } },
|
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: {
|
computed: {
|
||||||
matchingBusy() { return this.matchSubmitting || ['pending', 'running'].includes(this.activeJob?.status) },
|
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)) },
|
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 },
|
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)) },
|
deleteBlocked() { return this.selected.filter(item => ['matching', 'queued', 'applying', 'needs_check'].includes(item.status)) },
|
||||||
deleteEligibleCount() { return this.deleteBlocked.length ? 0 : this.selected.length },
|
deleteEligibleCount() { return this.deleteBlocked.length ? 0 : this.selected.length },
|
||||||
jobPercent() { return this.activeJob?.total ? Math.round(this.activeJob.processed * 100 / this.activeJob.total) : 0 },
|
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 } },
|
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() },
|
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 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 openApply() {
|
||||||
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 } },
|
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 } },
|
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 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) },
|
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)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user