feat(t224): add incremental freight sync
This commit is contained in:
@@ -66,6 +66,10 @@ func registerAdminAPI(routes gin.IRoutes, services AdminServices) error {
|
||||
if services.Freight != nil {
|
||||
routes.POST("/api/v1/freight-syncs", handler.createFreightSync)
|
||||
routes.GET("/api/v1/freight-syncs/:id", handler.freightSyncDetail)
|
||||
routes.GET(
|
||||
"/api/v1/freight-sync-watermark",
|
||||
handler.freightSyncWatermark,
|
||||
)
|
||||
routes.GET("/api/v1/freight-orders", handler.listFreightOrders)
|
||||
routes.GET("/api/v1/freight-orders/:id", handler.freightOrderDetail)
|
||||
}
|
||||
|
||||
@@ -395,6 +395,89 @@ func TestAdminFreightAPIImportsAllItemsWithoutPII(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminFreightDateSyncAdvancesInspectableWatermark(t *testing.T) {
|
||||
fixture := newAdminIntegrationFixture(t)
|
||||
location, err := time.LoadLocation("Asia/Shanghai")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLocation() error = %v", err)
|
||||
}
|
||||
today := time.Now().In(location).Format(time.DateOnly)
|
||||
body := fmt.Sprintf(
|
||||
`{"mode":"CREATED_RANGE","created_from":%q,"created_to":%q}`,
|
||||
today,
|
||||
today,
|
||||
)
|
||||
create := performAdminRequest(
|
||||
t,
|
||||
fixture.router,
|
||||
http.MethodPost,
|
||||
"/api/v1/freight-syncs",
|
||||
"application/json",
|
||||
strings.NewReader(body),
|
||||
"freight-date-sync-1",
|
||||
)
|
||||
requireAdminStatus(t, create, http.StatusAccepted)
|
||||
var created struct {
|
||||
Sync struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"sync"`
|
||||
}
|
||||
decodeResponse(t, create, &created)
|
||||
var status *httptest.ResponseRecorder
|
||||
for attempt := 0; attempt < 50; attempt++ {
|
||||
status = performAdminRequest(
|
||||
t,
|
||||
fixture.router,
|
||||
http.MethodGet,
|
||||
"/api/v1/freight-syncs/"+created.Sync.ID,
|
||||
"",
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
if strings.Contains(status.Body.String(), `"status":"SUCCEEDED"`) {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
requireAdminStatus(t, status, http.StatusOK)
|
||||
if !strings.Contains(status.Body.String(), `"mode":"CREATED_RANGE"`) ||
|
||||
!strings.Contains(status.Body.String(), `"created_from":"`+today+`"`) ||
|
||||
!strings.Contains(status.Body.String(), `"order_count":0`) {
|
||||
t.Fatalf("date sync response = %s", status.Body)
|
||||
}
|
||||
|
||||
watermark := performAdminRequest(
|
||||
t,
|
||||
fixture.router,
|
||||
http.MethodGet,
|
||||
"/api/v1/freight-sync-watermark",
|
||||
"",
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
requireAdminStatus(t, watermark, http.StatusOK)
|
||||
if !strings.Contains(
|
||||
watermark.Body.String(),
|
||||
`"last_successful_run_id":"`+created.Sync.ID+`"`,
|
||||
) || strings.Contains(watermark.Body.String(), "receiver") {
|
||||
t.Fatalf("watermark response = %s", watermark.Body)
|
||||
}
|
||||
|
||||
mixed := performAdminRequest(
|
||||
t,
|
||||
fixture.router,
|
||||
http.MethodPost,
|
||||
"/api/v1/freight-syncs",
|
||||
"application/json",
|
||||
strings.NewReader(
|
||||
`{"mode":"CREATED_RANGE","order_number":"must-not-be-ignored",`+
|
||||
`"created_from":"`+today+`","created_to":"`+today+`"}`,
|
||||
),
|
||||
"freight-date-mixed",
|
||||
)
|
||||
requireAdminStatus(t, mixed, http.StatusUnprocessableEntity)
|
||||
}
|
||||
|
||||
func TestAdminProcurementAPIProducesImmutablePendingTask(t *testing.T) {
|
||||
fixture := newAdminIntegrationFixture(t)
|
||||
createSync := performAdminRequest(
|
||||
@@ -708,6 +791,9 @@ func TestAdminOrderAuthorizationIsIdempotentAndRevisioned(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("migration.New() error = %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err != nil {
|
||||
t.Fatalf("date sync migration down: %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err != nil {
|
||||
t.Fatalf("procurement migration down: %v", err)
|
||||
}
|
||||
@@ -1125,6 +1211,21 @@ func (staticFreightSource) QueryOrder(
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (staticFreightSource) QueryCreatedRange(
|
||||
_ context.Context,
|
||||
createdFrom, createdTo string,
|
||||
) (domain.FreightSourceBatch, error) {
|
||||
return domain.FreightSourceBatch{
|
||||
SchemaVersion: 1,
|
||||
Query: domain.FreightSourceQuery{
|
||||
Mode: domain.FreightSyncCreatedRange,
|
||||
CreatedFrom: &createdFrom,
|
||||
CreatedTo: &createdTo,
|
||||
},
|
||||
Orders: []domain.FreightSourceOrder{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func mustDecodeAny(
|
||||
t *testing.T,
|
||||
response *httptest.ResponseRecorder,
|
||||
|
||||
@@ -713,6 +713,9 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("migration.New() after review error = %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err != nil {
|
||||
t.Fatalf("date sync migration down: %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err != nil {
|
||||
t.Fatalf("procurement migration down: %v", err)
|
||||
}
|
||||
@@ -733,8 +736,8 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
|
||||
}
|
||||
if applied, err := runner.Up(context.Background()); err != nil {
|
||||
t.Fatalf("restore device command migration: %v", err)
|
||||
} else if applied != 5 {
|
||||
t.Fatalf("restored migrations = %d, want 5", applied)
|
||||
} else if applied != 6 {
|
||||
t.Fatalf("restored migrations = %d, want 6", applied)
|
||||
}
|
||||
|
||||
completePayload := fmt.Sprintf(
|
||||
@@ -1423,6 +1426,9 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable(
|
||||
if err != nil {
|
||||
t.Fatalf("migration.New() error = %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err != nil {
|
||||
t.Fatalf("date sync migration down: %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err != nil {
|
||||
t.Fatalf("procurement migration down: %v", err)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ func (h *adminHandlers) createFreightSync(ctx *gin.Context) {
|
||||
var request struct {
|
||||
Mode string `json:"mode"`
|
||||
OrderNumber string `json:"order_number"`
|
||||
CreatedFrom string `json:"created_from"`
|
||||
CreatedTo string `json:"created_to"`
|
||||
SyncToNow bool `json:"sync_to_now"`
|
||||
}
|
||||
if err := decodeJSON(ctx, &request); err != nil {
|
||||
writePublicError(
|
||||
@@ -38,26 +41,66 @@ func (h *adminHandlers) createFreightSync(ctx *gin.Context) {
|
||||
)
|
||||
return
|
||||
}
|
||||
if request.Mode != domain.FreightSyncOrderNumber {
|
||||
var result usecase.CreateFreightSyncResult
|
||||
var err error
|
||||
switch request.Mode {
|
||||
case domain.FreightSyncOrderNumber:
|
||||
if strings.TrimSpace(request.CreatedFrom) != "" ||
|
||||
strings.TrimSpace(request.CreatedTo) != "" ||
|
||||
request.SyncToNow {
|
||||
writePublicError(
|
||||
ctx,
|
||||
http.StatusUnprocessableEntity,
|
||||
"FREIGHT_SYNC_INVALID",
|
||||
"freight sync request is invalid",
|
||||
false,
|
||||
fieldDetails("mode", "ORDER_NUMBER cannot include date parameters"),
|
||||
)
|
||||
return
|
||||
}
|
||||
result, err = h.services.Freight.CreateOrderSync(
|
||||
ctx.Request.Context(),
|
||||
usecase.CreateFreightSyncCommand{
|
||||
CreatorSubject: localAdminSubject,
|
||||
ActorUserID: adminActorUserID(ctx),
|
||||
IdempotencyKey: ctx.GetHeader("Idempotency-Key"),
|
||||
OrderNumber: request.OrderNumber,
|
||||
},
|
||||
)
|
||||
case domain.FreightSyncCreatedRange:
|
||||
if strings.TrimSpace(request.OrderNumber) != "" {
|
||||
writePublicError(
|
||||
ctx,
|
||||
http.StatusUnprocessableEntity,
|
||||
"FREIGHT_SYNC_INVALID",
|
||||
"freight sync request is invalid",
|
||||
false,
|
||||
fieldDetails("mode", "CREATED_RANGE cannot include order_number"),
|
||||
)
|
||||
return
|
||||
}
|
||||
result, err = h.services.Freight.CreateDateSync(
|
||||
ctx.Request.Context(),
|
||||
usecase.CreateFreightDateSyncCommand{
|
||||
CreatorSubject: localAdminSubject,
|
||||
ActorUserID: adminActorUserID(ctx),
|
||||
IdempotencyKey: ctx.GetHeader("Idempotency-Key"),
|
||||
CreatedFrom: request.CreatedFrom,
|
||||
CreatedTo: request.CreatedTo,
|
||||
SyncToNow: request.SyncToNow,
|
||||
},
|
||||
)
|
||||
default:
|
||||
writePublicError(
|
||||
ctx,
|
||||
http.StatusUnprocessableEntity,
|
||||
"FREIGHT_SYNC_MODE_INVALID",
|
||||
"freight sync mode is not supported",
|
||||
false,
|
||||
fieldDetails("mode", "must be ORDER_NUMBER"),
|
||||
fieldDetails("mode", "must be ORDER_NUMBER or CREATED_RANGE"),
|
||||
)
|
||||
return
|
||||
}
|
||||
result, err := h.services.Freight.CreateOrderSync(
|
||||
ctx.Request.Context(),
|
||||
usecase.CreateFreightSyncCommand{
|
||||
CreatorSubject: localAdminSubject,
|
||||
ActorUserID: adminActorUserID(ctx),
|
||||
IdempotencyKey: ctx.GetHeader("Idempotency-Key"),
|
||||
OrderNumber: request.OrderNumber,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
writeUsecaseError(ctx, err)
|
||||
return
|
||||
@@ -69,6 +112,28 @@ func (h *adminHandlers) createFreightSync(ctx *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func (h *adminHandlers) freightSyncWatermark(ctx *gin.Context) {
|
||||
watermark, err := h.services.Freight.GetWatermark(
|
||||
ctx.Request.Context(),
|
||||
localAdminSubject,
|
||||
)
|
||||
if err != nil {
|
||||
writeUsecaseError(ctx, err)
|
||||
return
|
||||
}
|
||||
ctx.Header("Cache-Control", "no-store")
|
||||
if watermark == nil {
|
||||
ctx.JSON(http.StatusOK, gin.H{"watermark": nil})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, gin.H{"watermark": gin.H{
|
||||
"source_system": watermark.SourceSystem,
|
||||
"last_successful_to": formatTime(watermark.LastSuccessfulTo),
|
||||
"last_successful_run_id": watermark.LastSuccessfulRunID,
|
||||
"updated_at": formatTime(watermark.UpdatedAt),
|
||||
}})
|
||||
}
|
||||
|
||||
func (h *adminHandlers) freightSyncDetail(ctx *gin.Context) {
|
||||
run, err := h.services.Freight.GetSync(
|
||||
ctx.Request.Context(),
|
||||
@@ -175,19 +240,29 @@ func (h *adminHandlers) freightOrderDetail(ctx *gin.Context) {
|
||||
|
||||
func freightSyncResponse(run domain.FreightSyncRun) gin.H {
|
||||
return gin.H{
|
||||
"id": run.ID,
|
||||
"mode": run.Mode,
|
||||
"query_sha256": run.QuerySHA256,
|
||||
"status": run.Status,
|
||||
"error_code": run.ErrorCode,
|
||||
"order_count": run.OrderCount,
|
||||
"item_count": run.ItemCount,
|
||||
"created_at": formatTime(run.CreatedAt),
|
||||
"started_at": formatOptionalTime(run.StartedAt),
|
||||
"finished_at": formatOptionalTime(run.FinishedAt),
|
||||
"id": run.ID,
|
||||
"mode": run.Mode,
|
||||
"created_from": nullableResponseString(run.CreatedFrom),
|
||||
"created_to": nullableResponseString(run.CreatedTo),
|
||||
"watermark_through": formatOptionalTime(run.WatermarkThrough),
|
||||
"query_sha256": run.QuerySHA256,
|
||||
"status": run.Status,
|
||||
"error_code": run.ErrorCode,
|
||||
"order_count": run.OrderCount,
|
||||
"item_count": run.ItemCount,
|
||||
"created_at": formatTime(run.CreatedAt),
|
||||
"started_at": formatOptionalTime(run.StartedAt),
|
||||
"finished_at": formatOptionalTime(run.FinishedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func nullableResponseString(value string) any {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func freightOrderResponse(order domain.FreightOrder) gin.H {
|
||||
return gin.H{
|
||||
"id": order.ID,
|
||||
|
||||
@@ -140,6 +140,17 @@ func (h *Handler) ImportFreight(ctx *gin.Context) {
|
||||
CSRFToken: token,
|
||||
},
|
||||
IdempotencyKey: key,
|
||||
Mode: "ORDER_NUMBER",
|
||||
}
|
||||
if location, locationErr := time.LoadLocation("Asia/Shanghai"); locationErr == nil {
|
||||
today := time.Now().In(location).Format(time.DateOnly)
|
||||
page.CreatedFrom = today
|
||||
page.CreatedTo = today
|
||||
}
|
||||
if watermark, watermarkErr := service.GetFreightWatermark(
|
||||
ctx.Request.Context(),
|
||||
); watermarkErr == nil {
|
||||
page.Watermark = watermark
|
||||
}
|
||||
if syncID := strings.TrimSpace(ctx.Query("sync")); syncID != "" {
|
||||
run, getErr := service.GetFreightSync(ctx.Request.Context(), syncID)
|
||||
@@ -157,9 +168,29 @@ func (h *Handler) CreateFreightImport(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
orderNumber := strings.TrimSpace(ctx.PostForm("order_number"))
|
||||
mode := strings.TrimSpace(ctx.PostForm("mode"))
|
||||
if mode == "" {
|
||||
mode = "ORDER_NUMBER"
|
||||
}
|
||||
createdFrom := strings.TrimSpace(ctx.PostForm("created_from"))
|
||||
createdTo := strings.TrimSpace(ctx.PostForm("created_to"))
|
||||
syncToNow := ctx.PostForm("sync_to_now") == "true"
|
||||
if syncToNow {
|
||||
createdFrom = ""
|
||||
createdTo = ""
|
||||
}
|
||||
key := strings.TrimSpace(ctx.PostForm("idempotency_key"))
|
||||
if orderNumber == "" || len([]byte(orderNumber)) > 128 ||
|
||||
!validToken(key) {
|
||||
validInput := validToken(key)
|
||||
if mode == "ORDER_NUMBER" {
|
||||
validInput = validInput && orderNumber != "" &&
|
||||
len([]byte(orderNumber)) <= 128
|
||||
} else if mode == "CREATED_RANGE" {
|
||||
validInput = validInput &&
|
||||
(syncToNow || (createdFrom != "" && createdTo != ""))
|
||||
} else {
|
||||
validInput = false
|
||||
}
|
||||
if !validInput {
|
||||
token, _ := csrfToken(ctx)
|
||||
h.render(ctx, http.StatusUnprocessableEntity, "freight-import", freightImportPage{
|
||||
Page: pageView{
|
||||
@@ -168,8 +199,11 @@ func (h *Handler) CreateFreightImport(ctx *gin.Context) {
|
||||
CSRFToken: token,
|
||||
},
|
||||
OrderNumber: orderNumber,
|
||||
Mode: mode,
|
||||
CreatedFrom: createdFrom,
|
||||
CreatedTo: createdTo,
|
||||
IdempotencyKey: key,
|
||||
Error: "请输入完整单号后重试。",
|
||||
Error: "请检查同步方式和查询条件后重试。",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -179,7 +213,11 @@ func (h *Handler) CreateFreightImport(ctx *gin.Context) {
|
||||
CreateFreightSyncInput{
|
||||
ActorUserID: actorUserID(ctx.Request.Context()),
|
||||
IdempotencyKey: key,
|
||||
Mode: mode,
|
||||
OrderNumber: orderNumber,
|
||||
CreatedFrom: createdFrom,
|
||||
CreatedTo: createdTo,
|
||||
SyncToNow: syncToNow,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
@@ -191,6 +229,9 @@ func (h *Handler) CreateFreightImport(ctx *gin.Context) {
|
||||
CSRFToken: token,
|
||||
},
|
||||
OrderNumber: orderNumber,
|
||||
Mode: mode,
|
||||
CreatedFrom: createdFrom,
|
||||
CreatedTo: createdTo,
|
||||
IdempotencyKey: key,
|
||||
Error: "同步任务创建失败,请稍后使用相同提交标识重试。",
|
||||
})
|
||||
@@ -1056,10 +1097,14 @@ type freightPage struct {
|
||||
|
||||
type freightImportPage struct {
|
||||
Page pageView
|
||||
Mode string
|
||||
OrderNumber string
|
||||
CreatedFrom string
|
||||
CreatedTo string
|
||||
IdempotencyKey string
|
||||
Error string
|
||||
Sync *FreightSync
|
||||
Watermark *FreightWatermark
|
||||
}
|
||||
|
||||
type freightDetailPage struct {
|
||||
|
||||
@@ -959,6 +959,11 @@ func TestFreightPagesEscapeSourceDataAndCreateAsyncSync(t *testing.T) {
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
if !strings.Contains(form.Body.String(), "同步至现在") ||
|
||||
!strings.Contains(form.Body.String(), `name="created_from"`) ||
|
||||
!strings.Contains(form.Body.String(), "尚无日期同步水位") {
|
||||
t.Fatalf("freight import form = %s", form.Body)
|
||||
}
|
||||
cookie := csrfCookie(t, form)
|
||||
idempotencyKey := hiddenValue(
|
||||
t,
|
||||
@@ -994,6 +999,102 @@ func TestFreightPagesEscapeSourceDataAndCreateAsyncSync(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreightDateFormSubmitsManualRange(t *testing.T) {
|
||||
now := time.Date(2026, 7, 28, 3, 4, 5, 0, time.UTC)
|
||||
service := &fakeFreightService{
|
||||
fakeService: &fakeService{},
|
||||
watermark: &FreightWatermark{
|
||||
LastSuccessfulTo: now,
|
||||
LastSuccessfulRunID: testTaskID,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
createResult: FreightSync{
|
||||
ID: testTaskID,
|
||||
Mode: "CREATED_RANGE",
|
||||
Status: "PENDING",
|
||||
CreatedAt: now,
|
||||
},
|
||||
}
|
||||
router := newTestRouter(t, service)
|
||||
form := performRequest(t, router, http.MethodGet, "/freight/import", nil, "")
|
||||
if !strings.Contains(form.Body.String(), "增量同步水位") ||
|
||||
!strings.Contains(form.Body.String(), testTaskID) {
|
||||
t.Fatalf("watermark form = %s", form.Body)
|
||||
}
|
||||
cookie := csrfCookie(t, form)
|
||||
key := hiddenValue(t, form.Body.String(), "idempotency_key")
|
||||
values := url.Values{
|
||||
"csrf_token": {cookie.Value},
|
||||
"idempotency_key": {key},
|
||||
"mode": {"CREATED_RANGE"},
|
||||
"created_from": {"2026-07-22"},
|
||||
"created_to": {"2026-07-28"},
|
||||
}
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/freight/import",
|
||||
strings.NewReader(values.Encode()),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(cookie)
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther ||
|
||||
service.createInput.Mode != "CREATED_RANGE" ||
|
||||
service.createInput.CreatedFrom != "2026-07-22" ||
|
||||
service.createInput.CreatedTo != "2026-07-28" {
|
||||
t.Fatalf(
|
||||
"date form response/input = %d / %+v",
|
||||
response.Code,
|
||||
service.createInput,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreightSyncToNowIgnoresPrefilledManualDates(t *testing.T) {
|
||||
service := &fakeFreightService{
|
||||
fakeService: &fakeService{},
|
||||
createResult: FreightSync{
|
||||
ID: testTaskID,
|
||||
Mode: "CREATED_RANGE",
|
||||
Status: "PENDING",
|
||||
},
|
||||
}
|
||||
router := newTestRouter(t, service)
|
||||
form := performRequest(t, router, http.MethodGet, "/freight/import", nil, "")
|
||||
cookie := csrfCookie(t, form)
|
||||
key := hiddenValue(t, form.Body.String(), "idempotency_key")
|
||||
values := url.Values{
|
||||
"csrf_token": {cookie.Value},
|
||||
"idempotency_key": {key},
|
||||
"mode": {"CREATED_RANGE"},
|
||||
"created_from": {"2026-07-22"},
|
||||
"created_to": {"2026-07-28"},
|
||||
"sync_to_now": {"true"},
|
||||
}
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/freight/import",
|
||||
strings.NewReader(values.Encode()),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(cookie)
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther ||
|
||||
!service.createInput.SyncToNow ||
|
||||
service.createInput.CreatedFrom != "" ||
|
||||
service.createInput.CreatedTo != "" {
|
||||
t.Fatalf(
|
||||
"sync-to-now response/input = %d / %+v",
|
||||
response.Code,
|
||||
service.createInput,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreightDetailCreatesProcurementTaskWithCSRF(t *testing.T) {
|
||||
const itemID = "00000000-0000-4000-8000-000000000002"
|
||||
service := &fakeProcurementService{
|
||||
@@ -1101,6 +1202,7 @@ type fakeFreightService struct {
|
||||
sync FreightSync
|
||||
createInput CreateFreightSyncInput
|
||||
createResult FreightSync
|
||||
watermark *FreightWatermark
|
||||
err error
|
||||
}
|
||||
|
||||
@@ -1159,6 +1261,12 @@ func (service *fakeFreightService) GetFreightSync(
|
||||
return service.sync, service.err
|
||||
}
|
||||
|
||||
func (service *fakeFreightService) GetFreightWatermark(
|
||||
context.Context,
|
||||
) (*FreightWatermark, error) {
|
||||
return service.watermark, service.err
|
||||
}
|
||||
|
||||
func (service *fakeFreightService) CreateFreightSync(
|
||||
_ context.Context,
|
||||
input CreateFreightSyncInput,
|
||||
|
||||
@@ -688,6 +688,27 @@ tbody tr:last-child td {
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.form-panel + .form-panel {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.form-panel h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.date-range-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
@@ -1273,7 +1294,8 @@ tbody tr:last-child td {
|
||||
}
|
||||
|
||||
.filters,
|
||||
.form-grid {
|
||||
.form-grid,
|
||||
.date-range-fields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<div class="title-row">
|
||||
<div>
|
||||
<h1>导入 ERP 货运</h1>
|
||||
<p class="subtitle">使用 ERP 页面“全部单号”中的完整单号</p>
|
||||
<p class="subtitle">按完整单号导入,或按创建日期发现新增货运</p>
|
||||
</div>
|
||||
<a class="button" href="/freight">返回列表</a>
|
||||
</div>
|
||||
@@ -20,6 +20,10 @@
|
||||
<section class="detail-section" aria-labelledby="sync-result-title">
|
||||
<h2 id="sync-result-title">同步状态</h2>
|
||||
<dl class="detail-grid">
|
||||
<div><dt>方式</dt><dd>{{if eq .Sync.Mode "CREATED_RANGE"}}创建日期{{else}}完整单号{{end}}</dd></div>
|
||||
{{if eq .Sync.Mode "CREATED_RANGE"}}
|
||||
<div><dt>查询范围</dt><dd>{{.Sync.CreatedFrom}} 至 {{.Sync.CreatedTo}}</dd></div>
|
||||
{{end}}
|
||||
<div><dt>状态</dt><dd>{{.Sync.Status}}</dd></div>
|
||||
<div><dt>货运单</dt><dd>{{.Sync.OrderCount}}</dd></div>
|
||||
<div><dt>商品明细</dt><dd>{{.Sync.ItemCount}}</dd></div>
|
||||
@@ -30,15 +34,49 @@
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
{{if .Watermark}}
|
||||
<section class="detail-section" aria-labelledby="watermark-title">
|
||||
<h2 id="watermark-title">增量同步水位</h2>
|
||||
<dl class="detail-grid">
|
||||
<div><dt>已成功同步至</dt><dd><time datetime="{{machineTime .Watermark.LastSuccessfulTo}}">{{displayTime .Watermark.LastSuccessfulTo}}</time></dd></div>
|
||||
<div><dt>最近成功任务</dt><dd>{{.Watermark.LastSuccessfulRunID}}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
{{else}}
|
||||
<div class="notice" role="status">尚无日期同步水位,“同步至现在”将从今天开始。</div>
|
||||
{{end}}
|
||||
<form class="form-panel" method="post" action="/freight/import" data-loading-form>
|
||||
<input type="hidden" name="csrf_token" value="{{.Page.CSRFToken}}">
|
||||
<input type="hidden" name="idempotency_key" value="{{.IdempotencyKey}}">
|
||||
<input type="hidden" name="mode" value="ORDER_NUMBER">
|
||||
<h2>按完整单号</h2>
|
||||
<div class="field">
|
||||
<label for="order-number">完整单号</label>
|
||||
<input id="order-number" name="order_number" value="{{.OrderNumber}}"
|
||||
maxlength="128" autocomplete="off" required>
|
||||
</div>
|
||||
<button class="button primary" type="submit" data-loading-label="正在创建…">开始同步</button>
|
||||
<button class="button primary" type="submit" data-loading-label="正在创建…">同步此单号</button>
|
||||
</form>
|
||||
<form class="form-panel" method="post" action="/freight/import" data-loading-form>
|
||||
<input type="hidden" name="csrf_token" value="{{.Page.CSRFToken}}">
|
||||
<input type="hidden" name="idempotency_key" value="{{.IdempotencyKey}}">
|
||||
<input type="hidden" name="mode" value="CREATED_RANGE">
|
||||
<h2>按创建日期</h2>
|
||||
<div class="date-range-fields">
|
||||
<div class="field">
|
||||
<label for="created-from">开始日期</label>
|
||||
<input id="created-from" name="created_from" type="date" value="{{.CreatedFrom}}" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="created-to">结束日期</label>
|
||||
<input id="created-to" name="created_to" type="date" value="{{.CreatedTo}}" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button class="button primary" type="submit" data-loading-label="正在创建…">同步日期范围</button>
|
||||
<button class="button" type="submit" name="sync_to_now" value="true"
|
||||
formnovalidate data-loading-label="正在创建…">同步至现在</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -31,6 +31,7 @@ type FreightService interface {
|
||||
ListFreightOrders(context.Context, int) ([]FreightOrder, error)
|
||||
GetFreightOrder(context.Context, string) (FreightOrderDetail, error)
|
||||
GetFreightSync(context.Context, string) (FreightSync, error)
|
||||
GetFreightWatermark(context.Context) (*FreightWatermark, error)
|
||||
CreateFreightSync(
|
||||
context.Context,
|
||||
CreateFreightSyncInput,
|
||||
@@ -71,19 +72,33 @@ type CreateProcurementTaskInput struct {
|
||||
}
|
||||
|
||||
type FreightSync struct {
|
||||
ID string
|
||||
Status string
|
||||
ErrorCode string
|
||||
OrderCount int
|
||||
ItemCount int
|
||||
CreatedAt time.Time
|
||||
FinishedAt time.Time
|
||||
ID string
|
||||
Mode string
|
||||
CreatedFrom string
|
||||
CreatedTo string
|
||||
WatermarkThrough time.Time
|
||||
Status string
|
||||
ErrorCode string
|
||||
OrderCount int
|
||||
ItemCount int
|
||||
CreatedAt time.Time
|
||||
FinishedAt time.Time
|
||||
}
|
||||
|
||||
type FreightWatermark struct {
|
||||
LastSuccessfulTo time.Time
|
||||
LastSuccessfulRunID string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type CreateFreightSyncInput struct {
|
||||
ActorUserID string
|
||||
IdempotencyKey string
|
||||
Mode string
|
||||
OrderNumber string
|
||||
CreatedFrom string
|
||||
CreatedTo string
|
||||
SyncToNow bool
|
||||
}
|
||||
|
||||
type FreightOrder struct {
|
||||
|
||||
@@ -270,21 +270,57 @@ func (adapter *UsecaseAdapter) CreateFreightSync(
|
||||
if adapter.freight == nil {
|
||||
return FreightSync{}, ErrUnavailable
|
||||
}
|
||||
result, err := adapter.freight.CreateOrderSync(
|
||||
ctx,
|
||||
usecase.CreateFreightSyncCommand{
|
||||
CreatorSubject: localAdminSubject,
|
||||
ActorUserID: input.ActorUserID,
|
||||
IdempotencyKey: input.IdempotencyKey,
|
||||
OrderNumber: input.OrderNumber,
|
||||
},
|
||||
)
|
||||
var result usecase.CreateFreightSyncResult
|
||||
var err error
|
||||
if input.Mode == domain.FreightSyncCreatedRange {
|
||||
result, err = adapter.freight.CreateDateSync(
|
||||
ctx,
|
||||
usecase.CreateFreightDateSyncCommand{
|
||||
CreatorSubject: localAdminSubject,
|
||||
ActorUserID: input.ActorUserID,
|
||||
IdempotencyKey: input.IdempotencyKey,
|
||||
CreatedFrom: input.CreatedFrom,
|
||||
CreatedTo: input.CreatedTo,
|
||||
SyncToNow: input.SyncToNow,
|
||||
},
|
||||
)
|
||||
} else {
|
||||
result, err = adapter.freight.CreateOrderSync(
|
||||
ctx,
|
||||
usecase.CreateFreightSyncCommand{
|
||||
CreatorSubject: localAdminSubject,
|
||||
ActorUserID: input.ActorUserID,
|
||||
IdempotencyKey: input.IdempotencyKey,
|
||||
OrderNumber: input.OrderNumber,
|
||||
},
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return FreightSync{}, mapUsecaseError(err)
|
||||
}
|
||||
return freightSyncFrom(result.Run), nil
|
||||
}
|
||||
|
||||
func (adapter *UsecaseAdapter) GetFreightWatermark(
|
||||
ctx context.Context,
|
||||
) (*FreightWatermark, error) {
|
||||
if adapter.freight == nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
watermark, err := adapter.freight.GetWatermark(ctx, localAdminSubject)
|
||||
if err != nil {
|
||||
return nil, mapUsecaseError(err)
|
||||
}
|
||||
if watermark == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &FreightWatermark{
|
||||
LastSuccessfulTo: watermark.LastSuccessfulTo,
|
||||
LastSuccessfulRunID: watermark.LastSuccessfulRunID,
|
||||
UpdatedAt: watermark.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func freightOrderFrom(order domain.FreightOrder) FreightOrder {
|
||||
result := FreightOrder{
|
||||
ID: order.ID,
|
||||
@@ -308,12 +344,18 @@ func freightOrderFrom(order domain.FreightOrder) FreightOrder {
|
||||
|
||||
func freightSyncFrom(run domain.FreightSyncRun) FreightSync {
|
||||
result := FreightSync{
|
||||
ID: run.ID,
|
||||
Status: string(run.Status),
|
||||
ErrorCode: stringValue(run.ErrorCode),
|
||||
OrderCount: run.OrderCount,
|
||||
ItemCount: run.ItemCount,
|
||||
CreatedAt: run.CreatedAt,
|
||||
ID: run.ID,
|
||||
Mode: run.Mode,
|
||||
CreatedFrom: run.CreatedFrom,
|
||||
CreatedTo: run.CreatedTo,
|
||||
Status: string(run.Status),
|
||||
ErrorCode: stringValue(run.ErrorCode),
|
||||
OrderCount: run.OrderCount,
|
||||
ItemCount: run.ItemCount,
|
||||
CreatedAt: run.CreatedAt,
|
||||
}
|
||||
if run.WatermarkThrough != nil {
|
||||
result.WatermarkThrough = *run.WatermarkThrough
|
||||
}
|
||||
if run.FinishedAt != nil {
|
||||
result.FinishedAt = *run.FinishedAt
|
||||
|
||||
Reference in New Issue
Block a user