feat: 顺运宝支持指定日期同步 (#53)
This commit is contained in:
+56
-12
@@ -86,7 +86,17 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw,
|
|||||||
// 现在「打开页面」纯粹是看数据,不触发任何对外部系统的动作;
|
// 现在「打开页面」纯粹是看数据,不触发任何对外部系统的动作;
|
||||||
// 真正会登录的只有「同步」这一个按钮。这与 Client 侧
|
// 真正会登录的只有「同步」这一个按钮。这与 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()
|
cfg, cfgErr := config.Load()
|
||||||
switch {
|
switch {
|
||||||
@@ -119,6 +129,12 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw,
|
|||||||
"LoginHint": loginHint,
|
"LoginHint": loginHint,
|
||||||
"Username": username,
|
"Username": username,
|
||||||
"ConfigProblem": configProblem,
|
"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()),
|
"Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -142,6 +158,12 @@ func sybStatusLine(result *service.SybListResult) string {
|
|||||||
// │ └→ 识别失败/OCR 不可达 ──┐
|
// │ └→ 识别失败/OCR 不可达 ──┐
|
||||||
// └→ OCR 未配置(留空)────────────────────┴→ 跳回列表页,弹手工登录框
|
// └→ OCR 未配置(留空)────────────────────┴→ 跳回列表页,弹手工登录框
|
||||||
func (h *Handler) SybSync(c *gin.Context) {
|
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()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fail(c, http.StatusBadRequest, err.Error())
|
fail(c, http.StatusBadRequest, err.Error())
|
||||||
@@ -166,7 +188,7 @@ func (h *Handler) SybSync(c *gin.Context) {
|
|||||||
h.sybRedirectLogin(c, reason)
|
h.sybRedirectLogin(c, reason)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !h.startSybSync(client, *cfg) {
|
if !h.startSybSync(client, *cfg, options) {
|
||||||
h.sybRedirect(c, "验证码自动识别成功,已自动登录;但已经有一个同步任务在跑,请稍后刷新页面查看结果")
|
h.sybRedirect(c, "验证码自动识别成功,已自动登录;但已经有一个同步任务在跑,请稍后刷新页面查看结果")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -174,7 +196,7 @@ func (h *Handler) SybSync(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !h.startSybSync(client, *cfg) {
|
if !h.startSybSync(client, *cfg, options) {
|
||||||
h.sybRedirect(c, "已经有一个同步任务在跑,请稍后刷新页面查看结果,不要重复点击")
|
h.sybRedirect(c, "已经有一个同步任务在跑,请稍后刷新页面查看结果,不要重复点击")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -244,6 +266,12 @@ func (h *Handler) SybCaptcha(c *gin.Context) {
|
|||||||
// `[必须]` 密码从 config.yaml 读,不接受表单传入、不回显、不进日志——
|
// `[必须]` 密码从 config.yaml 读,不接受表单传入、不回显、不进日志——
|
||||||
// 界面上只有验证码是操作员手输的,见工单 #46。
|
// 界面上只有验证码是操作员手输的,见工单 #46。
|
||||||
func (h *Handler) SybLoginAndSync(c *gin.Context) {
|
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()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fail(c, http.StatusBadRequest, err.Error())
|
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)
|
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, "登录成功,但已经有一个同步任务在跑,请稍后刷新页面查看结果")
|
h.sybRedirect(c, "登录成功,但已经有一个同步任务在跑,请稍后刷新页面查看结果")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -290,13 +318,13 @@ func (h *Handler) SybLoginAndSync(c *gin.Context) {
|
|||||||
//
|
//
|
||||||
// `[必须]` 后台协程用 context.Background(),不能用 c.Request.Context()——
|
// `[必须]` 后台协程用 context.Background(),不能用 c.Request.Context()——
|
||||||
// 那个请求上下文会在这次 HTTP 请求返回后就被取消,同步跑到一半会被打断。
|
// 那个请求上下文会在这次 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() {
|
if !service.TryStartSybSync() {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
db := h.db
|
db := h.db
|
||||||
go func() {
|
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)
|
service.FinishSybSync(report)
|
||||||
}()
|
}()
|
||||||
return true
|
return true
|
||||||
@@ -306,9 +334,7 @@ func (h *Handler) startSybSync(client *syb.Client, cfg config.Config) bool {
|
|||||||
// 用 303 跳转是为了让浏览器地址栏变成 GET /syb,按 F5 不会重复提交。
|
// 用 303 跳转是为了让浏览器地址栏变成 GET /syb,按 F5 不会重复提交。
|
||||||
func (h *Handler) sybRedirect(c *gin.Context, msg string) {
|
func (h *Handler) sybRedirect(c *gin.Context, msg string) {
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
if q := c.PostForm("order_no"); q != "" {
|
appendSybFormState(params, c)
|
||||||
params.Set("order_no", q)
|
|
||||||
}
|
|
||||||
if msg != "" {
|
if msg != "" {
|
||||||
params.Set("msg", msg)
|
params.Set("msg", msg)
|
||||||
}
|
}
|
||||||
@@ -325,9 +351,8 @@ func (h *Handler) sybRedirect(c *gin.Context, msg string) {
|
|||||||
// (比如 ocr_url 留空,压根没尝试过自动识别)弹窗不显示任何原因说明。
|
// (比如 ocr_url 留空,压根没尝试过自动识别)弹窗不显示任何原因说明。
|
||||||
func (h *Handler) sybRedirectLogin(c *gin.Context, reason string) {
|
func (h *Handler) sybRedirectLogin(c *gin.Context, reason string) {
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
if q := c.PostForm("order_no"); q != "" {
|
appendSybFormState(params, c)
|
||||||
params.Set("order_no", q)
|
params.Set("login", "1")
|
||||||
}
|
|
||||||
if reason != "" {
|
if reason != "" {
|
||||||
params.Set("login_reason", reason)
|
params.Set("login_reason", reason)
|
||||||
}
|
}
|
||||||
@@ -338,6 +363,25 @@ func (h *Handler) sybRedirectLogin(c *gin.Context, reason string) {
|
|||||||
c.Redirect(http.StatusSeeOther, target)
|
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 保存规格匹配结果。
|
// SybMatch 保存规格匹配结果。
|
||||||
//
|
//
|
||||||
// 关键:保存的是**可复用的 SKU 映射**(写 sku_mappings),
|
// 关键:保存的是**可复用的 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(
|
pdd, err := repository.EnsurePddProduct(
|
||||||
db, "737116531267",
|
db, "737116531267",
|
||||||
"https://mobile.yangkeduo.com/goods.html?goods_id=737116531267")
|
"https://mobile.yangkeduo.com/goods.html?goods_id=737116531267")
|
||||||
|
|||||||
+97
-14
@@ -32,6 +32,49 @@ import (
|
|||||||
|
|
||||||
const dateLayout = "2006-01-02"
|
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 是顺运宝服务端的时区。
|
// sybLocation 是顺运宝服务端的时区。
|
||||||
//
|
//
|
||||||
// `[必须]` 日期范围筛的是服务端的 created,而它是 UTC+8——实测
|
// `[必须]` 日期范围筛的是服务端的 created,而它是 UTC+8——实测
|
||||||
@@ -57,6 +100,12 @@ func dateOf(t time.Time) string {
|
|||||||
return t.In(sybLocation).Format(dateLayout)
|
return t.In(sybLocation).Format(dateLayout)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SybToday 返回顺运宝服务端时区下的今天,供页面 date 输入的 max/default
|
||||||
|
// 使用。页面和同步校验共用这一处,避免本机时区不同导致显示和校验差一天。
|
||||||
|
func SybToday(now time.Time) string {
|
||||||
|
return dateOf(now)
|
||||||
|
}
|
||||||
|
|
||||||
// syncDateRange 算出这次同步该拉哪个日期范围。
|
// syncDateRange 算出这次同步该拉哪个日期范围。
|
||||||
//
|
//
|
||||||
// `[必须]` 增量必须从"上次同步日期当天"重新拉,不是第二天——created
|
// `[必须]` 增量必须从"上次同步日期当天"重新拉,不是第二天——created
|
||||||
@@ -97,8 +146,9 @@ type SkipNote struct {
|
|||||||
// SyncReport 是一次同步的结果,供状态条显示。
|
// SyncReport 是一次同步的结果,供状态条显示。
|
||||||
type SyncReport struct {
|
type SyncReport struct {
|
||||||
From, To string
|
From, To string
|
||||||
StockCount int // 拉到的货运单数
|
Specified bool // true 表示操作员发起的指定日期补同步
|
||||||
DetailCount int // 落库的商品明细行数(不含跳过的)
|
StockCount int // 拉到的货运单数
|
||||||
|
DetailCount int // 落库的商品明细行数(不含跳过的)
|
||||||
Created int
|
Created int
|
||||||
Updated int
|
Updated int
|
||||||
SkippedZero int // quantity <= 0 被跳过的条数
|
SkippedZero int // quantity <= 0 被跳过的条数
|
||||||
@@ -116,7 +166,11 @@ func (r SyncReport) Summary() string {
|
|||||||
if r.Err != nil {
|
if r.Err != nil {
|
||||||
return "同步失败:" + r.Err.Error()
|
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)
|
r.From, r.To, r.StockCount, r.DetailCount, r.Created, r.Updated, r.SkippedZero)
|
||||||
if len(r.Notes) > 0 {
|
if len(r.Notes) > 0 {
|
||||||
var reasons []string
|
var reasons []string
|
||||||
@@ -222,7 +276,15 @@ func roundYuanToCent(yuan float64) int64 {
|
|||||||
// 如果会话恰好失效,会从 syb.Client 的调用里冒出 syb.ErrSessionInvalid,
|
// 如果会话恰好失效,会从 syb.Client 的调用里冒出 syb.ErrSessionInvalid,
|
||||||
// 和其它错误一样按"中途失败"处理:不更新 last_synced_at,把原因写进报告。
|
// 和其它错误一样按"中途失败"处理:不更新 last_synced_at,把原因写进报告。
|
||||||
func RunSybSync(ctx context.Context, db *sql.DB, client *syb.Client, cfg config.SybConfig, now time.Time) SyncReport {
|
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
|
pageSize := cfg.PageSize
|
||||||
if pageSize <= 0 {
|
if pageSize <= 0 {
|
||||||
@@ -240,11 +302,28 @@ func RunSybSync(ctx context.Context, db *sql.DB, client *syb.Client, cfg config.
|
|||||||
return report
|
return report
|
||||||
}
|
}
|
||||||
|
|
||||||
from, to, err := syncDateRange(lastSyncedAt, cfg.SyncFrom, now)
|
from, to := options.From, options.To
|
||||||
if err != nil {
|
advanceCursor := !options.IsSpecified()
|
||||||
report.Err = err
|
if options.IsSpecified() {
|
||||||
report.FinishedAt = time.Now().UTC()
|
validated, validateErr := NewSybSyncOptions(options.From, options.To, now)
|
||||||
return report
|
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
|
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 {
|
if total == 0 {
|
||||||
report.FinishedAt = time.Now().UTC()
|
report.FinishedAt = time.Now().UTC()
|
||||||
if err := repository.SetSybLastSyncedAt(db, model.NowISO()); err != nil {
|
if advanceCursor {
|
||||||
report.Err = fmt.Errorf("更新同步进度失败: %w", err)
|
if err := repository.SetSybLastSyncedAt(db, model.NowISO()); err != nil {
|
||||||
|
report.Err = fmt.Errorf("更新同步进度失败: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return report
|
return report
|
||||||
}
|
}
|
||||||
@@ -329,9 +410,11 @@ func RunSybSync(ctx context.Context, db *sql.DB, client *syb.Client, cfg config.
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ④ 全部成功,才更新 last_synced_at。
|
// ④ 全部成功,才更新 last_synced_at。
|
||||||
if err := repository.SetSybLastSyncedAt(db, model.NowISO()); err != nil {
|
if advanceCursor {
|
||||||
report.Err = fmt.Errorf("同步数据已全部写入,但更新同步进度失败,"+
|
if err := repository.SetSybLastSyncedAt(db, model.NowISO()); err != nil {
|
||||||
"下次同步会重新拉这个日期范围(不会漏,但会重复拉一次): %w", err)
|
report.Err = fmt.Errorf("同步数据已全部写入,但更新同步进度失败,"+
|
||||||
|
"下次同步会重新拉这个日期范围(不会漏,但会重复拉一次): %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
report.FinishedAt = time.Now().UTC()
|
report.FinishedAt = time.Now().UTC()
|
||||||
return report
|
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) {
|
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) {
|
func TestRunSybSync_数量小于等于0被跳过并计入报告(t *testing.T) {
|
||||||
srv := fakeSybServer(t, []fakeStock{
|
srv := fakeSybServer(t, []fakeStock{
|
||||||
{ID: 1, Code: "A", Details: []fakeDetail{
|
{ID: 1, Code: "A", Details: []fakeDetail{
|
||||||
|
|||||||
@@ -187,6 +187,15 @@ tr.empty small { color: #aaa; }
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 720px;
|
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 {
|
.modal-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -255,6 +264,13 @@ input[type="text"] {
|
|||||||
border: 1px solid #ccd1d6;
|
border: 1px solid #ccd1d6;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
input[type="date"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border: 1px solid #ccd1d6;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
input.wide { width: 100%; }
|
input.wide { width: 100%; }
|
||||||
|
|
||||||
select {
|
select {
|
||||||
|
|||||||
@@ -89,13 +89,18 @@
|
|||||||
散到前端就没人维护得住了。 */
|
散到前端就没人维护得住了。 */
|
||||||
|
|
||||||
function openModal(modal) {
|
function openModal(modal) {
|
||||||
|
modal._returnFocus = document.activeElement;
|
||||||
modal.hidden = false;
|
modal.hidden = false;
|
||||||
var focusable = modal.querySelector("input, button");
|
var focusable = modal.querySelector("[autofocus]") ||
|
||||||
|
modal.querySelector("input, button");
|
||||||
if (focusable) focusable.focus();
|
if (focusable) focusable.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeModal(modal) {
|
function closeModal(modal) {
|
||||||
modal.hidden = true;
|
modal.hidden = true;
|
||||||
|
if (modal._returnFocus && modal._returnFocus.focus) {
|
||||||
|
modal._returnFocus.focus();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupModals() {
|
function setupModals() {
|
||||||
|
|||||||
@@ -5,9 +5,11 @@
|
|||||||
<form class="inline" method="post" action="/syb/sync">
|
<form class="inline" method="post" action="/syb/sync">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<input type="hidden" name="order_no" value="{{.Keyword}}">
|
<input type="hidden" name="order_no" value="{{.Keyword}}">
|
||||||
<button type="submit">同步</button>
|
<button type="submit" class="primary">同步</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<button type="button" data-modal-open="sync-range-modal">指定日期同步</button>
|
||||||
|
|
||||||
<form class="inline" method="post" action="/syb/create-task">
|
<form class="inline" method="post" action="/syb/create-task">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<button type="submit" data-need-checked>创建采购任务</button>
|
<button type="submit" data-need-checked>创建采购任务</button>
|
||||||
@@ -28,6 +30,47 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</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}}
|
{{if .ConfigProblem}}
|
||||||
<p class="missing">
|
<p class="missing">
|
||||||
顺运宝配置有问题:{{.ConfigProblem}}
|
顺运宝配置有问题:{{.ConfigProblem}}
|
||||||
@@ -118,6 +161,11 @@
|
|||||||
<form method="post" action="/syb/login-and-sync">
|
<form method="post" action="/syb/login-and-sync">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<input type="hidden" name="order_no" value="{{.Keyword}}">
|
<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">
|
<div class="modal-body">
|
||||||
{{/* `[必须]` 工单 #47:自动识别验证码失败/降级时要说明原因
|
{{/* `[必须]` 工单 #47:自动识别验证码失败/降级时要说明原因
|
||||||
(已尝试 N 次 / 服务不可用),操作员才知道是配置错了还是
|
(已尝试 N 次 / 服务不可用),操作员才知道是配置错了还是
|
||||||
|
|||||||
@@ -407,7 +407,7 @@ Go 的 map 是无序的,不靠它定顺序的话,同一个商品每次刷新
|
|||||||
### 6.1 工具条
|
### 6.1 工具条
|
||||||
|
|
||||||
```text
|
```text
|
||||||
[同步] [创建采购任务] 订单号 [___] [搜索] [删除]
|
[同步] [指定日期同步] [创建采购任务] 订单号 [___] [搜索] [删除]
|
||||||
```
|
```
|
||||||
|
|
||||||
`[必须]` 「同步」按工单 #46 实现:
|
`[必须]` 「同步」按工单 #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-搜索框宽度)。
|
`[必须]` 搜索框宽度见 [§3.1](#31-搜索框宽度)。
|
||||||
|
|
||||||
### 6.2 表格列
|
### 6.2 表格列
|
||||||
|
|||||||
@@ -189,6 +189,29 @@ POST /am/stock/list → data.list 是数组
|
|||||||
示例脚本用的是 `max_matches = 100`。日期范围拉全量时这个上限要调大,
|
示例脚本用的是 `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. 货运单字段
|
## 5. 货运单字段
|
||||||
|
|||||||
Reference in New Issue
Block a user