feat(#48): add SYB sync orchestration, import endpoint and page entry

Stage 2. Completes the path from the SYB API to the archive.

sybimport.Sync pages a date range day by day and folds each detail line
through the existing idempotent ApplyDetail. The paging loop is driven by
listTotal, never by the list response's own total, which live data
confirmed is the page's row count rather than the filtered total. A day
whose completeness cannot be proven — a short page, a total that drifted
while paging, a detail response missing an order — stops the run instead
of reporting a partial import as a whole one. Rows already written stay:
they are idempotent on (order_code, detail_id), so a re-run overwrites.

sybimport.Connect reuses the cached session and only discards it when SYB
explicitly says it is invalid. Caching the numeric user id alongside the
cookies is required, not incidental: session validation calls
/am/user/get?id=, and a wrong id comes back as a business error rather
than a logout, so cookies alone can never be revalidated.

The import endpoint is single-flight and detached from the request
context, so closing the tab cannot abandon a half-finished range. It
reads only; no SYB write endpoint is reachable from GoAuto.

The page swaps its "no import entry" warning for a date-range dialog that
warns when the span is wide — a single week held over 7000 orders — and
reports partial progress when a run fails midway.

Verified: go build, go vet, go test ./... all pass. The drift check was
mutation-tested. Not verified: the MySQL migration for syb_session, and
any browser walkthrough.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
QiuSW
2026-08-19 16:56:00 +08:00
co-authored by Claude Opus 5
parent 4886e0d0a4
commit 7edb4e7b9f
11 changed files with 952 additions and 51 deletions
+6
View File
@@ -19,3 +19,9 @@ export function reparseSybProductsBatch(data) {
export function correctSybProduct(productId, data) {
return request({ url: `/api/admin/v1/syb-products/${productId}/correction`, method: 'patch', data })
}
// 从 SYB 拉取指定日期范围的货运单并入库。耗时可能很长(一周可达数千张),
// 所以单独放大超时,不用 request 的默认值。
export function importSybProducts(data) {
return request({ url: '/api/admin/v1/syb-products/import', method: 'post', data, timeout: 30 * 60 * 1000 })
}
+78 -4
View File
@@ -2,8 +2,8 @@
<BasicLayout>
<template #wrapper>
<el-card class="page-card" shadow="never">
<el-alert title="本页暂无「从 SYB 导入」入口:服务端尚未接入顺云宝 ERP 的真实接口凭据,导入功能待后续工单补充。当前数据仅能通过服务端内部调用写入。" type="warning" :closable="false" show-icon class="notice" />
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="search">
<el-form-item><el-button type="primary" :icon="Download" @click="openImport">从 SYB 导入</el-button></el-form-item>
<el-form-item label="搜索"><el-input v-model="query.keyword" placeholder="货运单号、虾皮商品ID或商品标题" clearable @keyup.enter="search" /></el-form-item>
<el-form-item label="解析状态"><el-select v-model="query.parseStatus" clearable placeholder="全部" style="width: 140px"><el-option label="成功" value="success" /><el-option label="存疑" value="uncertain" /><el-option label="失败" value="failed" /></el-select></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>
@@ -30,6 +30,36 @@
<p class="scope-note">本页不展示采购任务、PDD 订单号、快递单号和支付状态。</p>
</el-card>
<!-- 从 SYB 导入 -->
<el-dialog v-model="importDialog.open" title="从 SYB 导入" width="560px" :close-on-click-modal="false">
<el-alert title="按货运单创建日期拉取,只读取 SYB 数据,不会修改 SYB 上的任何内容。重复导入相同明细执行覆盖,不产生重复记录。" type="info" :closable="false" show-icon class="notice" />
<el-form label-width="96px" class="dialog-form">
<el-form-item label="日期范围">
<el-date-picker v-model="importDialog.range" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="起始日期" end-placeholder="结束日期" :disabled-date="disableFutureDate" style="width:100%" />
</el-form-item>
</el-form>
<el-alert v-if="importDialog.spanDays > 7" :title="`已选择 ${importDialog.spanDays} 天。实测一周可达 7000 张以上货运单,范围过大会触发单次同步上限而失败,建议分批导入。`" type="warning" :closable="false" show-icon class="notice" />
<el-alert v-if="importDialog.saving" title="导入进行中,可能需要数分钟。请不要关闭页面。" type="warning" :closable="false" show-icon class="notice" />
<template #footer>
<el-button :disabled="importDialog.saving" @click="importDialog.open = false">取消</el-button>
<el-button type="primary" :loading="importDialog.saving" :disabled="!importDialog.range || importDialog.range.length !== 2" @click="submitImport">开始导入</el-button>
</template>
</el-dialog>
<!-- 导入结果 -->
<el-dialog v-model="importResult.open" title="导入结果" width="560px">
<el-alert v-if="importResult.failed" :title="importResult.message" type="error" :closable="false" show-icon class="notice" />
<el-alert v-else title="导入完成" type="success" :closable="false" show-icon class="notice" />
<el-descriptions :column="2" border class="dialog-form">
<el-descriptions-item label="日期范围">{{ importResult.report.from }} ~ {{ importResult.report.to }}</el-descriptions-item>
<el-descriptions-item label="货运单">{{ importResult.report.orderCount }} 张</el-descriptions-item>
<el-descriptions-item label="商品明细">{{ importResult.report.detailCount }} 条</el-descriptions-item>
<el-descriptions-item label="新增 / 覆盖">{{ importResult.report.created }} / {{ importResult.report.updated }}</el-descriptions-item>
</el-descriptions>
<p v-if="importResult.failed" class="muted">中途失败时已写入的数据会保留;按同一范围重新导入会覆盖,不会产生重复记录。</p>
<template #footer><el-button type="primary" @click="importResult.open = false">知道了</el-button></template>
</el-dialog>
<!-- 批量重新解析 -->
<el-dialog v-model="reparseBatch.open" title="批量重新解析" width="560px" :close-on-click-modal="false">
<el-alert title="适用于解析规则更新后批量重跑,只读取已保存的原始 JSON,不请求 SYB 接口。" type="info" :closable="false" show-icon class="notice" />
@@ -104,16 +134,18 @@
<script>
import { ElMessage } from 'element-plus'
import { Search, RefreshLeft } from '@element-plus/icons-vue'
import { listSybProducts, getSybProduct, reparseSybProduct, reparseSybProductsBatch, correctSybProduct } from '@/api/goauto/syb-products'
import { Search, RefreshLeft, Download } from '@element-plus/icons-vue'
import { listSybProducts, getSybProduct, reparseSybProduct, reparseSybProductsBatch, correctSybProduct, importSybProducts } from '@/api/goauto/syb-products'
export default {
name: 'GoAutoSybProducts',
setup() { return { Search, RefreshLeft } },
setup() { return { Search, RefreshLeft, Download } },
data() {
return {
loading: false, products: [], selectedProducts: [], total: 0,
query: { page: 1, pageSize: 20, keyword: '', parseStatus: '' },
importDialog: { open: false, saving: false, range: null, spanDays: 0 },
importResult: { open: false, failed: false, message: '', report: {}},
reparseBatch: { open: false, saving: false, ids: [], force: false },
reparseResult: { open: false, results: [], reparsedCount: 0, skippedCount: 0, unchangedCount: 0 },
detail: { open: false, loading: false, item: null },
@@ -126,6 +158,14 @@ export default {
try { return JSON.stringify(JSON.parse(this.detail.item.rawJson), null, 2) } catch { return this.detail.item.rawJson }
}
},
watch: {
// 天数必须跟着日期选择实时更新,否则超范围的提示只在打开弹窗那一刻算过一次。
'importDialog.range'(range) {
if (!range || range.length !== 2) { this.importDialog.spanDays = 0; return }
const [from, to] = range.map((d) => new Date(`${d}T00:00:00`))
this.importDialog.spanDays = Math.round((to - from) / 86400000) + 1
}
},
created() { this.load() },
methods: {
async load() {
@@ -153,6 +193,39 @@ export default {
window.open(route.href, '_blank', 'noopener')
},
// ---------------- 从 SYB 导入 ----------------
disableFutureDate(date) { return date.getTime() > Date.now() },
openImport() {
const today = new Date()
const start = new Date(today.getTime() - 2 * 86400000)
const fmt = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
this.importDialog = { open: true, saving: false, range: [fmt(start), fmt(today)], spanDays: 3 }
},
async submitImport() {
const [dateFrom, dateTo] = this.importDialog.range
this.importDialog.saving = true
try {
const r = await importSybProducts({ dateFrom, dateTo })
this.importDialog.open = false
this.importResult = { open: true, failed: false, message: '', report: r.data || {}}
ElMessage.success('导入完成')
this.load()
} catch (error) {
// 中途失败时服务端仍会带回部分报告,让操作员看到导入到哪一步了。
const payload = error?.response?.data || {}
this.importDialog.open = false
this.importResult = {
open: true, failed: true,
message: payload.message || error?.message || '导入失败',
report: payload.data || {}
}
// 已经写入的部分要能立刻看到。
this.load()
} finally {
this.importDialog.saving = false
}
},
// ---------------- 批量重新解析 ----------------
openReparseBatch() { this.reparseBatch = { open: true, saving: false, ids: this.selectedProducts.map(item => item.id), force: false } },
async submitReparseBatch() {
@@ -224,6 +297,7 @@ export default {
.link{color:#1677ff;cursor:pointer}
.scope-note{margin-top:12px;font-size:12px;color:#b91c1c}
.notice{margin-bottom:16px}
.dialog-form{margin-bottom:8px}
.purchase-note{margin:0 0 16px}
.drawer-body{padding:0 4px 24px}
.split{display:grid;grid-template-columns:1fr 1fr;gap:24px}