feat(#122): add SYB inner-code admin workflow

This commit is contained in:
QiuSW
2026-08-28 10:24:22 +08:00
parent 5c6655f706
commit 941b7b11f3
4 changed files with 177 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
import request from '@/utils/request'
const base = '/api/admin/v1/syb-inner-codes'
export function listSybInnerCodes(params) { return request({ url: base, method: 'get', params }) }
export function getSybInnerCode(id) { return request({ url: `${base}/${id}`, method: 'get' }) }
export function importSybInnerCodes(file, requestId) { const data = new FormData(); data.append('file', file); data.append('requestId', requestId); return request({ url: `${base}/import`, method: 'post', data, headers: { 'Content-Type': 'multipart/form-data' }}) }
export function deleteSybInnerCodes(data) { return request({ url: `${base}/batch-delete`, method: 'post', data }) }
export function previewSybInnerCodeApply(ids) { return request({ url: `${base}/apply-preview`, method: 'post', data: { ids }}) }
export function applySybInnerCodes(data) { return request({ url: `${base}/apply`, method: 'post', data }) }
export function getSybInnerCodeMatchJob(id) { return request({ url: `${base}/match-jobs/${id}`, method: 'get' }) }
export function getSybInnerCodeApplyBatch(id) { return request({ url: `${base}/apply-batches/${id}`, method: 'get' }) }
export function recheckSybInnerCode(id) { return request({ url: `${base}/${id}/recheck`, method: 'post', data: {}}) }
export function rematchSybInnerCodes(data) { return request({ url: `${base}/rematch`, method: 'post', data }) }
+13
View File
@@ -158,6 +158,19 @@ export const constantRoutes = [
}
]
},
{
path: '/syb-inner-codes',
component: Layout,
redirect: '/syb-inner-codes/index',
children: [
{
path: 'index',
component: () => import('@/views/goauto/syb-inner-codes/index'),
name: 'GoAutoSybInnerCodes',
meta: { title: '档口入库码', icon: 'list' }
}
]
},
{
path: '/collection-rules',
component: Layout,
@@ -0,0 +1,101 @@
<template>
<BasicLayout>
<template #wrapper>
<el-card class="page-card" shadow="never">
<div class="page-heading"><div><h1>档口入库码</h1><p>导入 Excel 后自动匹配 SYB 商品;确认后逐件回写,结果不明确时只读复核。</p></div></div>
<div class="toolbar" role="search" aria-label="档口入库码筛选与批量操作">
<el-upload ref="upload" :auto-upload="false" :limit="1" accept=".xlsx" :show-file-list="false" :on-change="selectFile" :on-exceed="replaceFile"><el-button :icon="FolderOpened">选择 Excel</el-button></el-upload>
<el-button type="primary" :icon="Upload" :loading="importing" :disabled="!selectedFile || importing" @click="submitImport">导入</el-button>
<el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" class="date-range" />
<el-input v-model="query.keyword" clearable placeholder="订单号或入库码" class="keyword" @keyup.enter="search" />
<el-button :icon="RefreshLeft" @click="clearFilters">清除</el-button>
<el-button type="primary" :icon="Search" @click="search">搜索</el-button>
<span class="toolbar-spacer" />
<el-button type="primary" :disabled="selected.length === 0 || busy" @click="openApply">回写({{ applyEligibleCount }})</el-button>
<el-button type="danger" plain :disabled="selected.length === 0 || busy" @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>
<el-alert v-if="loadError" :title="loadError" type="error" show-icon :closable="false" class="notice"><template #default><el-button type="primary" link @click="load">重新加载</el-button></template></el-alert>
<el-alert v-if="activeJob" :title="jobTitle" :type="activeJob.status === 'failed' ? 'error' : 'info'" show-icon :closable="false" class="notice"><template #default><el-progress v-if="activeJob.status === 'running'" :percentage="jobPercent" :stroke-width="8" /><span v-if="activeJob.errorMessage">{{ activeJob.errorMessage }}</span><span v-else>关闭浏览器不会中断自动匹配。</span></template></el-alert>
<el-alert v-if="activeBatch" :title="batchTitle" :type="activeBatch.status === 'interrupted' ? 'warning' : 'info'" show-icon :closable="false" class="notice"><template #default><el-progress :percentage="batchPercent" :stroke-width="8" /><span>远端写入全局串行,结果不明确不会自动重试。</span></template></el-alert>
<el-table ref="table" v-loading="loading" :data="items" row-key="id" border stripe empty-text="暂无档口入库码数据,请先选择 Excel 并导入" @selection-change="selectionChanged">
<el-table-column type="selection" width="48" />
<el-table-column label="营业日期" prop="businessDate" width="112" />
<el-table-column label="订单号" prop="orderNumber" min-width="160" />
<el-table-column label="入库码" min-width="190"><template #default="{ row }"><div v-for="code in visibleCodes(row)" :key="code" class="code">{{ code }}</div><el-button v-if="row.items.length > 2" type="primary" link @click="openDetail(row.id)">共 {{ row.items.length }} 个</el-button></template></el-table-column>
<el-table-column label="档口" prop="stall" min-width="130"><template #default="{ row }">{{ row.stall || '—' }}</template></el-table-column>
<el-table-column label="本地规格" prop="specRaw" min-width="170" />
<el-table-column label="SYB 规格" min-width="170"><template #default="{ row }"><span v-if="row.plan">{{ row.plan.sybSpec || '已读取,规格为空' }}</span><span v-else class="muted">{{ sybSpecPlaceholder(row.status) }}</span></template></el-table-column>
<el-table-column label="状态" min-width="180"><template #default="{ row }"><el-tag :type="statusMeta(row.status).type">{{ statusMeta(row.status).label }}</el-tag><div v-if="row.resultMessage" class="status-note">{{ row.resultMessage }}</div></template></el-table-column>
<el-table-column label="操作" width="190" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openDetail(row.id)">详情</el-button><el-button v-if="['failed', 'skipped'].includes(row.status)" type="primary" link :loading="rowActionId === row.id" @click="rematch(row)">重新匹配</el-button><el-button v-if="row.status === 'needs_check'" type="warning" link :loading="rowActionId === row.id" @click="recheck(row)">只读复核</el-button></template></el-table-column>
</el-table>
<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">
<div v-loading="applyDialog.loading">
<el-alert title="远端写入将全局串行执行;超时或结果不明确时不会自动重试,只能只读复核。" type="warning" show-icon :closable="false" class="notice" />
<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" />
</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>
</el-dialog>
<el-dialog v-model="deleteDialog.open" :title="`确定删除已选择的 ${selected.length} 条数据吗?`" width="600px" :close-on-click-modal="false">
<el-alert title="删除后不可恢复,可重新导入 Excel。此操作不会撤销已经写入 SYB 的入库码。" type="error" show-icon :closable="false" class="notice" />
<div class="metric-grid three"><div><span>选中数量</span><strong>{{ selected.length }}</strong></div><div><span>可删除数量</span><strong>{{ deleteEligibleCount }}</strong></div><div><span>阻塞数量</span><strong>{{ deleteBlocked.length }}</strong></div></div>
<el-alert v-if="deleteBlocked.length" :title="`记录 ${deleteBlocked.map(item => item.id).join('、')} 处于排队中、回写中或需复核;本次不能部分删除。`" type="warning" show-icon :closable="false" />
<template #footer><el-button :disabled="deleteDialog.saving" @click="deleteDialog.open = false">取消</el-button><el-button type="danger" :loading="deleteDialog.saving" :disabled="deleteBlocked.length > 0" @click="confirmDelete">删除 {{ deleteEligibleCount }} 条</el-button></template>
</el-dialog>
<el-drawer v-model="detail.open" title="档口入库码详情" size="720px">
<div v-loading="detail.loading" class="drawer-body"><template v-if="detail.item"><el-descriptions :column="1" border><el-descriptions-item label="营业日期">{{ detail.item.businessDate }}</el-descriptions-item><el-descriptions-item label="订单号">{{ detail.item.orderNumber }}</el-descriptions-item><el-descriptions-item label="档口">{{ detail.item.stall || '—' }}</el-descriptions-item><el-descriptions-item label="店铺(匹配证据)">{{ detail.item.shopName || '—' }}</el-descriptions-item><el-descriptions-item label="原始 SKU">{{ detail.item.sourceSkuRaw || '—' }}</el-descriptions-item><el-descriptions-item label="本地规格">{{ detail.item.specRaw }}</el-descriptions-item><el-descriptions-item label="SYB 规格">{{ detail.item.plan?.sybSpec || '未形成计划' }}</el-descriptions-item><el-descriptions-item label="状态"><el-tag :type="statusMeta(detail.item.status).type">{{ statusMeta(detail.item.status).label }}</el-tag></el-descriptions-item><el-descriptions-item label="说明">{{ detail.item.resultMessage || '—' }}</el-descriptions-item></el-descriptions><h3>全部入库码</h3><el-table :data="detail.item.items" border size="small"><el-table-column label="顺序" prop="ordinal" width="80" /><el-table-column label="Excel 行" prop="sourceRow" width="100" /><el-table-column label="入库码" prop="code" /></el-table></template></div>
</el-drawer>
</template>
</BasicLayout>
</template>
<script>
import { FolderOpened, RefreshLeft, Search, Upload } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { applySybInnerCodes, deleteSybInnerCodes, getSybInnerCode, getSybInnerCodeApplyBatch, getSybInnerCodeMatchJob, importSybInnerCodes, listSybInnerCodes, previewSybInnerCodeApply, recheckSybInnerCode, rematchSybInnerCodes } from '@/api/goauto/syb-inner-codes'
export default {
name: 'GoAutoSybInnerCodes',
setup() { return { FolderOpened, RefreshLeft, Search, Upload } },
data() { return { loading: false, importing: false, busy: false, loadError: '', items: [], total: 0, selected: [], selectedFile: null, dateRange: null, rowActionId: 0, pollTimer: null, activeJob: null, activeBatch: null, query: { page: 1, pageSize: 100, dateFrom: '', dateTo: '', keyword: '' }, applyDialog: { open: false, loading: false, saving: false, preview: {}}, deleteDialog: { open: false, saving: false }, detail: { open: false, loading: false, item: null }} },
computed: {
applyEligibleCount() { return this.selected.filter(item => item.status === 'ready').length },
deleteBlocked() { return this.selected.filter(item => ['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 },
jobTitle() { const job = this.activeJob; return job?.status === 'failed' ? '自动匹配失败' : `自动匹配:${job?.processed || 0}/${job?.total || 0},可回写 ${job?.ready || 0},异常 ${job?.failed || 0}` },
batchPercent() { return this.activeBatch?.requested ? Math.round(this.activeBatch.processed * 100 / this.activeBatch.requested) : 0 },
batchTitle() { return `回写进度:${this.activeBatch?.processed || 0}/${this.activeBatch?.requested || 0}(${this.activeBatch?.status || 'queued'})` }
},
created() { this.load() }, beforeUnmount() { this.stopPolling() },
methods: {
statusMeta(status) { return { pending: { label: '等待匹配', type: 'info' }, matching: { label: '正在匹配', type: 'primary' }, ready: { label: '可回写', type: 'success' }, already_filled: { label: '远端已存在', type: 'success' }, skipped: { label: '匹配受限', type: 'warning' }, failed: { label: '失败', type: 'danger' }, queued: { label: '排队中', type: 'primary' }, applying: { label: '回写中', type: 'primary' }, updated: { label: '已回写', type: 'success' }, needs_check: { label: '需复核', type: 'warning' }}[status] || { label: status || '未知', type: 'info' } },
sybSpecPlaceholder(status) { return status === 'pending' ? '尚未匹配' : status === 'matching' ? '正在读取' : status === 'needs_check' ? '需重新读取核对' : '未形成匹配计划' },
visibleCodes(row) { return (row.items || []).slice(0, 2).map(item => item.code) }, fileSize(size) { return `${(size / 1024).toFixed(size > 1024 * 1024 ? 0 : 1)} KB` },
selectFile(file) { this.selectedFile = file.raw }, replaceFile(files) { this.$refs.upload.clearFiles(); this.$refs.upload.handleStart(files[0]) }, selectionChanged(rows) { this.selected = rows },
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, crypto.randomUUID()); 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: crypto.randomUUID(), 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 } },
openDelete() { this.deleteDialog.open = true }, async confirmDelete() { this.deleteDialog.saving = true; this.busy = true; try { const r = await deleteSybInnerCodes({ requestId: crypto.randomUUID(), 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 rematch(row) { this.rowActionId = row.id; try { const r = await rematchSybInnerCodes({ requestId: crypto.randomUUID(), ids: [row.id] }); this.activeJob = { id: r.data.matchJobId, status: 'pending', total: 1, processed: 0, ready: 0, failed: 0 }; ElMessage.success('已开始重新匹配'); this.startPolling(); await this.load() } finally { this.rowActionId = 0 } },
async openDetail(id) { this.detail = { open: true, loading: true, item: null }; try { const r = await getSybInnerCode(id); this.detail.item = r.data.item } finally { this.detail.loading = false } },
startPolling() { if (!this.pollTimer) this.pollTimer = window.setInterval(this.poll, 2000); this.poll() }, stopPolling() { if (this.pollTimer) window.clearInterval(this.pollTimer); this.pollTimer = null },
async poll() { let active = false; if (this.activeJob && ['pending', 'running'].includes(this.activeJob.status)) { const r = await getSybInnerCodeMatchJob(this.activeJob.id); this.activeJob = r.data.item; active = ['pending', 'running'].includes(this.activeJob.status) || active } if (this.activeBatch && ['queued', 'running'].includes(this.activeBatch.status)) { const r = await getSybInnerCodeApplyBatch(this.activeBatch.id); this.activeBatch = r.data.batch; active = ['queued', 'running'].includes(this.activeBatch.status) || active } await this.load(); if (!active) this.stopPolling() }
}
}
</script>
<style lang="scss" scoped>
.page-card{min-height:calc(100vh - 124px)}.page-heading{margin-bottom:16px}.page-heading h1{margin:0 0 6px;font-size:24px;color:#1f2937}.page-heading p{margin:0;color:#606266;line-height:1.5}.toolbar{display:flex;align-items:center;gap:8px;padding:12px 16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}.toolbar-spacer{flex:1}.date-range{width:250px}.keyword{width:210px}.file-state{min-height:38px;padding:8px 16px;color:#606266;font-size:13px}.file-state strong{color:#1f2937}.notice{margin-bottom:12px}.code{font-variant-numeric:tabular-nums;word-break:break-all}.muted{color:#909399}.status-note{margin-top:4px;color:#606266;font-size:12px;line-height:1.4}.metric-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:16px 0}.metric-grid.three{grid-template-columns:repeat(3,1fr)}.metric-grid>div{padding:16px;border:1px solid #dbeafe;border-radius:8px;background:#f8fafc}.metric-grid span{display:block;color:#606266;font-size:12px}.metric-grid strong{display:block;margin-top:8px;color:#1e40af;font-size:24px;font-variant-numeric:tabular-nums}.drawer-body{padding:0 4px 24px}.drawer-body h3{margin:24px 0 12px}@media(max-width:1280px){.toolbar{flex-wrap:wrap}.toolbar-spacer{display:none}}@media(max-width:760px){.date-range,.keyword{width:100%}.metric-grid,.metric-grid.three{grid-template-columns:1fr 1fr}}@media(prefers-reduced-motion:reduce){:deep(*){scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important}}
</style>
+50
View File
@@ -0,0 +1,50 @@
import { expect, test } from '@playwright/test'
test('档口入库码列表、回写确认与物理删除门禁符合已确认原型', async({ page, context }) => {
await page.setViewportSize({ width: 1920, height: 1080 })
await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }])
const items = [
{ id: 1, businessDate: '2026-08-28', orderNumber: 'ORDER-001', stall: 'A档#101', specRaw: '黑色,L', shopName: '内部店铺', status: 'ready', resultMessage: '唯一匹配,等待确认回写', items: [{ ordinal: 1, sourceRow: 2, code: 'IN-001' }, { ordinal: 2, sourceRow: 3, code: 'IN-002' }], plan: { sybSpec: '黑色,L' }},
{ id: 2, businessDate: '2026-08-28', orderNumber: 'ORDER-002', stall: 'B档#202', specRaw: '白色,M', shopName: '内部店铺', status: 'needs_check', resultMessage: '写入响应不明确', items: [{ ordinal: 1, sourceRow: 4, code: 'IN-003' }], plan: { sybSpec: '白色,M' }},
]
await page.route('**/api/**', async route => {
const url = new URL(route.request().url())
if (url.pathname.startsWith('/src/api/')) return route.continue()
if (url.pathname.endsWith('/api/v1/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: ['admin'], name: '管理员', avatar: '', introduction: '', permissions: [] }}})
if (url.pathname.endsWith('/api/admin/v1/syb-inner-codes/apply-preview')) return route.fulfill({ json: { code: 200, data: { records: 1, inboundCodes: 2, placeholderDetails: 1, replaceOldCodes: 1, blocked: [] }}})
if (url.pathname.endsWith('/api/admin/v1/syb-inner-codes')) return route.fulfill({ json: { code: 200, data: { items, total: items.length, page: 1, pageSize: 100 }}})
return route.fulfill({ json: { code: 200, data: {} }})
})
await page.goto('http://localhost:9527/#/syb-inner-codes/index')
await expect(page.getByRole('heading', { name: '档口入库码' })).toBeVisible()
await expect(page.getByPlaceholder('订单号或入库码')).toBeVisible()
await expect(page.getByRole('columnheader', { name: '订单号' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: '入库码' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: '档口' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: '本地规格' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'SYB 规格' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: '店铺' })).toHaveCount(0)
await expect(page.getByRole('columnheader', { name: '单件码' })).toHaveCount(0)
await expect(page.getByText('IN-001', { exact: true })).toBeVisible()
const rows = page.locator('.el-table__body-wrapper tbody tr')
await rows.nth(0).locator('.el-checkbox').click()
await page.getByRole('button', { name: '回写(1)' }).click()
await expect(page.getByText('业务记录数', { exact: true })).toBeVisible()
await expect(page.getByText('入库码总数', { exact: true })).toBeVisible()
await expect(page.getByText('预计占位明细数', { exact: true })).toBeVisible()
await expect(page.getByText('替换旧码数', { exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: '回写 1 条' })).toBeEnabled()
await page.getByRole('dialog').getByRole('button', { name: '取消' }).click()
await rows.nth(0).locator('.el-checkbox').click()
await rows.nth(1).locator('.el-checkbox').click()
await expect(rows.nth(1).getByRole('button', { name: '只读复核' })).toBeVisible()
await expect(rows.nth(1).getByRole('button', { name: '重新匹配' })).toHaveCount(0)
await page.getByRole('button', { name: '删除(0)' }).click()
await expect(page.getByText('阻塞数量', { exact: true })).toBeVisible()
await expect(page.getByText(/本次不能部分删除/)).toBeVisible()
await expect(page.getByRole('button', { name: '删除 0 条' })).toBeDisabled()
})