diff --git a/admin/service/syb.go b/admin/service/syb.go index 8cadb37..7ec11b9 100644 --- a/admin/service/syb.go +++ b/admin/service/syb.go @@ -37,9 +37,10 @@ func mappingContextVersion(c repository.SybOrderContext) string { } const ( - dateLayout = "2006-01-02" - maxSpecifiedSyncDays = 31 - defaultSybMaxMatches = 10000 + dateLayout = "2006-01-02" + maxSpecifiedSyncDays = 31 + defaultSybMaxMatches = 10000 + sybTodayListMaxAttempts = 3 ) // SybSyncOptions 表示一次同步的日期选择。页面必须明确传入两端日期;两端都空 @@ -149,44 +150,17 @@ type SybSyncDefaults struct { Warning string } -// DefaultSybSyncRange 通常预填最近三天;如果覆盖游标落后,则优先从游标 -// 当天连续补齐。缺口超过 31 天时只选最早一段,成功后下一次继续。 -func DefaultSybSyncRange(db *sql.DB, now time.Time) (SybSyncDefaults, error) { +// DefaultSybSyncRange 固定预填昨天到今天。覆盖游标仍由同步服务维护,但不再 +// 改写采购员眼前的日期选择;需要补历史缺口时由采购员明确选择日期范围。 +func DefaultSybSyncRange(_ *sql.DB, now time.Time) (SybSyncDefaults, error) { today, err := time.Parse(dateLayout, dateOf(now)) if err != nil { return SybSyncDefaults{}, fmt.Errorf("计算顺运宝今天日期失败: %w", err) } - recentStart := today.AddDate(0, 0, -2) - defaults := SybSyncDefaults{From: recentStart.Format(dateLayout), To: today.Format(dateLayout)} - - lastSyncedAt, found, err := repository.GetSybLastSyncedAt(db) - if err != nil { - return SybSyncDefaults{}, err - } - if !found { - return defaults, nil - } - lastTime, ok := model.ParseISO(lastSyncedAt) - if !ok { - return SybSyncDefaults{}, fmt.Errorf("上次同步时间 %q 解析失败", lastSyncedAt) - } - coveredDate, err := time.Parse(dateLayout, dateOf(lastTime)) - if err != nil || !coveredDate.Before(recentStart) { - return defaults, nil - } - - segmentEnd := coveredDate.AddDate(0, 0, maxSpecifiedSyncDays-1) - defaults.From = coveredDate.Format(dateLayout) - if segmentEnd.Before(today) { - defaults.To = segmentEnd.Format(dateLayout) - defaults.Warning = fmt.Sprintf( - "存在较长的未同步区间,已先选择最早 %d 天(%s 至 %s);本段成功后请继续同步下一段。", - maxSpecifiedSyncDays, defaults.From, defaults.To) - return defaults, nil - } - defaults.To = today.Format(dateLayout) - defaults.Warning = fmt.Sprintf("检测到上次同步停在 %s,已自动扩展开始日期以补齐缺口。", defaults.From) - return defaults, nil + return SybSyncDefaults{ + From: today.AddDate(0, 0, -1).Format(dateLayout), + To: today.Format(dateLayout), + }, nil } // cursorISOForDate 把“已连续覆盖到哪一天”保存成 UTC ISO。下一次仍从该日 @@ -482,6 +456,77 @@ func roundYuanToCent(yuan float64) int64 { return int64(math.Round(yuan * 100)) } +// sybDailyListResult 是一次单日列表分页尝试取得的数据。每次重试都重新创建, +// 防止把前一次已发生 offset 位移的 ID 混进下一次。 +type sybDailyListResult struct { + stockByID map[int64]syb.StockRow + orderedIDs []int64 + observedTotal int +} + +// sybListDriftError 表示请求本身成功,但分页期间的列表没有形成稳定快照。 +// 只有今天允许有限重试;历史日期遇到它仍然立即失败。 +type sybListDriftError struct { + message string +} + +func (e *sybListDriftError) Error() string { return e.message } + +// loadSybDailyList 拉取一天的全部列表,并核对页长、分页前后总数及唯一 ID。 +// 发生快照漂移时仍返回本次已经取得的合法 ID,供今天最后一次尝试在完整拉取 +// 明细后安全 upsert;调用方不得因此把本次同步标记为成功或推进游标。 +func loadSybDailyList(ctx context.Context, client *syb.Client, date string, pageSize, expectedTotal int) (sybDailyListResult, error) { + result := sybDailyListResult{ + stockByID: make(map[int64]syb.StockRow, expectedTotal), + orderedIDs: make([]int64, 0, expectedTotal), + observedTotal: expectedTotal, + } + for start := 0; start < expectedTotal; start += pageSize { + pageIndex := start/pageSize + 1 + rows, pageCount, err := client.ListPage(ctx, date, date, start, pageIndex, pageSize) + if err != nil { + return result, fmt.Errorf("拉取 %s 货运单列表第 %d 页失败(已获取 %d/%d 张,本次同步整体作废,"+ + "下次会从同一个起始日期重新拉,靠 upsert 幂等不会重复计数): %w", + date, pageIndex, len(result.orderedIDs), expectedTotal, err) + } + for _, row := range rows { + if _, duplicate := result.stockByID[row.ID]; duplicate { + continue + } + result.stockByID[row.ID] = row + result.orderedIDs = append(result.orderedIDs, row.ID) + } + + expectedPageCount := min(pageSize, expectedTotal-start) + if pageCount != expectedPageCount || len(rows) != expectedPageCount { + afterTotal, totalErr := client.ListTotal(ctx, date, date, pageSize) + if totalErr != nil { + return result, fmt.Errorf("分页异常后重新查询 %s 货运单总数失败: %w", date, totalErr) + } + result.observedTotal = afterTotal + return result, &sybListDriftError{message: fmt.Sprintf( + "%s 货运单列表第 %d 页不完整:预期 %d 行,实际 %d 行", + date, pageIndex, expectedPageCount, len(rows))} + } + } + + afterTotal, err := client.ListTotal(ctx, date, date, pageSize) + if err != nil { + return result, fmt.Errorf("分页后重新查询 %s 货运单总数失败: %w", date, err) + } + result.observedTotal = afterTotal + if afterTotal != expectedTotal { + return result, &sybListDriftError{message: fmt.Sprintf( + "%s 货运单总数在分页期间从 %d 变为 %d", date, expectedTotal, afterTotal)} + } + if len(result.orderedIDs) != expectedTotal { + return result, &sybListDriftError{message: fmt.Sprintf( + "%s 货运单列表不完整:预期 %d 张,分页后只有 %d 个唯一 ID", + date, expectedTotal, len(result.orderedIDs))} + } + return result, nil +} + // RunSybSync 执行一次完整的顺运宝货运单同步。 // // `[必须]` 调用方负责: @@ -575,7 +620,12 @@ func RunSybSyncWithOptions(ctx context.Context, db *sql.DB, client *syb.Client, } plans := make([]dailyPlan, 0, len(dates)) allTotal := 0 + containsToday := false + today := dateOf(now) for _, date := range dates { + if date == today { + containsToday = true + } total, totalErr := client.ListTotal(ctx, date, date, pageSize) if totalErr != nil { report.Err = fmt.Errorf("查询 %s 货运单总数失败: %w", date, totalErr) @@ -597,7 +647,9 @@ func RunSybSyncWithOptions(ctx context.Context, db *sql.DB, client *syb.Client, allTotal += total plans = append(plans, dailyPlan{date: date, total: total}) } - if allTotal == 0 { + // 历史范围全为 0 时可以直接结束。范围包含今天时仍再做一次列表快照 + // 校验,避免预检刚返回 0 就有新单进入而被当成完整成功。 + if allTotal == 0 && !containsToday { report.FinishedAt = time.Now().UTC() if advanceCursor { if err := repository.SetSybLastSyncedAt(db, cursorAt); err != nil { @@ -620,54 +672,64 @@ func RunSybSyncWithOptions(ctx context.Context, db *sql.DB, client *syb.Client, // 的),但"这次同步整体算成功"这件事不能发生,否则漏掉的单永远补不回来。 const detailBatch = 100 for _, plan := range plans { - if plan.total == 0 { + if plan.total == 0 && plan.date != today { continue } - stockByID := map[int64]syb.StockRow{} - orderedIDs := make([]int64, 0, plan.total) - for start := 0; start < plan.total; start += pageSize { - pageIndex := start/pageSize + 1 - rows, pageCount, listErr := client.ListPage(ctx, plan.date, plan.date, start, pageIndex, pageSize) - if listErr != nil { - report.Err = fmt.Errorf("拉取 %s 货运单列表第 %d 页失败(已获取 %d/%d 张,本次同步整体作废,"+ - "下次会从同一个起始日期重新拉,靠 upsert 幂等不会重复计数): %w", - plan.date, pageIndex, len(orderedIDs), plan.total, listErr) + + attempts := 1 + if plan.date == today { + attempts = sybTodayListMaxAttempts + } + var listResult sybDailyListResult + var unstableTodayErr error + for attempt := 1; attempt <= attempts; attempt++ { + listResult, err = loadSybDailyList(ctx, client, plan.date, pageSize, plan.total) + if err == nil { + unstableTodayErr = nil + break + } + var driftErr *sybListDriftError + if plan.date != today || !errors.As(err, &driftErr) { + report.Err = fmt.Errorf("%w;本次同步停止且不推进游标", err) report.FinishedAt = time.Now().UTC() return report } - expectedPageCount := min(pageSize, plan.total-start) - if pageCount != expectedPageCount || len(rows) != expectedPageCount { - report.Err = fmt.Errorf("%s 货运单列表第 %d 页不完整:预期 %d 行,实际 %d 行,"+ - "本次同步停止且不推进游标", plan.date, pageIndex, expectedPageCount, len(rows)) + + unstableTodayErr = driftErr + if attempt == attempts { + break + } + if listResult.observedTotal < 0 { + report.Err = fmt.Errorf("重新查询 %s 货运单总数返回负数 %d", + plan.date, listResult.observedTotal) report.FinishedAt = time.Now().UTC() return report } - for _, row := range rows { - if _, dup := stockByID[row.ID]; dup { - continue - } - stockByID[row.ID] = row - orderedIDs = append(orderedIDs, row.ID) + otherDatesTotal := allTotal - plan.total + if listResult.observedTotal > maxMatches-otherDatesTotal { + report.Err = fmt.Errorf( + "今天货运单变化后日期范围 %s ~ %s 的总数超过单次同步上限 %d,"+ + "已停止自动重试,请缩小日期范围或调大 max_matches", + from, to, maxMatches) + report.FinishedAt = time.Now().UTC() + return report } + allTotal = otherDatesTotal + listResult.observedTotal + plan.total = listResult.observedTotal } - afterTotal, totalErr := client.ListTotal(ctx, plan.date, plan.date, pageSize) - if totalErr != nil { - report.Err = fmt.Errorf("分页后重新查询 %s 货运单总数失败: %w", plan.date, totalErr) - report.FinishedAt = time.Now().UTC() - return report - } - if afterTotal != plan.total { - report.Err = fmt.Errorf("%s 货运单总数在分页期间从 %d 变为 %d,"+ - "为防止 offset 分页漏单,本次同步停止且不推进游标", plan.date, plan.total, afterTotal) - report.FinishedAt = time.Now().UTC() - return report - } - if len(orderedIDs) != plan.total { - report.Err = fmt.Errorf("%s 货运单列表不完整:预期 %d 张,分页后只有 %d 个唯一 ID,"+ - "本次同步停止且不推进游标", plan.date, plan.total, len(orderedIDs)) + otherDatesTotal := allTotal - plan.total + largestTodayCount := max(listResult.observedTotal, len(listResult.orderedIDs)) + if largestTodayCount < 0 || largestTodayCount > maxMatches-otherDatesTotal { + report.Err = fmt.Errorf( + "今天货运单变化后日期范围 %s ~ %s 的总数超过单次同步上限 %d,"+ + "未保存本次超限数据,请缩小日期范围或调大 max_matches", + from, to, maxMatches) report.FinishedAt = time.Now().UTC() return report } + + stockByID := listResult.stockByID + orderedIDs := listResult.orderedIDs report.StockCount += len(orderedIDs) for i := 0; i < len(orderedIDs); i += detailBatch { @@ -698,6 +760,14 @@ func RunSybSyncWithOptions(ctx context.Context, db *sql.DB, client *syb.Client, } } } + if unstableTodayErr != nil { + report.Err = fmt.Errorf( + "%s 当天货运单在连续 %d 次分页期间仍有变化;已保存最后一次取得的 %d 张货运单完整明细,"+ + "本次未形成稳定快照且不推进游标,下次同步将继续覆盖当天:%w", + plan.date, sybTodayListMaxAttempts, len(orderedIDs), unstableTodayErr) + report.FinishedAt = time.Now().UTC() + return report + } } // ④ 全部成功,才更新 last_synced_at。 diff --git a/admin/service/syb_test.go b/admin/service/syb_test.go index 457a73e..1c2f84f 100644 --- a/admin/service/syb_test.go +++ b/admin/service/syb_test.go @@ -120,33 +120,23 @@ func TestNewSybSyncOptions_指定日期校验(t *testing.T) { } } -func TestDefaultSybSyncRange_最近三天与缺口分段(t *testing.T) { +func TestDefaultSybSyncRange_固定为昨天到今天(t *testing.T) { now := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC) - t.Run("没有游标默认最近三天", func(t *testing.T) { + t.Run("没有游标", func(t *testing.T) { db := newSyncTestDB(t) got, err := DefaultSybSyncRange(db, now) - if err != nil || got.From != "2026-08-07" || got.To != "2026-08-09" || got.Warning != "" { + if err != nil || got.From != "2026-08-08" || got.To != "2026-08-09" || got.Warning != "" { t.Fatalf("默认范围错误: got=%+v err=%v", got, err) } }) - t.Run("短缺口自动扩展到今天", func(t *testing.T) { - db := newSyncTestDB(t) - if err := repository.SetSybLastSyncedAt(db, "2026-08-05T00:00:00Z"); err != nil { - t.Fatal(err) - } - got, err := DefaultSybSyncRange(db, now) - if err != nil || got.From != "2026-08-05" || got.To != "2026-08-09" || got.Warning == "" { - t.Fatalf("短缺口范围错误: got=%+v err=%v", got, err) - } - }) - t.Run("长缺口优先选择最早三十一天", func(t *testing.T) { + t.Run("旧游标不改写页面默认范围", func(t *testing.T) { db := newSyncTestDB(t) if err := repository.SetSybLastSyncedAt(db, "2026-07-01T00:00:00Z"); err != nil { t.Fatal(err) } got, err := DefaultSybSyncRange(db, now) - if err != nil || got.From != "2026-07-01" || got.To != "2026-07-31" || !strings.Contains(got.Warning, "继续同步下一段") { - t.Fatalf("长缺口分段错误: got=%+v err=%v", got, err) + if err != nil || got.From != "2026-08-08" || got.To != "2026-08-09" || got.Warning != "" { + t.Fatalf("有旧游标时默认范围错误: got=%+v err=%v", got, err) } }) } @@ -772,6 +762,168 @@ func TestRunSybSync_跨日范围按天同步并汇总(t *testing.T) { } } +type growingTodayServerState struct { + historyListCalls int + todayListCalls int + currentToday int +} + +// fakeGrowingTodaySybServer 模拟“历史日稳定、今天在每次分页时新增一张单”。 +// growAttempts=2 表示前两次漂移、第三次稳定;=3 表示三次都漂移。 +func fakeGrowingTodaySybServer(t *testing.T, today string, growAttempts int) (*httptest.Server, *growingTodayServerState) { + t.Helper() + state := &growingTodayServerState{currentToday: 1} + requestDate := func(body map[string]any) string { + queries, ok := body["queries"].([]any) + if !ok || len(queries) == 0 { + t.Fatalf("同步请求缺少 queries: %#v", body) + } + query, ok := queries[0].(map[string]any) + if !ok { + t.Fatalf("同步请求日期条件格式错误: %#v", queries[0]) + } + rangeText, _ := query["dvalue"].(string) + return strings.SplitN(rangeText, ",", 2)[0] + } + listRows := func(count int, idOffset int64) []map[string]any { + rows := make([]map[string]any, 0, count) + for i := 1; i <= count; i++ { + id := idOffset + int64(i) + rows = append(rows, map[string]any{"id": id, "code": fmt.Sprintf("ORDER-%d", id)}) + } + return rows + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/am/stock/listTotal": + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("解析 listTotal 请求失败: %v", err) + } + if requestDate(body) == today { + writeEnvelope(t, w, true, "ok", state.currentToday, nil) + } else { + writeEnvelope(t, w, true, "ok", 1, nil) + } + case "/am/stock/list": + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("解析 list 请求失败: %v", err) + } + if requestDate(body) == today { + state.todayListCalls++ + if state.todayListCalls <= growAttempts { + state.currentToday++ + } + rows := listRows(state.currentToday, 0) + writeEnvelope(t, w, true, "ok", map[string]any{"list": rows, "total": len(rows)}, nil) + } else { + state.historyListCalls++ + rows := listRows(1, 100) + writeEnvelope(t, w, true, "ok", map[string]any{"list": rows, "total": len(rows)}, nil) + } + case "/am/stock/detail/listByStock": + var body struct { + IDs []int64 `json:"ids"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("解析明细请求失败: %v", err) + } + list := make([]map[string]any, 0, len(body.IDs)) + for _, id := range body.IDs { + list = append(list, map[string]any{ + "id": id, "code": fmt.Sprintf("ORDER-%d", id), + "details": []map[string]any{{ + "id": id + 1000, "productId": id + 10000, "productQty": 1, + }}, + }) + } + writeEnvelope(t, w, true, "ok", map[string]any{"list": list}, nil) + default: + t.Errorf("测试假服务端没有实现这个路径: %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + return srv, state +} + +func TestRunSybSync_今天前两次漂移第三次稳定只重试今天(t *testing.T) { + const today = "2026-08-12" + srv, state := fakeGrowingTodaySybServer(t, today, 2) + defer srv.Close() + db := newSyncTestDB(t) + client, _ := syb.New(srv.URL) + + report := RunSybSyncWithOptions(context.Background(), db, client, + config.SybConfig{BaseURL: srv.URL, PageSize: 20, MaxMatches: 100}, + time.Date(2026, 8, 12, 8, 0, 0, 0, time.UTC), + SybSyncOptions{From: "2026-08-11", To: today}) + if report.Err != nil { + t.Fatalf("第三次稳定后应该成功: %v", report.Err) + } + if state.historyListCalls != 1 || state.todayListCalls != 3 { + t.Fatalf("只应重试今天: history=%d today=%d", state.historyListCalls, state.todayListCalls) + } + if report.StockCount != 4 || report.Created != 4 || !report.CursorAdvanced { + t.Fatalf("稳定后的统计或游标错误: %+v", report) + } +} + +func TestRunSybSync_今天连续三次漂移保存最后一次完整明细但不推进游标(t *testing.T) { + const today = "2026-08-12" + srv, state := fakeGrowingTodaySybServer(t, today, 3) + defer srv.Close() + db := newSyncTestDB(t) + client, _ := syb.New(srv.URL) + + report := RunSybSyncWithOptions(context.Background(), db, client, + config.SybConfig{BaseURL: srv.URL, PageSize: 20, MaxMatches: 100}, + time.Date(2026, 8, 12, 8, 0, 0, 0, time.UTC), + SybSyncOptions{From: "2026-08-11", To: today}) + if report.Err == nil || !strings.Contains(report.Err.Error(), "连续 3 次") || + !strings.Contains(report.Err.Error(), "下次同步将继续覆盖当天") { + t.Fatalf("持续漂移应该返回可操作提示,实际: %v", report.Err) + } + if state.historyListCalls != 1 || state.todayListCalls != 3 { + t.Fatalf("只应重试今天: history=%d today=%d", state.historyListCalls, state.todayListCalls) + } + if report.StockCount != 5 || report.Created != 5 || report.CursorAdvanced { + t.Fatalf("应保存历史 1 张和最后一次当天 4 张,但不推进游标: %+v", report) + } + if total, err := repository.CountSybOrdersTotal(db); err != nil || total != 5 { + t.Fatalf("已取得的完整明细应落库: total=%d err=%v", total, err) + } + if _, found, err := repository.GetSybLastSyncedAt(db); err != nil || found { + t.Fatalf("持续漂移不得建立或推进游标: found=%v err=%v", found, err) + } +} + +func TestRunSybSync_今天重试增长后仍受MaxMatches限制(t *testing.T) { + const today = "2026-08-12" + srv, state := fakeGrowingTodaySybServer(t, today, 3) + defer srv.Close() + db := newSyncTestDB(t) + client, _ := syb.New(srv.URL) + + report := RunSybSyncWithOptions(context.Background(), db, client, + config.SybConfig{BaseURL: srv.URL, PageSize: 20, MaxMatches: 3}, + time.Date(2026, 8, 12, 8, 0, 0, 0, time.UTC), + SybSyncOptions{From: "2026-08-11", To: today}) + if report.Err == nil || !strings.Contains(report.Err.Error(), "超过单次同步上限") { + t.Fatalf("今天增长突破上限时应该停止,实际: %v", report.Err) + } + if state.historyListCalls != 1 || state.todayListCalls != 2 { + t.Fatalf("超限后不应继续重试: history=%d today=%d", state.historyListCalls, state.todayListCalls) + } + if total, err := repository.CountSybOrdersTotal(db); err != nil || total != 1 { + t.Fatalf("只应保留超限前已完成的历史数据: total=%d err=%v", total, err) + } + if report.CursorAdvanced { + t.Fatal("今天增长超限不得推进游标") + } +} + func TestValidateDetailBatch_必须与请求货运单一一对应(t *testing.T) { cases := []struct { name string @@ -837,7 +989,7 @@ func TestRunSybSync_列表或明细不完整时不推进游标(t *testing.T) { client, _ := syb.New(srv.URL) report := RunSybSyncWithOptions(context.Background(), db, client, config.SybConfig{BaseURL: srv.URL, PageSize: 20, MaxMatches: 100, SyncFrom: "2026-07-28"}, - time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC), + time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC), SybSyncOptions{From: "2026-07-28", To: "2026-07-28"}) if report.Err == nil || !strings.Contains(report.Err.Error(), tc.wantErr) { t.Fatalf("错误应包含 %q,实际 %v", tc.wantErr, report.Err) diff --git a/docs/admin/05-ui-specification.md b/docs/admin/05-ui-specification.md index 4cbe53a..5fc2db0 100644 --- a/docs/admin/05-ui-specification.md +++ b/docs/admin/05-ui-specification.md @@ -466,13 +466,12 @@ Go 的 map 是无序的,不靠它定顺序的话,同一个商品每次刷新 ### 6.1 工具条 ```text -开始日期 [2026-08-07] 结束日期 [2026-08-09] [同步] [同步记录] +开始日期 [2026-08-08] 结束日期 [2026-08-09] [同步] [同步记录] [创建 PDD 采集任务] [创建采购任务] 处理阶段 [全部] 店铺 [___] 订单号 [___] [搜索] [删除] ``` -日期始终显示在主工具条,按 UTC+8 解释且两端都包含。通常默认最近 3 天;如果 -覆盖游标更早,则从游标当天开始补齐。缺口超过 31 天时只预选最早 31 天,并明确 -提示本段成功后继续下一段。 +日期始终显示在主工具条,按 UTC+8 解释且两端都包含。首次打开页面固定默认昨天 +到今天,不因覆盖游标位置自动扩大范围;需要补历史缺口时由采购员明确选择日期。 `[必须]` 「同步」是唯一同步入口: diff --git a/docs/admin/08-顺运宝接口.md b/docs/admin/08-顺运宝接口.md index 5edd0bd..7f8a6e3 100644 --- a/docs/admin/08-顺运宝接口.md +++ b/docs/admin/08-顺运宝接口.md @@ -199,14 +199,21 @@ Admin 默认 `max_matches = 10000`,可以在配置中调整;上限针对整 `[必须]` 每一页 `list.data.total` 必须等于该页 `list` 数组长度。非最后一页 必须返回 `length` 条,最后一页必须返回预检总数对应的剩余条数。每天翻页结束后 再次调用 `listTotal`,前后总数必须一致;全部页去重后的货运单 ID 数还必须等于 -预检总数。前后总数变化、短页、重复 ID 或唯一 ID 不足都视为本次同步失败, +预检总数。历史日期出现前后总数变化、短页、重复 ID 或唯一 ID 不足时立即失败, 不推进游标。 +今天的货运单会在同步期间持续新增。只有 UTC+8 下的今天发生上述快照漂移时, +允许只重试今天的列表分页,最多 3 次;已经完成的历史日期不得重复拉取,每次尝试 +也必须使用独立 ID 集合。第三次仍不稳定时,可以对最后一次取得的合法唯一 ID +读取完整明细并按既有 upsert 保存,但本次同步仍记为失败、明确提示当天未形成 +稳定快照且不推进游标,下一次继续覆盖今天。任何尝试都不得突破 `max_matches`; +网络/业务错误、非法 ID 或不完整明细不属于可放宽的快照漂移。 + ### 4.4 统一日期范围同步与覆盖游标 -页面只有一个同步入口,操作员确认工具条上的开始日和结束日后发起。通常默认最近 -3 天;如果 `last_synced_at` 更早,默认范围从覆盖游标当天开始。缺口超过 31 天时 -只选择最早一段 31 天,本段成功后下一次继续显示后一段。 +页面只有一个同步入口,操作员确认工具条上的开始日和结束日后发起。首次打开页面 +固定默认昨天到今天,不因 `last_synced_at` 更早而自动扩大范围;需要补历史缺口时 +由操作员明确选择日期,单次仍不得超过 31 天。 `[必须]` 首次成功同步以所选结束日建立覆盖游标。已有游标时,只有日期范围从 游标当天或更早开始、并且结束日在游标之后,全部成功后才推进游标。局部历史补拉