feat: queue and reconcile SYB purchase order numbers (#305)

This commit is contained in:
QiuSW
2026-09-18 10:13:20 +08:00
parent 07a3817591
commit e89de1a085
22 changed files with 1126 additions and 61 deletions
+1
View File
@@ -6,6 +6,7 @@ export function previewPurchaseTasks(data, options = {}) { return request({ url:
export function matchPurchaseSpecsBatch(data) { return request({ url: '/api/admin/v1/purchase-tasks/batch-spec-match', method: 'post', data, timeout: 610000 }) }
export function createPurchaseTasksBatch(data) { return request({ url: '/api/admin/v1/purchase-tasks/batch', method: 'post', data, timeout: 60000 }) }
export function retryPurchaseTasksBatch(data) { return request({ url: '/api/admin/v1/purchase-tasks/batch-retry', method: 'post', data, timeout: 60000 }) }
export function writebackPurchaseOrderNumbers(data) { return request({ url: '/api/admin/v1/purchase-tasks/syb-order-writeback', method: 'post', data }) }
export function createStockPurchaseTask(data, options = {}) { return request({ url: '/api/admin/v1/purchase-tasks/stock', method: 'post', data, ...options }) }
export function createPurchaseTask(data) { return request({ url: '/api/admin/v1/purchase-tasks', method: 'post', data, timeout: 60000 }) }
export function getPurchaseMatching(taskId) { return request({ url: `/api/admin/v1/purchase-tasks/${taskId}/matching`, method: 'get' }) }
+35 -5
View File
@@ -11,11 +11,11 @@
<el-form-item label="SYB 商品"><el-input v-model="query.sybProductId" class="syb-input" clearable placeholder="商品 ID" @keyup.enter="search" /></el-form-item>
<el-form-item label="PDD 订单号"><el-input v-model="query.pddOrderNo" class="order-input" clearable placeholder="订单号" @keyup.enter="search" /></el-form-item>
<el-form-item><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button></el-form-item>
<el-form-item class="retry-action"><span class="selected-copy">已选择 <strong>{{ retrySelection.length }}</strong> 条</span><el-button type="warning" :disabled="retrySelection.length === 0" :loading="retryDialog.saving" @click="openRetryDialog">重试({{ retrySelection.length }})</el-button></el-form-item>
</el-form>
<div class="batch-actions"><span class="selected-copy">已选择 <strong>{{ retrySelection.length }}</strong> 条</span><el-button type="warning" :disabled="retryCandidates.length === 0" :loading="retryDialog.saving" @click="openRetryDialog">重试({{ retryCandidates.length }})</el-button><el-button type="primary" plain :disabled="orderWritebackCandidates.length === 0" :loading="orderWriteback.saving" @click="submitOrderWriteback(orderWritebackCandidates)">批量回填 SYB({{ orderWritebackCandidates.length }})</el-button></div>
<el-table ref="taskTable" v-loading="loading" :data="tasks" row-key="id" border stripe empty-text="暂无采购任务" :row-class-name="retryRowClass" @selection-change="handleRetrySelection">
<el-table-column type="selection" width="48" :selectable="isRetrySelectable" />
<el-table-column type="selection" width="48" :selectable="isSelectable" />
<el-table-column label="任务" width="105"><template #default="{ row }"><div class="primary">CG-{{ row.id }}</div><el-tag size="small" effect="plain" :type="row.executionMode === 'live' ? 'success' : 'info'">{{ modeLabel(row.executionMode) }}</el-tag></template></el-table-column>
<el-table-column label="类型" width="110"><template #default="{ row }"><el-tag size="small" effect="plain" :type="taskTypeTag(row.taskType)">{{ taskTypeLabel(row.taskType) }}</el-tag></template></el-table-column>
<el-table-column label="蝦皮订单号" min-width="160"><template #default="{ row }"><span class="order-number">{{ row.shopeeOrderNoSnapshot || '—' }}</span></template></el-table-column>
@@ -26,6 +26,7 @@
<el-table-column label="状态" width="150" align="center"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag><el-tooltip v-if="row.status === 'failed' && !row.retryable" :content="row.retryDisabledReason || '当前任务不能重试'" placement="top"><span class="retry-disabled" tabindex="0">不可重试</span></el-tooltip></template></el-table-column>
<el-table-column label="规格匹配" min-width="150"><template #default="{ row }"><el-tag :type="matchingType(row.matching?.status)" effect="plain">{{ matchingLabel(row.matching?.status) }}</el-tag><div v-if="row.matching && !row.matching.executable" class="muted">{{ row.matching.reason || '完成前不可执行' }}</div></template></el-table-column>
<el-table-column label="订单 / 支付" min-width="170"><template #default="{ row }"><div>{{ row.pddOrderNo || '尚未取得订单号' }}</div><div class="muted">{{ paymentLabel(row.paymentReviewStatus) }}</div></template></el-table-column>
<el-table-column label="SYB 单号回填" min-width="170"><template #default="{ row }"><span>{{ orderWritebackLabel(row.orderWriteback?.status) }}</span><div class="muted">{{ row.orderWriteback?.reason || '' }}</div></template></el-table-column>
<el-table-column label="创建时间" width="170"><template #default="{ row }">{{ formatTime(row.createdAt) }}</template></el-table-column>
<el-table-column label="操作" width="90" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openDetail(row)">详情</el-button></template></el-table-column>
</el-table>
@@ -59,9 +60,11 @@
<div class="state-card"><span>订单</span><strong>{{ detail.task.pddOrderNo || '尚未取得订单号' }}</strong><small>{{ detail.task.orderSubmittedAt ? `下单时间 ${formatTime(detail.task.orderSubmittedAt)}` : '获取订单号失败即采购失败' }}</small><small>实付价格:{{ detail.task.pddOrderAmountCent == null ? '尚未获取' : priceText(detail.task.pddOrderAmountCent, 'CNY') }}</small></div>
<div class="state-card"><span>支付复核</span><strong>{{ paymentLabel(detail.task.paymentReviewStatus) }}</strong><small>{{ detail.task.paymentReviewedAt ? formatTime(detail.task.paymentReviewedAt) : '仅人工记录,系统不支付' }}</small></div>
<div class="state-card"><span>物流</span><strong>{{ logisticsLabel(detail.task.logisticsStatus) }}</strong><small>{{ detail.task.trackingNo || '尚未取得快递单号' }}</small></div>
<div v-if="detail.task.taskType !== 'stock'" class="state-card"><span>SYB 回填</span><strong>{{ writebackLabel(detail.task.writebackStatus) }}</strong><small>{{ detail.task.writebackAt ? formatTime(detail.task.writebackAt) : '由独立回填流程处理' }}</small></div>
<div v-if="detail.task.taskType !== 'stock'" class="state-card"><span>SYB 物流回填</span><strong>{{ writebackLabel(detail.task.writebackStatus) }}</strong><small>{{ detail.task.writebackAt ? formatTime(detail.task.writebackAt) : '由独立回填流程处理' }}</small></div>
</div></section>
<section><h3>SYB 单号回填</h3><div class="state-card"><strong>{{ orderWritebackLabel(detail.task.orderWriteback?.status) }}</strong><small>{{ detail.task.orderWriteback?.reason || '仅回填 PDD 订单号,不回填实付价格,不要求已支付。' }}</small><small>完成时间:{{ formatTime(detail.task.orderWriteback?.completedAt) }}</small><div><el-button v-if="canOrderWriteback(detail.task)" type="primary" plain :loading="orderWriteback.saving" @click="submitOrderWriteback([detail.task])">{{ detail.task.orderWriteback.status === 'not_started' ? '回填 SYB 单号' : '重试回填 SYB 单号' }}</el-button><el-button :loading="detail.loading" @click="refreshOrderWritebackDetail">刷新状态</el-button></div></div></section>
<section><h3>执行记录</h3><el-table :data="detail.attempts" border size="small" empty-text="暂无执行记录">
<el-table-column label="次数" width="70"><template #default="{ row }">#{{ row.attemptNumber }}</template></el-table-column><el-table-column label="阶段" prop="phase" min-width="120" /><el-table-column label="状态" prop="status" min-width="110" /><el-table-column label="结果" min-width="150"><template #default="{ row }">{{ row.resultType || row.errorCode || '—' }}</template></el-table-column><el-table-column label="说明" min-width="220"><template #default="{ row }">{{ row.errorMessage || '—' }}</template></el-table-column><el-table-column label="开始时间" width="170"><template #default="{ row }">{{ formatTime(row.startedAt || row.createdAt) }}</template></el-table-column>
</el-table></section>
@@ -112,6 +115,12 @@
<template #footer><el-button @click="retryResult.open=false">留在当前页</el-button><el-button v-if="firstRetriedTask" type="primary" @click="openRetriedTask(firstRetriedTask)">查看成功任务</el-button></template>
</el-dialog>
<el-dialog v-model="orderWriteback.open" title="SYB 单号回填提交结果" width="760px" :close-on-click-modal="false">
<el-alert title="待回填表示已受理,不代表 SYB 已写入。请刷新列表或详情查看最终状态;冲突项不会覆盖原单号。" type="info" :closable="false" show-icon class="notice" />
<el-table :data="orderWriteback.items" border size="small" max-height="360" empty-text="暂无处理结果"><el-table-column label="任务" width="110"><template #default="{ row }">CG-{{ row.taskId }}</template></el-table-column><el-table-column label="结果" width="130"><template #default="{ row }">{{ orderWritebackLabel(row.result) }}</template></el-table-column><el-table-column label="说明" min-width="280" prop="reason" /></el-table>
<template #footer><el-button @click="orderWriteback.open=false">关闭</el-button><el-button type="primary" :loading="loading" @click="load">刷新列表状态</el-button></template>
</el-dialog>
<el-dialog v-model="cancelDialog.open" title="标记采购任务已取消" width="520px" :close-on-click-modal="false">
<el-alert title="这里只记录人工已在 PDD 取消的结果,不会替你操作 PDD。" type="info" :closable="false" show-icon class="notice" />
<el-form label-position="top"><el-form-item label="取消原因(必填)"><el-input v-model="cancelDialog.reason" type="textarea" :rows="3" maxlength="300" show-word-limit /></el-form-item></el-form>
@@ -133,7 +142,7 @@
<script>
import { ElMessage, ElMessageBox } from 'element-plus'
import { RefreshLeft, Search } from '@element-plus/icons-vue'
import { authorizeRepurchase, cancelPurchaseTask, choosePurchaseMatching, getPurchaseTask, listPurchaseTasks, requeuePurchaseMatching, resolveUnknownPurchaseTask, retryPurchaseTasksBatch, reviewPurchasePayment, selectPurchaseWriteback } from '@/api/goauto/purchase-tasks'
import { authorizeRepurchase, cancelPurchaseTask, choosePurchaseMatching, getPurchaseTask, listPurchaseTasks, requeuePurchaseMatching, resolveUnknownPurchaseTask, retryPurchaseTasksBatch, reviewPurchasePayment, selectPurchaseWriteback, writebackPurchaseOrderNumbers } from '@/api/goauto/purchase-tasks'
import { createRequestId } from '@/utils/request-id'
const statusOptions = [
@@ -147,6 +156,7 @@ export default {
data() {
return {
loading: false, loadError: '', tasks: [], total: 0, statuses: statusOptions,
orderWriteback: { open: false, saving: false, items: [] },
query: { page: 1, pageSize: 20, taskId: '', taskType: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '' },
detail: { open: false, loading: false, task: null, attempts: [], matching: null },
matchingAction: { saving: false }, matchingDialog: { open: false, saving: false, color: '', size: '' },
@@ -159,6 +169,8 @@ export default {
}
},
computed: {
retryCandidates() { return this.retrySelection.filter(this.isRetrySelectable) },
orderWritebackCandidates() { return this.retrySelection.filter(this.canOrderWriteback) },
detailAlert() {
const task = this.detail.task || {}
if (task.status === 'order_result_unknown') return { type: 'error', title: '订单结果未知,禁止重复采购', description: '请先到 PDD 人工核对订单,再在本页处理结果。' }
@@ -214,9 +226,26 @@ export default {
formatTime(value) { if (!value) return '—'; return new Date(value).toLocaleString('zh-CN', { hour12: false }) },
requestId() { return createRequestId() },
isRetrySelectable(row) { return row.taskType !== 'stock' && row.status === 'failed' && row.retryable === true },
canOrderWriteback(row) { return row.orderWriteback?.canSubmit === true },
isSelectable(row) { return this.isRetrySelectable(row) || this.canOrderWriteback(row) },
orderWritebackLabel(value) { return { not_started: '未发起', pending: '待回填', running: '回填中', succeeded: '已回填', failed: '回填失败', unknown: '结果待核对', conflict: '单号冲突', skipped: '已跳过', not_applicable: '不适用' }[value] || '未记录' },
async refreshOrderWritebackDetail() { this.detail.loading = true; try { await this.refreshDetail() } finally { this.detail.loading = false } },
async submitOrderWriteback(rows) {
if (this.orderWriteback.saving) return
const ids = rows.filter(this.canOrderWriteback).map(row => row.id)
if (!ids.length) return
this.orderWriteback.saving = true
try {
const r = await writebackPurchaseOrderNumbers({ requestId: this.requestId(), purchaseTaskIds: ids })
this.orderWriteback.items = r.data.items || []
this.orderWriteback.open = true
await this.load()
if (this.detail.open && this.detail.task) await this.refreshDetail()
} catch (error) { ElMessage.error('回填提交结果未确认,请刷新任务状态后再试。') } finally { this.orderWriteback.saving = false }
},
retryRowClass({ row }) { return row.status === 'failed' && !row.retryable ? 'retry-disabled-row' : '' },
handleRetrySelection(rows) { this.retrySelection = rows },
openRetryDialog() { if (!this.retrySelection.length) return; this.retryDialog = { open: true, saving: false, items: [...this.retrySelection] } },
openRetryDialog() { if (!this.retryCandidates.length) return; this.retryDialog = { open: true, saving: false, items: [...this.retryCandidates] } },
async submitRetry() {
if (!this.retryDialog.items.length) return
this.retryDialog.saving = true
@@ -251,5 +280,6 @@ export default {
</script>
<style lang="scss" scoped>
.batch-actions{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:16px}.batch-actions .el-button+.el-button{margin-left:0}
.page-card{min-height:calc(100vh - 124px)}.search-form{display:flex;align-items:flex-end;padding:16px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc;white-space:nowrap}.retry-action{margin-left:auto;margin-right:0}.selected-copy{margin-right:10px;color:#606266}.selected-copy strong{color:#337ecc;font-variant-numeric:tabular-nums}.task-input{width:72px}.shopee-order-input{width:140px}.syb-input{width:100px}.order-input{width:120px}.order-number{font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.notice{margin-bottom:18px}.primary{color:#303133;font-weight:600}.muted{color:#909399;font-size:12px;line-height:1.5}.ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.image-search-tag{margin-left:6px;vertical-align:middle}.retry-disabled{display:block;margin-top:4px;color:#b88230;font-size:11px;line-height:1.2;cursor:help}.dialog-help{margin:14px 0 0;color:#606266;line-height:1.6}.drawer-body{min-height:240px;padding:0 4px 28px}.detail-heading{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:18px}.detail-heading>div{display:flex;align-items:center;gap:8px}.task-number{font-size:22px;font-weight:700;color:#1f2937}.drawer-body section{margin-top:24px}.drawer-body h3{margin:0 0 12px;color:#303133;font-size:17px}.state-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.state-card{display:flex;min-height:104px;flex-direction:column;gap:7px;padding:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}.state-card span,.state-card small{color:#606266}.state-card strong{color:#1f2937;font-size:16px;overflow-wrap:anywhere}.drawer-actions{position:sticky;bottom:0;z-index:2;display:flex;justify-content:flex-end;gap:8px;margin:24px -4px -28px;padding:16px 4px;background:#fff;border-top:1px solid #e5e7eb}:deep(.el-table .retry-disabled-row){color:#909399;background:#fafafa}:deep(.el-table .retry-disabled-row .el-checkbox){cursor:not-allowed}@media(max-width:1200px){.search-form{overflow-x:auto}.retry-action{margin-left:24px}}@media(max-width:768px){.search-form{white-space:normal}.state-grid{grid-template-columns:1fr}.detail-heading{align-items:flex-start;flex-direction:column}.drawer-actions{flex-wrap:wrap}}
</style>
@@ -0,0 +1,78 @@
import { expect, test } from '@playwright/test'
async function setup(page: any, state = 'not_started') {
await page.setViewportSize({ width: 1920, height: 1080 })
await page.context().addCookies([{ name: 'Admin-Token', value: 'test-only', domain: 'localhost', path: '/' }])
const requests: { path: string, body: any }[] = []
const items = [
{ id: 11, taskType: 'syb_order', executionMode: 'live', status: 'order_created', pddOrderNo: 'EXAMPLE-11', paymentReviewStatus: 'unpaid', orderWriteback: { status: state, canSubmit: ['not_started', 'failed', 'unknown'].includes(state), reason: state === 'conflict' ? 'SYB 已有不同单号,未覆盖' : '' } },
{ id: 12, taskType: 'syb_order', executionMode: 'live', status: 'failed', retryable: true, orderWriteback: { status: 'not_applicable', canSubmit: false, reason: '' } },
{ id: 13, taskType: 'stock', executionMode: 'live', status: 'order_created', orderWriteback: { status: 'not_applicable', canSubmit: false, reason: '' } }
]
await page.route('**/api/**', async route => {
const path = new URL(route.request().url()).pathname
if (path.endsWith('/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: ['admin'], name: '测试采购员', avatar: '', introduction: '', permissions: [] } } })
if (path.endsWith('/menurole')) return route.fulfill({ json: { code: 200, data: [{ path: '/collection-purchase', component: 'Layout', menuName: 'GoAutoCollectionPurchase', title: '采集采购', visible: '0', children: [{ path: '/purchase-tasks/index', component: '/goauto/purchase-tasks/index', menuName: 'GoAutoPurchaseTasks', title: '采购管理', visible: '0' }] }] } })
if (route.request().method() === 'POST') {
const body = route.request().postDataJSON()
requests.push({ path, body })
if (path.endsWith('/syb-order-writeback')) {
items[0].orderWriteback = { status: 'pending', canSubmit: false, reason: '' }
return route.fulfill({ json: { code: 200, data: { items: body.purchaseTaskIds.map((taskId: number) => ({ taskId, result: 'pending', reason: '已受理' })) } } })
}
if (path.endsWith('/batch-retry')) return route.fulfill({ json: { code: 200, data: { items: [], createdCount: 1, failedCount: 0 } } })
throw new Error('unexpected write: ' + path)
}
if (path.endsWith('/purchase-tasks/11')) return route.fulfill({ json: { code: 200, data: { task: items[0], attempts: [] } } })
if (path.endsWith('/purchase-tasks')) return route.fulfill({ json: { code: 200, data: { items, total: items.length } } })
return route.fulfill({ json: { code: 200, data: [] } })
})
await page.goto('/#/purchase-tasks/index')
await expect(page.getByText('CG-11', { exact: true })).toBeVisible()
return requests
}
test('混选任务分别回填与重试,不把已下单任务交给采购重试', async ({ page }) => {
const requests = await setup(page)
await page.locator('.el-table__header-wrapper .el-checkbox').click()
await expect(page.getByRole('button', { name: '重试(1)', exact: true })).toBeEnabled()
await expect(page.getByRole('button', { name: '批量回填 SYB(1)', exact: true })).toBeEnabled()
await page.getByRole('button', { name: '重试(1)', exact: true }).click()
const retry = page.getByRole('dialog', { name: '确认重试失败任务' })
await expect(retry.getByText('CG-12', { exact: true })).toBeVisible()
await expect(retry.getByText('CG-11', { exact: true })).toHaveCount(0)
await retry.getByRole('button', { name: '确认重试', exact: true }).click()
await expect.poll(() => requests.length).toBe(1)
expect(requests[0].body.taskIds).toEqual([12])
await page.getByRole('dialog', { name: '批量重试结果' }).getByRole('button', { name: '留在当前页' }).click()
await page.locator('.el-table__header-wrapper .el-checkbox').first().click()
await page.getByRole('button', { name: '批量回填 SYB(1)', exact: true }).click()
const result = page.getByRole('dialog', { name: 'SYB 单号回填提交结果' })
await expect(result.getByText('待回填', { exact: true })).toBeVisible()
expect(requests[1].body.purchaseTaskIds).toEqual([11])
expect(requests[1].body.requestId).toMatch(/^[0-9a-f-]{36}$/)
await result.getByRole('button', { name: '关闭', exact: true }).click()
await expect(result).toBeHidden()
await expect(page.getByRole('button', { name: '批量回填 SYB(0)', exact: true })).toBeDisabled()
})
test('未支付订单可从详情补偿回填,旧物流流程保持独立', async ({ page }) => {
const requests = await setup(page, 'failed')
await page.getByRole('button', { name: '详情', exact: true }).first().click()
const drawer = page.getByRole('dialog', { name: '采购任务详情' })
await expect(drawer.getByText('SYB 物流回填', { exact: true })).toBeVisible()
await drawer.getByRole('button', { name: '重试回填 SYB 单号', exact: true }).click()
await expect(page.getByRole('dialog', { name: 'SYB 单号回填提交结果' })).toBeVisible()
expect(requests).toHaveLength(1)
expect(requests[0].path).toContain('/syb-order-writeback')
expect(requests[0].body.purchaseTaskIds).toEqual([11])
})
test('冲突显示原因且没有覆盖按钮', async ({ page }) => {
await setup(page, 'conflict')
await page.getByRole('button', { name: '详情', exact: true }).first().click()
const drawer = page.getByRole('dialog', { name: '采购任务详情' })
await expect(drawer.getByText('单号冲突', { exact: true })).toBeVisible()
await expect(drawer.getByText('SYB 已有不同单号,未覆盖', { exact: true })).toBeVisible()
await expect(drawer.getByRole('button', { name: /回填 SYB 单号/ })).toHaveCount(0)
})