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,
|
||||
|
||||
Reference in New Issue
Block a user