feat: 顺运宝支持指定日期同步 (#53)
This commit is contained in:
+56
-12
@@ -86,7 +86,17 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw,
|
||||
// 现在「打开页面」纯粹是看数据,不触发任何对外部系统的动作;
|
||||
// 真正会登录的只有「同步」这一个按钮。这与 Client 侧
|
||||
// 「『获取任务』是唯一会产生外部后果的命令」是同一条原则。
|
||||
needLogin := loginReason != ""
|
||||
needLogin := c.Query("login") == "1"
|
||||
today := service.SybToday(time.Now())
|
||||
dateFrom := c.Query("date_from")
|
||||
dateTo := c.Query("date_to")
|
||||
hasSyncRange := dateFrom != "" || dateTo != ""
|
||||
if dateFrom == "" {
|
||||
dateFrom = today
|
||||
}
|
||||
if dateTo == "" {
|
||||
dateTo = today
|
||||
}
|
||||
|
||||
cfg, cfgErr := config.Load()
|
||||
switch {
|
||||
@@ -119,6 +129,12 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw,
|
||||
"LoginHint": loginHint,
|
||||
"Username": username,
|
||||
"ConfigProblem": configProblem,
|
||||
"Today": today,
|
||||
"SyncDateFrom": dateFrom,
|
||||
"SyncDateTo": dateTo,
|
||||
"HasSyncRange": hasSyncRange,
|
||||
"NeedRangeSync": c.Query("range_sync") == "1",
|
||||
"RangeError": c.Query("range_error"),
|
||||
"Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()),
|
||||
}))
|
||||
}
|
||||
@@ -142,6 +158,12 @@ func sybStatusLine(result *service.SybListResult) string {
|
||||
// │ └→ 识别失败/OCR 不可达 ──┐
|
||||
// └→ OCR 未配置(留空)────────────────────┴→ 跳回列表页,弹手工登录框
|
||||
func (h *Handler) SybSync(c *gin.Context) {
|
||||
options, err := service.NewSybSyncOptions(c.PostForm("date_from"), c.PostForm("date_to"), time.Now())
|
||||
if err != nil {
|
||||
h.sybRedirectRange(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, err.Error())
|
||||
@@ -166,7 +188,7 @@ func (h *Handler) SybSync(c *gin.Context) {
|
||||
h.sybRedirectLogin(c, reason)
|
||||
return
|
||||
}
|
||||
if !h.startSybSync(client, *cfg) {
|
||||
if !h.startSybSync(client, *cfg, options) {
|
||||
h.sybRedirect(c, "验证码自动识别成功,已自动登录;但已经有一个同步任务在跑,请稍后刷新页面查看结果")
|
||||
return
|
||||
}
|
||||
@@ -174,7 +196,7 @@ func (h *Handler) SybSync(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if !h.startSybSync(client, *cfg) {
|
||||
if !h.startSybSync(client, *cfg, options) {
|
||||
h.sybRedirect(c, "已经有一个同步任务在跑,请稍后刷新页面查看结果,不要重复点击")
|
||||
return
|
||||
}
|
||||
@@ -244,6 +266,12 @@ func (h *Handler) SybCaptcha(c *gin.Context) {
|
||||
// `[必须]` 密码从 config.yaml 读,不接受表单传入、不回显、不进日志——
|
||||
// 界面上只有验证码是操作员手输的,见工单 #46。
|
||||
func (h *Handler) SybLoginAndSync(c *gin.Context) {
|
||||
options, optionErr := service.NewSybSyncOptions(c.PostForm("date_from"), c.PostForm("date_to"), time.Now())
|
||||
if optionErr != nil {
|
||||
h.sybRedirectRange(c, optionErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, err.Error())
|
||||
@@ -275,7 +303,7 @@ func (h *Handler) SybLoginAndSync(c *gin.Context) {
|
||||
log.Printf("syb_session_save_failed username=%s err=%v", result.User.Username, err)
|
||||
}
|
||||
|
||||
if !h.startSybSync(client, *cfg) {
|
||||
if !h.startSybSync(client, *cfg, options) {
|
||||
h.sybRedirect(c, "登录成功,但已经有一个同步任务在跑,请稍后刷新页面查看结果")
|
||||
return
|
||||
}
|
||||
@@ -290,13 +318,13 @@ func (h *Handler) SybLoginAndSync(c *gin.Context) {
|
||||
//
|
||||
// `[必须]` 后台协程用 context.Background(),不能用 c.Request.Context()——
|
||||
// 那个请求上下文会在这次 HTTP 请求返回后就被取消,同步跑到一半会被打断。
|
||||
func (h *Handler) startSybSync(client *syb.Client, cfg config.Config) bool {
|
||||
func (h *Handler) startSybSync(client *syb.Client, cfg config.Config, options service.SybSyncOptions) bool {
|
||||
if !service.TryStartSybSync() {
|
||||
return false
|
||||
}
|
||||
db := h.db
|
||||
go func() {
|
||||
report := service.RunSybSync(context.Background(), db, client, cfg.Syb, time.Now())
|
||||
report := service.RunSybSyncWithOptions(context.Background(), db, client, cfg.Syb, time.Now(), options)
|
||||
service.FinishSybSync(report)
|
||||
}()
|
||||
return true
|
||||
@@ -306,9 +334,7 @@ func (h *Handler) startSybSync(client *syb.Client, cfg config.Config) bool {
|
||||
// 用 303 跳转是为了让浏览器地址栏变成 GET /syb,按 F5 不会重复提交。
|
||||
func (h *Handler) sybRedirect(c *gin.Context, msg string) {
|
||||
params := url.Values{}
|
||||
if q := c.PostForm("order_no"); q != "" {
|
||||
params.Set("order_no", q)
|
||||
}
|
||||
appendSybFormState(params, c)
|
||||
if msg != "" {
|
||||
params.Set("msg", msg)
|
||||
}
|
||||
@@ -325,9 +351,8 @@ func (h *Handler) sybRedirect(c *gin.Context, msg string) {
|
||||
// (比如 ocr_url 留空,压根没尝试过自动识别)弹窗不显示任何原因说明。
|
||||
func (h *Handler) sybRedirectLogin(c *gin.Context, reason string) {
|
||||
params := url.Values{}
|
||||
if q := c.PostForm("order_no"); q != "" {
|
||||
params.Set("order_no", q)
|
||||
}
|
||||
appendSybFormState(params, c)
|
||||
params.Set("login", "1")
|
||||
if reason != "" {
|
||||
params.Set("login_reason", reason)
|
||||
}
|
||||
@@ -338,6 +363,25 @@ func (h *Handler) sybRedirectLogin(c *gin.Context, reason string) {
|
||||
c.Redirect(http.StatusSeeOther, target)
|
||||
}
|
||||
|
||||
// sybRedirectRange 把服务端日期校验错误带回指定日期弹窗。输入值一并保留,
|
||||
// 操作员只需改错的那一端,不必重新填整张表单。
|
||||
func (h *Handler) sybRedirectRange(c *gin.Context, message string) {
|
||||
params := url.Values{}
|
||||
appendSybFormState(params, c)
|
||||
params.Set("range_sync", "1")
|
||||
params.Set("range_error", message)
|
||||
c.Redirect(http.StatusSeeOther, "/syb?"+params.Encode())
|
||||
}
|
||||
|
||||
// appendSybFormState 保存同步/登录跳转后仍需要的页面状态。
|
||||
func appendSybFormState(params url.Values, c *gin.Context) {
|
||||
for _, name := range []string{"order_no", "date_from", "date_to"} {
|
||||
if value := strings.TrimSpace(c.PostForm(name)); value != "" {
|
||||
params.Set(name, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SybMatch 保存规格匹配结果。
|
||||
//
|
||||
// 关键:保存的是**可复用的 SKU 映射**(写 sku_mappings),
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func sybPostContext(t *testing.T, values url.Values) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(recorder)
|
||||
request := httptest.NewRequest(http.MethodPost, "/syb/sync", strings.NewReader(values.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
context.Request = request
|
||||
return context, recorder
|
||||
}
|
||||
|
||||
func TestSybRedirectLogin_保留指定日期并打开登录弹窗(t *testing.T) {
|
||||
context, recorder := sybPostContext(t, url.Values{
|
||||
"order_no": {"ORDER-1"},
|
||||
"date_from": {"2026-08-01"},
|
||||
"date_to": {"2026-08-09"},
|
||||
})
|
||||
|
||||
(&Handler{}).sybRedirectLogin(context, "")
|
||||
if context.Writer.Status() != http.StatusSeeOther {
|
||||
t.Fatalf("状态码 = %d,期望 %d", context.Writer.Status(), http.StatusSeeOther)
|
||||
}
|
||||
location, err := url.Parse(recorder.Header().Get("Location"))
|
||||
if err != nil {
|
||||
t.Fatalf("解析 Location 失败: %v", err)
|
||||
}
|
||||
query := location.Query()
|
||||
for name, want := range map[string]string{
|
||||
"login": "1", "order_no": "ORDER-1",
|
||||
"date_from": "2026-08-01", "date_to": "2026-08-09",
|
||||
} {
|
||||
if got := query.Get(name); got != want {
|
||||
t.Errorf("%s = %q,期望 %q;Location=%s", name, got, want, location.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSybRedirectRange_保留输入并重新打开日期弹窗(t *testing.T) {
|
||||
context, recorder := sybPostContext(t, url.Values{
|
||||
"date_from": {"2026-08-09"},
|
||||
"date_to": {"2026-08-01"},
|
||||
})
|
||||
|
||||
(&Handler{}).sybRedirectRange(context, "开始日期不能晚于结束日期")
|
||||
location, err := url.Parse(recorder.Header().Get("Location"))
|
||||
if err != nil {
|
||||
t.Fatalf("解析 Location 失败: %v", err)
|
||||
}
|
||||
query := location.Query()
|
||||
if query.Get("range_sync") != "1" || query.Get("range_error") == "" {
|
||||
t.Fatalf("日期错误后应该重开弹窗并显示错误,Location=%s", location.String())
|
||||
}
|
||||
if query.Get("date_from") != "2026-08-09" || query.Get("date_to") != "2026-08-01" {
|
||||
t.Fatalf("日期错误后应该保留两端输入,Location=%s", location.String())
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,18 @@ func TestMainPagesReturnOK(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
sybRequest := httptest.NewRequest(http.MethodGet, "/syb", nil)
|
||||
sybResponse := httptest.NewRecorder()
|
||||
router.ServeHTTP(sybResponse, sybRequest)
|
||||
for _, want := range []string{
|
||||
"指定日期同步", `name="date_from"`, `name="date_to"`,
|
||||
"按顺运宝货运单创建日期(UTC+8)同步",
|
||||
} {
|
||||
if !strings.Contains(sybResponse.Body.String(), want) {
|
||||
t.Errorf("顺运宝页面缺少 %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
pdd, err := repository.EnsurePddProduct(
|
||||
db, "737116531267",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=737116531267")
|
||||
|
||||
+97
-14
@@ -32,6 +32,49 @@ import (
|
||||
|
||||
const dateLayout = "2006-01-02"
|
||||
|
||||
// SybSyncOptions 表示一次同步的日期选择。From/To 都为空是日常自动增量;
|
||||
// 两者都有值是操作员明确发起的指定日期补同步。
|
||||
//
|
||||
// `[必须]` 不允许只填一端。把“半个范围”悄悄退化成自动增量,会让操作员
|
||||
// 以为补拉了指定日期,实际却跑了另一段数据。
|
||||
type SybSyncOptions struct {
|
||||
From string
|
||||
To string
|
||||
}
|
||||
|
||||
// IsSpecified 判断这是不是指定日期同步。
|
||||
func (o SybSyncOptions) IsSpecified() bool {
|
||||
return strings.TrimSpace(o.From) != "" || strings.TrimSpace(o.To) != ""
|
||||
}
|
||||
|
||||
// NewSybSyncOptions 清理并校验浏览器提交的日期范围。
|
||||
// 日期按顺运宝服务端 UTC+8 解释,起止两天都包含。
|
||||
func NewSybSyncOptions(fromRaw, toRaw string, now time.Time) (SybSyncOptions, error) {
|
||||
from := strings.TrimSpace(fromRaw)
|
||||
to := strings.TrimSpace(toRaw)
|
||||
if from == "" && to == "" {
|
||||
return SybSyncOptions{}, nil
|
||||
}
|
||||
if from == "" || to == "" {
|
||||
return SybSyncOptions{}, fmt.Errorf("开始日期和结束日期必须同时填写")
|
||||
}
|
||||
fromDate, err := time.Parse(dateLayout, from)
|
||||
if err != nil || fromDate.Format(dateLayout) != from {
|
||||
return SybSyncOptions{}, fmt.Errorf("开始日期格式不正确,请使用 YYYY-MM-DD")
|
||||
}
|
||||
toDate, err := time.Parse(dateLayout, to)
|
||||
if err != nil || toDate.Format(dateLayout) != to {
|
||||
return SybSyncOptions{}, fmt.Errorf("结束日期格式不正确,请使用 YYYY-MM-DD")
|
||||
}
|
||||
if fromDate.After(toDate) {
|
||||
return SybSyncOptions{}, fmt.Errorf("开始日期不能晚于结束日期")
|
||||
}
|
||||
if to > dateOf(now) {
|
||||
return SybSyncOptions{}, fmt.Errorf("结束日期不能晚于顺运宝服务端今天(%s)", dateOf(now))
|
||||
}
|
||||
return SybSyncOptions{From: from, To: to}, nil
|
||||
}
|
||||
|
||||
// sybLocation 是顺运宝服务端的时区。
|
||||
//
|
||||
// `[必须]` 日期范围筛的是服务端的 created,而它是 UTC+8——实测
|
||||
@@ -57,6 +100,12 @@ func dateOf(t time.Time) string {
|
||||
return t.In(sybLocation).Format(dateLayout)
|
||||
}
|
||||
|
||||
// SybToday 返回顺运宝服务端时区下的今天,供页面 date 输入的 max/default
|
||||
// 使用。页面和同步校验共用这一处,避免本机时区不同导致显示和校验差一天。
|
||||
func SybToday(now time.Time) string {
|
||||
return dateOf(now)
|
||||
}
|
||||
|
||||
// syncDateRange 算出这次同步该拉哪个日期范围。
|
||||
//
|
||||
// `[必须]` 增量必须从"上次同步日期当天"重新拉,不是第二天——created
|
||||
@@ -97,8 +146,9 @@ type SkipNote struct {
|
||||
// SyncReport 是一次同步的结果,供状态条显示。
|
||||
type SyncReport struct {
|
||||
From, To string
|
||||
StockCount int // 拉到的货运单数
|
||||
DetailCount int // 落库的商品明细行数(不含跳过的)
|
||||
Specified bool // true 表示操作员发起的指定日期补同步
|
||||
StockCount int // 拉到的货运单数
|
||||
DetailCount int // 落库的商品明细行数(不含跳过的)
|
||||
Created int
|
||||
Updated int
|
||||
SkippedZero int // quantity <= 0 被跳过的条数
|
||||
@@ -116,7 +166,11 @@ func (r SyncReport) Summary() string {
|
||||
if r.Err != nil {
|
||||
return "同步失败:" + r.Err.Error()
|
||||
}
|
||||
msg := fmt.Sprintf("同步完成:日期范围 %s ~ %s,货运单 %d 张,商品明细 %d 条(新增 %d,更新 %d,跳过 %d)",
|
||||
prefix := "同步完成"
|
||||
if r.Specified {
|
||||
prefix = "指定日期同步完成"
|
||||
}
|
||||
msg := fmt.Sprintf("%s:日期范围 %s ~ %s,货运单 %d 张,商品明细 %d 条(新增 %d,更新 %d,跳过 %d)", prefix,
|
||||
r.From, r.To, r.StockCount, r.DetailCount, r.Created, r.Updated, r.SkippedZero)
|
||||
if len(r.Notes) > 0 {
|
||||
var reasons []string
|
||||
@@ -222,7 +276,15 @@ func roundYuanToCent(yuan float64) int64 {
|
||||
// 如果会话恰好失效,会从 syb.Client 的调用里冒出 syb.ErrSessionInvalid,
|
||||
// 和其它错误一样按"中途失败"处理:不更新 last_synced_at,把原因写进报告。
|
||||
func RunSybSync(ctx context.Context, db *sql.DB, client *syb.Client, cfg config.SybConfig, now time.Time) SyncReport {
|
||||
report := SyncReport{StartedAt: now}
|
||||
return RunSybSyncWithOptions(ctx, db, client, cfg, now, SybSyncOptions{})
|
||||
}
|
||||
|
||||
// RunSybSyncWithOptions 执行自动增量或指定日期同步。
|
||||
//
|
||||
// 指定日期只有完整覆盖“本来应该自动同步的范围”时才推进 last_synced_at。
|
||||
// 局部历史补拉只 upsert 数据、不动游标,否则会让未覆盖的订单永久漏掉。
|
||||
func RunSybSyncWithOptions(ctx context.Context, db *sql.DB, client *syb.Client, cfg config.SybConfig, now time.Time, options SybSyncOptions) SyncReport {
|
||||
report := SyncReport{StartedAt: now, Specified: options.IsSpecified()}
|
||||
|
||||
pageSize := cfg.PageSize
|
||||
if pageSize <= 0 {
|
||||
@@ -240,11 +302,28 @@ func RunSybSync(ctx context.Context, db *sql.DB, client *syb.Client, cfg config.
|
||||
return report
|
||||
}
|
||||
|
||||
from, to, err := syncDateRange(lastSyncedAt, cfg.SyncFrom, now)
|
||||
if err != nil {
|
||||
report.Err = err
|
||||
report.FinishedAt = time.Now().UTC()
|
||||
return report
|
||||
from, to := options.From, options.To
|
||||
advanceCursor := !options.IsSpecified()
|
||||
if options.IsSpecified() {
|
||||
validated, validateErr := NewSybSyncOptions(options.From, options.To, now)
|
||||
if validateErr != nil {
|
||||
report.Err = validateErr
|
||||
report.FinishedAt = time.Now().UTC()
|
||||
return report
|
||||
}
|
||||
from, to = validated.From, validated.To
|
||||
|
||||
// 算得出自动范围时,只有完整覆盖它才允许推进游标。首次同步没有
|
||||
// sync_from 时仍允许补拉,但不能凭局部范围建立一个不可靠的游标。
|
||||
autoFrom, autoTo, rangeErr := syncDateRange(lastSyncedAt, cfg.SyncFrom, now)
|
||||
advanceCursor = rangeErr == nil && from <= autoFrom && to >= autoTo
|
||||
} else {
|
||||
from, to, err = syncDateRange(lastSyncedAt, cfg.SyncFrom, now)
|
||||
if err != nil {
|
||||
report.Err = err
|
||||
report.FinishedAt = time.Now().UTC()
|
||||
return report
|
||||
}
|
||||
}
|
||||
report.From, report.To = from, to
|
||||
|
||||
@@ -263,8 +342,10 @@ func RunSybSync(ctx context.Context, db *sql.DB, client *syb.Client, cfg config.
|
||||
}
|
||||
if total == 0 {
|
||||
report.FinishedAt = time.Now().UTC()
|
||||
if err := repository.SetSybLastSyncedAt(db, model.NowISO()); err != nil {
|
||||
report.Err = fmt.Errorf("更新同步进度失败: %w", err)
|
||||
if advanceCursor {
|
||||
if err := repository.SetSybLastSyncedAt(db, model.NowISO()); err != nil {
|
||||
report.Err = fmt.Errorf("更新同步进度失败: %w", err)
|
||||
}
|
||||
}
|
||||
return report
|
||||
}
|
||||
@@ -329,9 +410,11 @@ func RunSybSync(ctx context.Context, db *sql.DB, client *syb.Client, cfg config.
|
||||
}
|
||||
|
||||
// ④ 全部成功,才更新 last_synced_at。
|
||||
if err := repository.SetSybLastSyncedAt(db, model.NowISO()); err != nil {
|
||||
report.Err = fmt.Errorf("同步数据已全部写入,但更新同步进度失败,"+
|
||||
"下次同步会重新拉这个日期范围(不会漏,但会重复拉一次): %w", err)
|
||||
if advanceCursor {
|
||||
if err := repository.SetSybLastSyncedAt(db, model.NowISO()); err != nil {
|
||||
report.Err = fmt.Errorf("同步数据已全部写入,但更新同步进度失败,"+
|
||||
"下次同步会重新拉这个日期范围(不会漏,但会重复拉一次): %w", err)
|
||||
}
|
||||
}
|
||||
report.FinishedAt = time.Now().UTC()
|
||||
return report
|
||||
|
||||
@@ -84,6 +84,39 @@ func TestSyncDateRange_结束日期按顺运宝服务端时区UTC加8算不是UT
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSybSyncOptions_指定日期校验(t *testing.T) {
|
||||
now := time.Date(2026, 8, 9, 23, 0, 0, 0, time.UTC) // UTC+8 已是 8 月 10 日
|
||||
cases := []struct {
|
||||
name, from, to string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "两端都空是自动增量"},
|
||||
{name: "合法闭区间", from: "2026-08-01", to: "2026-08-10"},
|
||||
{name: "只填开始", from: "2026-08-01", wantErr: "同时填写"},
|
||||
{name: "开始格式错误", from: "2026/08/01", to: "2026-08-02", wantErr: "开始日期格式"},
|
||||
{name: "结束格式错误", from: "2026-08-01", to: "2026/08/02", wantErr: "结束日期格式"},
|
||||
{name: "起止倒置", from: "2026-08-03", to: "2026-08-02", wantErr: "不能晚于"},
|
||||
{name: "晚于UTC加8今天", from: "2026-08-10", to: "2026-08-11", wantErr: "不能晚于"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := NewSybSyncOptions(tc.from, tc.to, now)
|
||||
if tc.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("不应该报错: %v", err)
|
||||
}
|
||||
if got.From != tc.from || got.To != tc.to {
|
||||
t.Fatalf("范围被意外改变:%+v", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("错误应包含 %q,实际 %v", tc.wantErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── 金额换算 ──────────────────────────────────────────
|
||||
|
||||
func TestRoundYuanToCent_先四舍五入再转整数(t *testing.T) {
|
||||
@@ -367,6 +400,61 @@ func TestRunSybSync_全部成功后更新last_synced_at(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSybSyncWithOptions_历史补拉不推进游标(t *testing.T) {
|
||||
srv := fakeSybServer(t, nil, 0)
|
||||
defer srv.Close()
|
||||
db := newSyncTestDB(t)
|
||||
client, _ := syb.New(srv.URL)
|
||||
const original = "2026-07-20T06:30:00Z"
|
||||
if err := repository.SetSybLastSyncedAt(db, original); err != nil {
|
||||
t.Fatalf("准备同步游标失败: %v", err)
|
||||
}
|
||||
|
||||
report := RunSybSyncWithOptions(context.Background(), db, client,
|
||||
config.SybConfig{BaseURL: srv.URL, PageSize: 20, MaxMatches: 500, SyncFrom: "2026-07-01"},
|
||||
time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC),
|
||||
SybSyncOptions{From: "2026-07-01", To: "2026-07-10"})
|
||||
if report.Err != nil {
|
||||
t.Fatalf("历史补拉失败: %v", report.Err)
|
||||
}
|
||||
if !report.Specified || !strings.HasPrefix(report.Summary(), "指定日期同步完成") {
|
||||
t.Fatalf("报告没有标明指定日期同步:%+v / %s", report, report.Summary())
|
||||
}
|
||||
at, _, err := repository.GetSybLastSyncedAt(db)
|
||||
if err != nil {
|
||||
t.Fatalf("读取同步游标失败: %v", err)
|
||||
}
|
||||
if at != original {
|
||||
t.Fatalf("局部历史补拉不应推进游标,原值 %q,实际 %q", original, at)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSybSyncWithOptions_完整覆盖自动范围才推进游标(t *testing.T) {
|
||||
srv := fakeSybServer(t, nil, 0)
|
||||
defer srv.Close()
|
||||
db := newSyncTestDB(t)
|
||||
client, _ := syb.New(srv.URL)
|
||||
const original = "2026-07-20T06:30:00Z" // UTC+8 仍是 7 月 20 日
|
||||
if err := repository.SetSybLastSyncedAt(db, original); err != nil {
|
||||
t.Fatalf("准备同步游标失败: %v", err)
|
||||
}
|
||||
|
||||
report := RunSybSyncWithOptions(context.Background(), db, client,
|
||||
config.SybConfig{BaseURL: srv.URL, PageSize: 20, MaxMatches: 500, SyncFrom: "2026-07-01"},
|
||||
time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC),
|
||||
SybSyncOptions{From: "2026-07-20", To: "2026-07-28"})
|
||||
if report.Err != nil {
|
||||
t.Fatalf("完整范围同步失败: %v", report.Err)
|
||||
}
|
||||
at, _, err := repository.GetSybLastSyncedAt(db)
|
||||
if err != nil {
|
||||
t.Fatalf("读取同步游标失败: %v", err)
|
||||
}
|
||||
if at == original || at == "" {
|
||||
t.Fatalf("完整覆盖自动范围后应该推进游标,实际仍为 %q", at)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSybSync_数量小于等于0被跳过并计入报告(t *testing.T) {
|
||||
srv := fakeSybServer(t, []fakeStock{
|
||||
{ID: 1, Code: "A", Details: []fakeDetail{
|
||||
|
||||
@@ -187,6 +187,15 @@ tr.empty small { color: #aaa; }
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
}
|
||||
.modal-narrow { max-width: 520px; }
|
||||
.field-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.field-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
.modal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -255,6 +264,13 @@ input[type="text"] {
|
||||
border: 1px solid #ccd1d6;
|
||||
border-radius: 3px;
|
||||
}
|
||||
input[type="date"] {
|
||||
width: 100%;
|
||||
padding: 5px 8px;
|
||||
border: 1px solid #ccd1d6;
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
}
|
||||
input.wide { width: 100%; }
|
||||
|
||||
select {
|
||||
|
||||
@@ -89,13 +89,18 @@
|
||||
散到前端就没人维护得住了。 */
|
||||
|
||||
function openModal(modal) {
|
||||
modal._returnFocus = document.activeElement;
|
||||
modal.hidden = false;
|
||||
var focusable = modal.querySelector("input, button");
|
||||
var focusable = modal.querySelector("[autofocus]") ||
|
||||
modal.querySelector("input, button");
|
||||
if (focusable) focusable.focus();
|
||||
}
|
||||
|
||||
function closeModal(modal) {
|
||||
modal.hidden = true;
|
||||
if (modal._returnFocus && modal._returnFocus.focus) {
|
||||
modal._returnFocus.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function setupModals() {
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
<form class="inline" method="post" action="/syb/sync">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="order_no" value="{{.Keyword}}">
|
||||
<button type="submit">同步</button>
|
||||
<button type="submit" class="primary">同步</button>
|
||||
</form>
|
||||
|
||||
<button type="button" data-modal-open="sync-range-modal">指定日期同步</button>
|
||||
|
||||
<form class="inline" method="post" action="/syb/create-task">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<button type="submit" data-need-checked>创建采购任务</button>
|
||||
@@ -28,6 +30,47 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{/* 指定日期只用于补拉历史数据。日常增量仍走工具栏第一个“同步”按钮,
|
||||
避免操作员忘记清掉旧日期、下一次又误跑同一段历史数据。 */}}
|
||||
<div class="modal-backdrop" id="sync-range-modal" {{if not .NeedRangeSync}}hidden{{end}}>
|
||||
<div class="modal modal-narrow" role="dialog" aria-modal="true"
|
||||
aria-labelledby="sync-range-modal-title">
|
||||
<div class="modal-head">
|
||||
<h2 id="sync-range-modal-title">指定日期同步</h2>
|
||||
<button type="button" class="modal-x" data-modal-close aria-label="关闭">×</button>
|
||||
</div>
|
||||
<form method="post" action="/syb/sync">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="order_no" value="{{.Keyword}}">
|
||||
<div class="modal-body">
|
||||
{{if .RangeError}}
|
||||
<p class="missing" role="alert">{{.RangeError}}</p>
|
||||
{{end}}
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label for="sync-date-from">开始日期</label>
|
||||
<input id="sync-date-from" type="date" name="date_from"
|
||||
value="{{.SyncDateFrom}}" max="{{.Today}}" required autofocus>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="sync-date-to">结束日期</label>
|
||||
<input id="sync-date-to" type="date" name="date_to"
|
||||
value="{{.SyncDateTo}}" max="{{.Today}}" required>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">
|
||||
按顺运宝货运单创建日期(UTC+8)同步,开始日和结束日都包含。
|
||||
日常同步请直接使用工具栏的“同步”。
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button type="button" data-modal-close>取消</button>
|
||||
<button type="submit" class="primary">开始同步</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .ConfigProblem}}
|
||||
<p class="missing">
|
||||
顺运宝配置有问题:{{.ConfigProblem}}
|
||||
@@ -118,6 +161,11 @@
|
||||
<form method="post" action="/syb/login-and-sync">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="order_no" value="{{.Keyword}}">
|
||||
{{/* 指定日期同步遇到登录时必须把范围带过来;为空就是普通增量同步。 */}}
|
||||
{{if .HasSyncRange}}
|
||||
<input type="hidden" name="date_from" value="{{.SyncDateFrom}}">
|
||||
<input type="hidden" name="date_to" value="{{.SyncDateTo}}">
|
||||
{{end}}
|
||||
<div class="modal-body">
|
||||
{{/* `[必须]` 工单 #47:自动识别验证码失败/降级时要说明原因
|
||||
(已尝试 N 次 / 服务不可用),操作员才知道是配置错了还是
|
||||
|
||||
@@ -407,7 +407,7 @@ Go 的 map 是无序的,不靠它定顺序的话,同一个商品每次刷新
|
||||
### 6.1 工具条
|
||||
|
||||
```text
|
||||
[同步] [创建采购任务] 订单号 [___] [搜索] [删除]
|
||||
[同步] [指定日期同步] [创建采购任务] 订单号 [___] [搜索] [删除]
|
||||
```
|
||||
|
||||
`[必须]` 「同步」按工单 #46 实现:
|
||||
@@ -434,6 +434,25 @@ Go 的 map 是无序的,不靠它定顺序的话,同一个商品每次刷新
|
||||
有跳过/失败会在后面列出具体原因,不是只给个数字。同步中再次点「同步」会提示
|
||||
"已经有一个同步任务在跑",不会并发跑两个。
|
||||
|
||||
`[必须]` 工单 #53 增加的「指定日期同步」是次要操作,不改变上面「同步」的
|
||||
自动增量语义。点击后打开小弹窗:
|
||||
|
||||
```text
|
||||
指定日期同步
|
||||
|
||||
开始日期 [2026-08-01] 结束日期 [2026-08-09]
|
||||
按货运单创建日期(UTC+8)同步,开始日和结束日都包含。
|
||||
|
||||
[取消] [开始同步]
|
||||
```
|
||||
|
||||
- 两端都必填,结束日期不得晚于 UTC+8 下的今天。
|
||||
- 指定历史范围用于首次分批拉取和补数据;日常操作仍点「同步」。
|
||||
- 指定范围经过自动 OCR 或手工验证码登录后必须原样保留。
|
||||
- 日期校验失败时重开弹窗、保留原输入,并在字段区域显示可读错误。
|
||||
- 指定历史范围默认不推进自动增量游标;游标规则见
|
||||
[08 顺运宝接口](08-顺运宝接口.md) §4.4。
|
||||
|
||||
`[必须]` 搜索框宽度见 [§3.1](#31-搜索框宽度)。
|
||||
|
||||
### 6.2 表格列
|
||||
|
||||
@@ -189,6 +189,29 @@ POST /am/stock/list → data.list 是数组
|
||||
示例脚本用的是 `max_matches = 100`。日期范围拉全量时这个上限要调大,
|
||||
但不能没有——手滑填成一年的范围会把整库拉下来。
|
||||
|
||||
### 4.4 自动增量与指定日期补同步
|
||||
|
||||
页面有两种同步入口,但共用 §4.2 的日期范围接口、分页、明细读取和 upsert:
|
||||
|
||||
| 模式 | 日期范围 | `last_synced_at` |
|
||||
|---|---|---|
|
||||
| 自动增量「同步」 | 上次成功同步日期当天 ~ UTC+8 下的今天;首次从 `sync_from` 开始 | 全部成功后推进 |
|
||||
| 「指定日期同步」 | 操作员填写的开始日 ~ 结束日,两端都包含 | 见下方安全规则 |
|
||||
|
||||
`[必须]` 指定范围只有在**完整覆盖自动增量本来应该同步的区间**时,全部成功后
|
||||
才可以推进 `last_synced_at`。局部历史补拉只 upsert 数据,不动游标。
|
||||
|
||||
例如系统本来应该同步 `2026-08-01 ~ 2026-08-09`,操作员只补拉
|
||||
`2026-08-07 ~ 2026-08-08`。如果这时把游标推进到当前时间,前后没有覆盖的
|
||||
日期可能永远不会再自动同步,而且不会报错。
|
||||
|
||||
`[必须]` 日期格式固定 `YYYY-MM-DD`,按 UTC+8 解释;两端必须同时填写,
|
||||
开始不得晚于结束,结束不得晚于 UTC+8 下的今天。中途失败或超过
|
||||
`max_matches` 时,两种模式都不推进游标。
|
||||
|
||||
`[必须]` 登录和验证码只是同步前置步骤。指定范围经过自动 OCR 降级、手工
|
||||
输入验证码和 303 跳转时必须原样保留,不能悄悄退化成自动增量。
|
||||
|
||||
---
|
||||
|
||||
## 5. 货运单字段
|
||||
|
||||
Reference in New Issue
Block a user