feat: 统一顺运宝日期同步并记录历史 (#59)
This commit is contained in:
+232
-28
@@ -36,8 +36,8 @@ const (
|
||||
defaultSybMaxMatches = 10000
|
||||
)
|
||||
|
||||
// SybSyncOptions 表示一次同步的日期选择。From/To 都为空是日常自动增量;
|
||||
// 两者都有值是操作员明确发起的指定日期补同步。
|
||||
// SybSyncOptions 表示一次同步的日期选择。页面必须明确传入两端日期;两端都空
|
||||
// 仅保留给旧版内部调用兼容。
|
||||
//
|
||||
// `[必须]` 不允许只填一端。把“半个范围”悄悄退化成自动增量,会让操作员
|
||||
// 以为补拉了指定日期,实际却跑了另一段数据。
|
||||
@@ -46,7 +46,7 @@ type SybSyncOptions struct {
|
||||
To string
|
||||
}
|
||||
|
||||
// IsSpecified 判断这是不是指定日期同步。
|
||||
// IsSpecified 判断是否明确给出了日期范围。
|
||||
func (o SybSyncOptions) IsSpecified() bool {
|
||||
return strings.TrimSpace(o.From) != "" || strings.TrimSpace(o.To) != ""
|
||||
}
|
||||
@@ -78,7 +78,7 @@ func NewSybSyncOptions(fromRaw, toRaw string, now time.Time) (SybSyncOptions, er
|
||||
}
|
||||
days := int(toDate.Sub(fromDate).Hours()/24) + 1
|
||||
if days > maxSpecifiedSyncDays {
|
||||
return SybSyncOptions{}, fmt.Errorf("指定日期同步最多选择 %d 天,当前范围为 %d 天,请分段同步",
|
||||
return SybSyncOptions{}, fmt.Errorf("同步日期范围最多选择 %d 天,当前范围为 %d 天,请分段同步",
|
||||
maxSpecifiedSyncDays, days)
|
||||
}
|
||||
return SybSyncOptions{From: from, To: to}, nil
|
||||
@@ -136,6 +136,63 @@ func SybToday(now time.Time) string {
|
||||
return dateOf(now)
|
||||
}
|
||||
|
||||
// SybSyncDefaults 是页面首次打开时预填的同步范围和必要提示。
|
||||
type SybSyncDefaults struct {
|
||||
From string
|
||||
To string
|
||||
Warning string
|
||||
}
|
||||
|
||||
// DefaultSybSyncRange 通常预填最近三天;如果覆盖游标落后,则优先从游标
|
||||
// 当天连续补齐。缺口超过 31 天时只选最早一段,成功后下一次继续。
|
||||
func DefaultSybSyncRange(db *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
|
||||
}
|
||||
|
||||
// cursorISOForDate 把“已连续覆盖到哪一天”保存成 UTC ISO。下一次仍从该日
|
||||
// 重拉,宁可重复当天,也不能漏掉该日稍晚创建的货运单。
|
||||
func cursorISOForDate(date string) (string, error) {
|
||||
t, err := time.ParseInLocation(dateLayout, date, sybLocation)
|
||||
if err != nil || t.Format(dateLayout) != date {
|
||||
return "", fmt.Errorf("同步游标日期格式不正确: %q", date)
|
||||
}
|
||||
return t.UTC().Format(model.TimeLayout), nil
|
||||
}
|
||||
|
||||
// syncDateRange 算出这次同步该拉哪个日期范围。
|
||||
//
|
||||
// `[必须]` 增量必须从"上次同步日期当天"重新拉,不是第二天——created
|
||||
@@ -175,17 +232,146 @@ type SkipNote struct {
|
||||
|
||||
// SyncReport 是一次同步的结果,供状态条显示。
|
||||
type SyncReport struct {
|
||||
From, To string
|
||||
Specified bool // true 表示操作员发起的指定日期补同步
|
||||
StockCount int // 拉到的货运单数
|
||||
DetailCount int // 落库的商品明细行数(不含跳过的)
|
||||
Created int
|
||||
Updated int
|
||||
SkippedZero int // quantity <= 0 被跳过的条数
|
||||
Notes []SkipNote
|
||||
Err error
|
||||
StartedAt time.Time
|
||||
FinishedAt time.Time
|
||||
From, To string
|
||||
Specified bool // true 表示操作员发起的指定日期补同步
|
||||
StockCount int // 拉到的货运单数
|
||||
DetailCount int // 落库的商品明细行数(不含跳过的)
|
||||
Created int
|
||||
Updated int
|
||||
SkippedZero int // quantity <= 0 被跳过的条数
|
||||
Notes []SkipNote
|
||||
Err error
|
||||
StartedAt time.Time
|
||||
FinishedAt time.Time
|
||||
CursorAdvanced bool
|
||||
}
|
||||
|
||||
const sybSyncHistoryPageSize = 10
|
||||
|
||||
// SybSyncRunView 是同步记录弹窗的一行,时间和状态已经转成采购员可读文本。
|
||||
type SybSyncRunView struct {
|
||||
RunID string
|
||||
Username string
|
||||
DateRange string
|
||||
StatusText string
|
||||
StatusClass string
|
||||
Summary string
|
||||
ErrorMessage string
|
||||
CursorText string
|
||||
StartedAt string
|
||||
FinishedAt string
|
||||
}
|
||||
|
||||
// SybSyncHistoryResult 是同步记录弹窗的分页结果。
|
||||
type SybSyncHistoryResult struct {
|
||||
Rows []SybSyncRunView
|
||||
Page int
|
||||
TotalPages int
|
||||
Total int
|
||||
}
|
||||
|
||||
// CreateSybSyncRun 在后台任务启动前创建可审计记录。
|
||||
func CreateSybSyncRun(db *sql.DB, actor *model.User, options SybSyncOptions, now time.Time) (string, error) {
|
||||
if actor == nil || actor.UserID == "" {
|
||||
return "", fmt.Errorf("无法确认当前操作账号,同步没有启动")
|
||||
}
|
||||
if !options.IsSpecified() {
|
||||
return "", fmt.Errorf("同步必须明确填写开始日期和结束日期")
|
||||
}
|
||||
runID, err := randomID("SYB-", 16)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("生成同步记录编号失败: %w", err)
|
||||
}
|
||||
run := model.SybSyncRun{
|
||||
RunID: runID, UserID: actor.UserID, DateFrom: options.From, DateTo: options.To,
|
||||
Status: model.SybSyncRunning, StartedAt: now.UTC().Format(model.TimeLayout),
|
||||
}
|
||||
if err := repository.CreateSybSyncRun(db, run); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return runID, nil
|
||||
}
|
||||
|
||||
// FinishSybSyncRun 把同步报告持久化到对应记录。
|
||||
func FinishSybSyncRun(db *sql.DB, runID string, report SyncReport) error {
|
||||
status := model.SybSyncSucceeded
|
||||
errorMessage := ""
|
||||
if report.Err != nil {
|
||||
status = model.SybSyncFailed
|
||||
errorMessage = report.Err.Error()
|
||||
if len([]rune(errorMessage)) > 500 {
|
||||
errorMessage = string([]rune(errorMessage)[:500]) + "…"
|
||||
}
|
||||
}
|
||||
finishedAt := report.FinishedAt
|
||||
if finishedAt.IsZero() {
|
||||
finishedAt = time.Now().UTC()
|
||||
}
|
||||
return repository.FinishSybSyncRun(db, model.SybSyncRun{
|
||||
RunID: runID, Status: status, StockCount: report.StockCount,
|
||||
DetailCount: report.DetailCount, Created: report.Created, Updated: report.Updated,
|
||||
Skipped: report.SkippedZero, ErrorMessage: errorMessage,
|
||||
CursorAdvanced: report.CursorAdvanced, FinishedAt: finishedAt.UTC().Format(model.TimeLayout),
|
||||
})
|
||||
}
|
||||
|
||||
// InterruptRunningSybSyncRuns 收敛上次进程退出前未完成的同步记录。
|
||||
func InterruptRunningSybSyncRuns(db *sql.DB, now time.Time) (int, error) {
|
||||
return repository.InterruptRunningSybSyncRuns(db, now.UTC().Format(model.TimeLayout))
|
||||
}
|
||||
|
||||
// ListSybSyncHistory 返回同步记录弹窗所需的分页视图。
|
||||
func ListSybSyncHistory(db *sql.DB, page int) (*SybSyncHistoryResult, error) {
|
||||
total, err := repository.CountSybSyncRuns(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
totalPages := max(1, (total+sybSyncHistoryPageSize-1)/sybSyncHistoryPageSize)
|
||||
if page > totalPages {
|
||||
page = totalPages
|
||||
}
|
||||
runs, err := repository.ListSybSyncRuns(db, sybSyncHistoryPageSize, (page-1)*sybSyncHistoryPageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &SybSyncHistoryResult{Page: page, TotalPages: totalPages, Total: total}
|
||||
for _, run := range runs {
|
||||
statusText, statusClass := sybSyncRunStatusText(run.Status)
|
||||
view := SybSyncRunView{
|
||||
RunID: run.RunID, Username: run.Username,
|
||||
DateRange: run.DateFrom + " ~ " + run.DateTo,
|
||||
StatusText: statusText, StatusClass: statusClass,
|
||||
Summary: fmt.Sprintf("货运单 %d,明细 %d(新增 %d,更新 %d,跳过 %d)",
|
||||
run.StockCount, run.DetailCount, run.Created, run.Updated, run.Skipped),
|
||||
ErrorMessage: run.ErrorMessage, StartedAt: formatLocalTime(run.StartedAt),
|
||||
FinishedAt: formatLocalTime(run.FinishedAt),
|
||||
}
|
||||
if run.CursorAdvanced {
|
||||
view.CursorText = "已推进"
|
||||
} else {
|
||||
view.CursorText = "未推进"
|
||||
}
|
||||
result.Rows = append(result.Rows, view)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func sybSyncRunStatusText(status model.SybSyncRunStatus) (string, string) {
|
||||
switch status {
|
||||
case model.SybSyncRunning:
|
||||
return "同步中", "status-running"
|
||||
case model.SybSyncSucceeded:
|
||||
return "成功", "status-success"
|
||||
case model.SybSyncFailed:
|
||||
return "失败", "status-failed"
|
||||
case model.SybSyncInterrupted:
|
||||
return "已中断", "status-interrupted"
|
||||
default:
|
||||
return "未知", ""
|
||||
}
|
||||
}
|
||||
|
||||
// Summary 组装状态条文案,格式见工单 #46「报告要说清楚」:
|
||||
@@ -196,11 +382,7 @@ func (r SyncReport) Summary() string {
|
||||
if r.Err != nil {
|
||||
return "同步失败:" + r.Err.Error()
|
||||
}
|
||||
prefix := "同步完成"
|
||||
if r.Specified {
|
||||
prefix = "指定日期同步完成"
|
||||
}
|
||||
msg := fmt.Sprintf("%s:日期范围 %s ~ %s,货运单 %d 张,商品明细 %d 条(新增 %d,更新 %d,跳过 %d)", prefix,
|
||||
msg := fmt.Sprintf("同步完成:日期范围 %s ~ %s,货运单 %d 张,商品明细 %d 条(新增 %d,更新 %d,跳过 %d)",
|
||||
r.From, r.To, r.StockCount, r.DetailCount, r.Created, r.Updated, r.SkippedZero)
|
||||
if len(r.Notes) > 0 {
|
||||
var reasons []string
|
||||
@@ -309,7 +491,7 @@ func RunSybSync(ctx context.Context, db *sql.DB, client *syb.Client, cfg config.
|
||||
return RunSybSyncWithOptions(ctx, db, client, cfg, now, SybSyncOptions{})
|
||||
}
|
||||
|
||||
// RunSybSyncWithOptions 执行自动增量或指定日期同步。
|
||||
// RunSybSyncWithOptions 执行日期范围同步;空范围仅兼容旧版内部自动增量调用。
|
||||
//
|
||||
// 指定日期只有完整覆盖“本来应该自动同步的范围”时才推进 last_synced_at。
|
||||
// 局部历史补拉只 upsert 数据、不动游标,否则会让未覆盖的订单永久漏掉。
|
||||
@@ -325,7 +507,7 @@ func RunSybSyncWithOptions(ctx context.Context, db *sql.DB, client *syb.Client,
|
||||
maxMatches = defaultSybMaxMatches
|
||||
}
|
||||
|
||||
lastSyncedAt, _, err := repository.GetSybLastSyncedAt(db)
|
||||
lastSyncedAt, hasCursor, err := repository.GetSybLastSyncedAt(db)
|
||||
if err != nil {
|
||||
report.Err = fmt.Errorf("读取上次同步进度失败: %w", err)
|
||||
report.FinishedAt = time.Now().UTC()
|
||||
@@ -334,6 +516,7 @@ func RunSybSyncWithOptions(ctx context.Context, db *sql.DB, client *syb.Client,
|
||||
|
||||
from, to := options.From, options.To
|
||||
advanceCursor := !options.IsSpecified()
|
||||
cursorAt := model.NowISO()
|
||||
if options.IsSpecified() {
|
||||
validated, validateErr := NewSybSyncOptions(options.From, options.To, now)
|
||||
if validateErr != nil {
|
||||
@@ -343,10 +526,27 @@ func RunSybSyncWithOptions(ctx context.Context, db *sql.DB, client *syb.Client,
|
||||
}
|
||||
from, to = validated.From, validated.To
|
||||
|
||||
// 算得出自动范围时,只有完整覆盖它才允许推进游标。首次同步没有
|
||||
// sync_from 时仍允许补拉,但不能凭局部范围建立一个不可靠的游标。
|
||||
autoFrom, autoTo, rangeErr := syncDateRange(lastSyncedAt, cfg.SyncFrom, now)
|
||||
advanceCursor = rangeErr == nil && from <= autoFrom && to >= autoTo
|
||||
cursorAt, err = cursorISOForDate(to)
|
||||
if err != nil {
|
||||
report.Err = err
|
||||
report.FinishedAt = time.Now().UTC()
|
||||
return report
|
||||
}
|
||||
if !hasCursor {
|
||||
// 统一日期入口第一次成功后,以操作员明确选择的结束日建立后续覆盖基线。
|
||||
advanceCursor = true
|
||||
} else {
|
||||
lastTime, ok := model.ParseISO(lastSyncedAt)
|
||||
if !ok {
|
||||
report.Err = fmt.Errorf("上次同步时间 %q 解析失败", lastSyncedAt)
|
||||
report.FinishedAt = time.Now().UTC()
|
||||
return report
|
||||
}
|
||||
coveredDate := dateOf(lastTime)
|
||||
// 只有范围衔接当前覆盖日期、且确实向后延伸时才推进。
|
||||
// 历史补拉或跳过缺口的范围只 upsert 数据,不改变覆盖基线。
|
||||
advanceCursor = from <= coveredDate && to > coveredDate
|
||||
}
|
||||
} else {
|
||||
from, to, err = syncDateRange(lastSyncedAt, cfg.SyncFrom, now)
|
||||
if err != nil {
|
||||
@@ -394,8 +594,10 @@ func RunSybSyncWithOptions(ctx context.Context, db *sql.DB, client *syb.Client,
|
||||
if allTotal == 0 {
|
||||
report.FinishedAt = time.Now().UTC()
|
||||
if advanceCursor {
|
||||
if err := repository.SetSybLastSyncedAt(db, model.NowISO()); err != nil {
|
||||
if err := repository.SetSybLastSyncedAt(db, cursorAt); err != nil {
|
||||
report.Err = fmt.Errorf("更新同步进度失败: %w", err)
|
||||
} else {
|
||||
report.CursorAdvanced = true
|
||||
}
|
||||
}
|
||||
return report
|
||||
@@ -481,9 +683,11 @@ func RunSybSyncWithOptions(ctx context.Context, db *sql.DB, client *syb.Client,
|
||||
|
||||
// ④ 全部成功,才更新 last_synced_at。
|
||||
if advanceCursor {
|
||||
if err := repository.SetSybLastSyncedAt(db, model.NowISO()); err != nil {
|
||||
if err := repository.SetSybLastSyncedAt(db, cursorAt); err != nil {
|
||||
report.Err = fmt.Errorf("同步数据已全部写入,但更新同步进度失败,"+
|
||||
"下次同步会重新拉这个日期范围(不会漏,但会重复拉一次): %w", err)
|
||||
} else {
|
||||
report.CursorAdvanced = true
|
||||
}
|
||||
}
|
||||
report.FinishedAt = time.Now().UTC()
|
||||
|
||||
Reference in New Issue
Block a user