diff --git a/backend-api/internal/domain/freight.go b/backend-api/internal/domain/freight.go index 409a70a..374984b 100644 --- a/backend-api/internal/domain/freight.go +++ b/backend-api/internal/domain/freight.go @@ -5,6 +5,7 @@ import "time" const ( FreightSourceShunyunbao = "SHUNYUNBAO" FreightSyncOrderNumber = "ORDER_NUMBER" + FreightSyncCreatedRange = "CREATED_RANGE" ) type FreightSyncStatus string @@ -17,19 +18,22 @@ const ( ) type FreightSyncRun struct { - ID string - CreatorSubject string - CreatedByUserID string - Mode string - OrderNumber string - QuerySHA256 string - Status FreightSyncStatus - ErrorCode *string - OrderCount int - ItemCount int - CreatedAt time.Time - StartedAt *time.Time - FinishedAt *time.Time + ID string + CreatorSubject string + CreatedByUserID string + Mode string + OrderNumber string + CreatedFrom string + CreatedTo string + WatermarkThrough *time.Time + QuerySHA256 string + Status FreightSyncStatus + ErrorCode *string + OrderCount int + ItemCount int + CreatedAt time.Time + StartedAt *time.Time + FinishedAt *time.Time } type FreightOrder struct { @@ -84,7 +88,17 @@ type FreightSourceBatch struct { } type FreightSourceQuery struct { - Mode string `json:"mode"` + Mode string `json:"mode"` + CreatedFrom *string `json:"created_from"` + CreatedTo *string `json:"created_to"` +} + +type FreightSyncWatermark struct { + CreatorSubject string + SourceSystem string + LastSuccessfulTo time.Time + LastSuccessfulRunID string + UpdatedAt time.Time } type FreightSourceOrder struct { diff --git a/backend-api/internal/platform/erpconnector/client.go b/backend-api/internal/platform/erpconnector/client.go index 13c6b37..5249f97 100644 --- a/backend-api/internal/platform/erpconnector/client.go +++ b/backend-api/internal/platform/erpconnector/client.go @@ -57,11 +57,33 @@ func New(baseURL, apiKey string, timeout time.Duration) (*Client, error) { func (client *Client) QueryOrder( ctx context.Context, orderNumber string, +) (domain.FreightSourceBatch, error) { + return client.query(ctx, map[string]string{ + "mode": domain.FreightSyncOrderNumber, + "order_number": orderNumber, + }, domain.FreightSyncOrderNumber, "", "") +} + +func (client *Client) QueryCreatedRange( + ctx context.Context, + createdFrom, createdTo string, +) (domain.FreightSourceBatch, error) { + return client.query(ctx, map[string]string{ + "mode": domain.FreightSyncCreatedRange, + "created_from": createdFrom, + "created_to": createdTo, + }, domain.FreightSyncCreatedRange, createdFrom, createdTo) +} + +func (client *Client) query( + ctx context.Context, + payload map[string]string, + expectedMode, expectedFrom, expectedTo string, ) (domain.FreightSourceBatch, error) { if client.apiKey == "" { return domain.FreightSourceBatch{}, ErrNotConfigured } - body, err := json.Marshal(map[string]string{"order_number": orderNumber}) + body, err := json.Marshal(payload) if err != nil { return domain.FreightSourceBatch{}, ErrProtocol } @@ -106,9 +128,19 @@ func (client *Client) QueryOrder( if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { return domain.FreightSourceBatch{}, ErrProtocol } - if result.SchemaVersion != 1 || result.Query.Mode != "ORDER_NUMBER" || + if result.SchemaVersion != 1 || result.Query.Mode != expectedMode || result.Orders == nil { return domain.FreightSourceBatch{}, ErrProtocol } + if expectedMode == domain.FreightSyncOrderNumber { + if result.Query.CreatedFrom != nil || result.Query.CreatedTo != nil { + return domain.FreightSourceBatch{}, ErrProtocol + } + } else if result.Query.CreatedFrom == nil || + result.Query.CreatedTo == nil || + *result.Query.CreatedFrom != expectedFrom || + *result.Query.CreatedTo != expectedTo { + return domain.FreightSourceBatch{}, ErrProtocol + } return result, nil } diff --git a/backend-api/internal/platform/erpconnector/client_test.go b/backend-api/internal/platform/erpconnector/client_test.go index a20e02d..6154432 100644 --- a/backend-api/internal/platform/erpconnector/client_test.go +++ b/backend-api/internal/platform/erpconnector/client_test.go @@ -2,6 +2,7 @@ package erpconnector import ( "context" + "encoding/json" "errors" "net/http" "net/http/httptest" @@ -61,6 +62,85 @@ func TestQueryOrderAcceptsAllowlistResponse(t *testing.T) { } } +func TestQueryCreatedRangeUsesStrictContract(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func( + writer http.ResponseWriter, + request *http.Request, + ) { + var body map[string]string + if err := json.NewDecoder(request.Body).Decode(&body); err != nil { + t.Fatalf("decode request: %v", err) + } + if body["mode"] != "CREATED_RANGE" || + body["created_from"] != "2026-07-22" || + body["created_to"] != "2026-07-28" || + len(body) != 3 { + t.Fatalf("request body = %#v", body) + } + writer.Header().Set("Content-Type", "application/json") + _, _ = writer.Write([]byte(`{ + "schema_version":1, + "query":{ + "mode":"CREATED_RANGE", + "created_from":"2026-07-22", + "created_to":"2026-07-28" + }, + "orders":[] + }`)) + })) + defer server.Close() + client, _ := New( + server.URL, + "12345678901234567890123456789012", + time.Second, + ) + + result, err := client.QueryCreatedRange( + context.Background(), + "2026-07-22", + "2026-07-28", + ) + + if err != nil || result.Query.CreatedFrom == nil || + *result.Query.CreatedFrom != "2026-07-22" { + t.Fatalf("QueryCreatedRange() = %+v, %v", result, err) + } +} + +func TestQueryCreatedRangeRejectsMismatchedResponseWindow(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func( + writer http.ResponseWriter, + _ *http.Request, + ) { + writer.Header().Set("Content-Type", "application/json") + _, _ = writer.Write([]byte(`{ + "schema_version":1, + "query":{ + "mode":"CREATED_RANGE", + "created_from":"2026-07-21", + "created_to":"2026-07-28" + }, + "orders":[] + }`)) + })) + defer server.Close() + client, _ := New( + server.URL, + "12345678901234567890123456789012", + time.Second, + ) + + _, err := client.QueryCreatedRange( + context.Background(), + "2026-07-22", + "2026-07-28", + ) + + if !errors.Is(err, ErrProtocol) { + t.Fatalf("QueryCreatedRange() error = %v", err) + } +} + func TestQueryOrderRejectsUnexpectedPIIField(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func( writer http.ResponseWriter, diff --git a/backend-api/internal/platform/migration/claims_migration_test.go b/backend-api/internal/platform/migration/claims_migration_test.go index 37a996a..5cb8703 100644 --- a/backend-api/internal/platform/migration/claims_migration_test.go +++ b/backend-api/internal/platform/migration/claims_migration_test.go @@ -34,8 +34,11 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) { if applied, err := runner.Up(ctx); err != nil { t.Fatalf("initial Up() error = %v", err) - } else if applied != 13 { - t.Fatalf("initial Up() applied = %d, want 13", applied) + } else if applied != 14 { + t.Fatalf("initial Up() applied = %d, want 14", applied) + } + if err := runner.Down(ctx); err != nil { + t.Fatalf("initial Down(v14) error = %v", err) } if err := runner.Down(ctx); err != nil { t.Fatalf("initial Down(v13) error = %v", err) @@ -68,9 +71,14 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) { seedClaimsHistoricalFixture(t, db) if applied, err := runner.Up(ctx); err != nil { - t.Fatalf("Up(v5-v13) over historical data error = %v", err) - } else if applied != 9 { - t.Fatalf("Up(v5-v13) applied = %d, want 9", applied) + t.Fatalf("Up(v5-v14) over historical data error = %v", err) + } else if applied != 10 { + t.Fatalf("Up(v5-v14) applied = %d, want 10", applied) + } + assertClaimsHistory(t, db, true) + + if err := runner.Down(ctx); err != nil { + t.Fatalf("Down(v14) with compatible history error = %v", err) } assertClaimsHistory(t, db, true) @@ -125,9 +133,9 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) { assertClaimsHistory(t, db, false) if applied, err := runner.Up(ctx); err != nil { - t.Fatalf("final Up(v4-v13) error = %v", err) - } else if applied != 10 { - t.Fatalf("final Up(v4-v13) applied = %d, want 10", applied) + t.Fatalf("final Up(v4-v14) error = %v", err) + } else if applied != 11 { + t.Fatalf("final Up(v4-v14) applied = %d, want 11", applied) } assertClaimsHistory(t, db, true) } @@ -363,6 +371,9 @@ func TestClaimsMigrationDownFailsClosedForNewAuditData(t *testing.T) { t.Fatalf("insert v4 audit event: %v", err) } + if err := runner.Down(ctx); err != nil { + t.Fatalf("Down(v14) error = %v", err) + } if err := runner.Down(ctx); err != nil { t.Fatalf("Down(v13) error = %v", err) } diff --git a/backend-api/internal/platform/migration/runner_test.go b/backend-api/internal/platform/migration/runner_test.go index 4ad63b8..91f4d90 100644 --- a/backend-api/internal/platform/migration/runner_test.go +++ b/backend-api/internal/platform/migration/runner_test.go @@ -27,8 +27,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) { if err != nil { t.Fatalf("Up() error = %v", err) } - if applied != 13 { - t.Fatalf("Up() applied = %d, want 13", applied) + if applied != 14 { + t.Fatalf("Up() applied = %d, want 14", applied) } assertStatuses(t, runner, map[int64]bool{ 1: true, @@ -44,6 +44,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) { 11: true, 12: true, 13: true, + 14: true, }) applied, err = runner.Up(context.Background()) @@ -70,7 +71,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) { 10: true, 11: true, 12: true, - 13: false, + 13: true, + 14: false, }) applied, err = runner.Up(context.Background()) @@ -94,6 +96,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) { 11: true, 12: true, 13: true, + 14: true, }) } diff --git a/backend-api/internal/repository/sqlite/auth_repository_test.go b/backend-api/internal/repository/sqlite/auth_repository_test.go index 9830d0f..8354026 100644 --- a/backend-api/internal/repository/sqlite/auth_repository_test.go +++ b/backend-api/internal/repository/sqlite/auth_repository_test.go @@ -383,6 +383,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks( if err != nil { t.Fatalf("migration.New() error = %v", err) } + if err := runner.Down(context.Background()); err != nil { + t.Fatalf("Down(v14) error = %v", err) + } if err := runner.Down(context.Background()); err != nil { t.Fatalf("Down(v13) error = %v", err) } @@ -429,9 +432,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks( t.Fatal("purchase_tasks was lost during auth migration rollback") } if applied, err := runner.Up(context.Background()); err != nil { - t.Fatalf("Up(v3-v13) error = %v", err) - } else if applied != 11 { - t.Fatalf("Up(v3-v13) applied = %d, want 11", applied) + t.Fatalf("Up(v3-v14) error = %v", err) + } else if applied != 12 { + t.Fatalf("Up(v3-v14) applied = %d, want 12", applied) } } diff --git a/backend-api/internal/repository/sqlite/freight_repository.go b/backend-api/internal/repository/sqlite/freight_repository.go index 089b9ec..2e5cf94 100644 --- a/backend-api/internal/repository/sqlite/freight_repository.go +++ b/backend-api/internal/repository/sqlite/freight_repository.go @@ -52,14 +52,18 @@ func (store *Store) CreateFreightSync( ctx, `INSERT INTO erp_sync_runs ( id, creator_subject, created_by_user_id, mode, order_number, - query_sha256, idempotency_key, request_sha256, status, - order_count, item_count, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?)`, + created_from, created_to, watermark_through, query_sha256, + idempotency_key, request_sha256, status, order_count, item_count, + created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?)`, run.ID, run.CreatorSubject, run.CreatedByUserID, run.Mode, - run.OrderNumber, + nullableFreightSyncValue(run.OrderNumber), + nullableFreightSyncValue(run.CreatedFrom), + nullableFreightSyncValue(run.CreatedTo), + nullableTimestamp(run.WatermarkThrough), run.QuerySHA256, idempotencyKey, requestSHA256, @@ -106,6 +110,32 @@ func (store *Store) CompleteFreightSync( run domain.FreightSyncRun, batch domain.FreightImportBatch, finishedAt time.Time, +) error { + return store.completeFreightSync(ctx, run, batch, nil, finishedAt) +} + +func (store *Store) CompleteFreightDateSync( + ctx context.Context, + run domain.FreightSyncRun, + batch domain.FreightImportBatch, + watermarkThrough time.Time, + finishedAt time.Time, +) error { + return store.completeFreightSync( + ctx, + run, + batch, + &watermarkThrough, + finishedAt, + ) +} + +func (store *Store) completeFreightSync( + ctx context.Context, + run domain.FreightSyncRun, + batch domain.FreightImportBatch, + watermarkThrough *time.Time, + finishedAt time.Time, ) error { tx, err := store.db.BeginTx(ctx, nil) if err != nil { @@ -184,6 +214,28 @@ func (store *Store) CompleteFreightSync( if changed != 1 { return usecase.ErrTaskStateConflict } + if watermarkThrough != nil { + if _, err := tx.ExecContext( + ctx, + `INSERT INTO erp_sync_watermarks ( + creator_subject, source_system, last_successful_to, + last_successful_run_id, updated_at + ) VALUES (?, 'SHUNYUNBAO', ?, ?, ?) + ON CONFLICT (creator_subject, source_system) + DO UPDATE SET + last_successful_to = excluded.last_successful_to, + last_successful_run_id = excluded.last_successful_run_id, + updated_at = excluded.updated_at + WHERE julianday(erp_sync_watermarks.last_successful_to) + < julianday(excluded.last_successful_to)`, + run.CreatorSubject, + formatTimestamp(*watermarkThrough), + run.ID, + formatTimestamp(finishedAt), + ); err != nil { + return repositoryFailure(err) + } + } if err := tx.Commit(); err != nil { return repositoryFailure(err) } @@ -377,6 +429,43 @@ func (store *Store) GetFreightSync( return run, nil } +func (store *Store) GetFreightSyncWatermark( + ctx context.Context, + creatorSubject string, +) (*domain.FreightSyncWatermark, error) { + var watermark domain.FreightSyncWatermark + var lastSuccessfulTo, updatedAt string + err := store.db.QueryRowContext( + ctx, + `SELECT creator_subject, source_system, last_successful_to, + last_successful_run_id, updated_at + FROM erp_sync_watermarks + WHERE creator_subject = ? AND source_system = 'SHUNYUNBAO'`, + creatorSubject, + ).Scan( + &watermark.CreatorSubject, + &watermark.SourceSystem, + &lastSuccessfulTo, + &watermark.LastSuccessfulRunID, + &updatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, repositoryFailure(err) + } + watermark.LastSuccessfulTo, err = parseTimestamp(lastSuccessfulTo) + if err != nil { + return nil, repositoryFailure(err) + } + watermark.UpdatedAt, err = parseTimestamp(updatedAt) + if err != nil { + return nil, repositoryFailure(err) + } + return &watermark, nil +} + func (store *Store) ListFreightOrders( ctx context.Context, creatorSubject string, @@ -458,13 +547,15 @@ func (store *Store) GetFreightOrder( const freightSyncSelect = `SELECT id, creator_subject, created_by_user_id, mode, order_number, - query_sha256, status, error_code, order_count, item_count, - created_at, started_at, finished_at + created_from, created_to, watermark_through, query_sha256, status, + error_code, order_count, item_count, created_at, started_at, finished_at FROM erp_sync_runs ` func scanFreightSync(scanner rowScanner) (domain.FreightSyncRun, error) { var run domain.FreightSyncRun + var orderNumber, createdFrom, createdTo sql.NullString + var watermarkThrough sql.NullString var errorCode sql.NullString var createdAt string var startedAt sql.NullString @@ -474,7 +565,10 @@ func scanFreightSync(scanner rowScanner) (domain.FreightSyncRun, error) { &run.CreatorSubject, &run.CreatedByUserID, &run.Mode, - &run.OrderNumber, + &orderNumber, + &createdFrom, + &createdTo, + &watermarkThrough, &run.QuerySHA256, &run.Status, &errorCode, @@ -490,6 +584,19 @@ func scanFreightSync(scanner rowScanner) (domain.FreightSyncRun, error) { if errorCode.Valid { run.ErrorCode = &errorCode.String } + if orderNumber.Valid { + run.OrderNumber = orderNumber.String + } + if createdFrom.Valid { + run.CreatedFrom = createdFrom.String + } + if createdTo.Valid { + run.CreatedTo = createdTo.String + } + run.WatermarkThrough, err = parseNullableTimestamp(watermarkThrough) + if err != nil { + return domain.FreightSyncRun{}, err + } run.CreatedAt, err = parseTimestamp(createdAt) if err != nil { return domain.FreightSyncRun{}, err @@ -622,6 +729,13 @@ func nullableFreightQuantity(value *int) any { return *value } +func nullableFreightSyncValue(value string) any { + if value == "" { + return nil + } + return value +} + func optionalString(value sql.NullString) *string { if !value.Valid { return nil diff --git a/backend-api/internal/repository/sqlite/freight_repository_test.go b/backend-api/internal/repository/sqlite/freight_repository_test.go index 84d4a15..9eec2c5 100644 --- a/backend-api/internal/repository/sqlite/freight_repository_test.go +++ b/backend-api/internal/repository/sqlite/freight_repository_test.go @@ -117,6 +117,9 @@ func TestFreightImportIsAtomicIdempotentAndRevisioned(t *testing.T) { if err != nil { t.Fatalf("migration.New() error = %v", err) } + if err := runner.Down(ctx); err != nil { + t.Fatalf("date sync migration down: %v", err) + } if err := runner.Down(ctx); err != nil { t.Fatalf("procurement migration down: %v", err) } @@ -163,6 +166,106 @@ func TestFreightSyncRecoveryAndFailedBatchDoNotPersistOrders(t *testing.T) { } } +func TestFreightDateSyncAdvancesWatermarkOnlyOnWholeBatchSuccess( + t *testing.T, +) { + db := openDatabase(t) + store, _ := repository.New(db) + ctx := context.Background() + now := time.Date(2026, 7, 28, 8, 0, 0, 0, time.UTC) + userID := uuid(960) + seedFreightUser(t, db, userID, now) + + firstThrough := now.Add(30 * time.Minute) + first := freightDateRun( + 961, + userID, + now, + "2026-07-27", + "2026-07-28", + firstThrough, + ) + createAndStartFreightRun(t, store, first, "date-success", "6") + if err := store.CompleteFreightDateSync( + ctx, + first, + domain.FreightImportBatch{}, + firstThrough, + now.Add(time.Minute), + ); err != nil { + t.Fatalf("CompleteFreightDateSync() error = %v", err) + } + watermark, err := store.GetFreightSyncWatermark(ctx, "local-admin") + if err != nil || watermark == nil || + !watermark.LastSuccessfulTo.Equal(firstThrough) || + watermark.LastSuccessfulRunID != first.ID { + t.Fatalf("first watermark = %+v, %v", watermark, err) + } + + failed := freightDateRun( + 962, + userID, + now.Add(time.Hour), + "2026-07-28", + "2026-07-28", + now.Add(2*time.Hour), + ) + createAndStartFreightRun(t, store, failed, "date-failed", "7") + if err := store.FailFreightSync( + ctx, + failed.ID, + "ERP_CONNECTOR_UNAVAILABLE", + now.Add(time.Hour+time.Minute), + ); err != nil { + t.Fatalf("FailFreightSync() error = %v", err) + } + watermark, _ = store.GetFreightSyncWatermark(ctx, "local-admin") + if !watermark.LastSuccessfulTo.Equal(firstThrough) || + watermark.LastSuccessfulRunID != first.ID { + t.Fatalf("failed run advanced watermark = %+v", watermark) + } + + olderThrough := now.Add(-time.Hour) + older := freightDateRun( + 963, + userID, + now.Add(2*time.Hour), + "2026-07-26", + "2026-07-26", + olderThrough, + ) + createAndStartFreightRun(t, store, older, "date-older", "8") + if err := store.CompleteFreightDateSync( + ctx, + older, + domain.FreightImportBatch{}, + olderThrough, + now.Add(2*time.Hour+time.Minute), + ); err != nil { + t.Fatalf("older CompleteFreightDateSync() error = %v", err) + } + watermark, _ = store.GetFreightSyncWatermark(ctx, "local-admin") + if !watermark.LastSuccessfulTo.Equal(firstThrough) || + watermark.LastSuccessfulRunID != first.ID { + t.Fatalf("older run retreated watermark = %+v", watermark) + } + + runner, _ := migration.New(db) + if err := runner.Down(ctx); err == nil { + t.Fatal("date sync migration down succeeded with retained watermark") + } + var foreignKeysEnabled int + if err := db.QueryRow(`PRAGMA foreign_keys`).Scan( + &foreignKeysEnabled, + ); err != nil || foreignKeysEnabled != 1 { + t.Fatalf( + "failed down disabled foreign keys = %d, %v", + foreignKeysEnabled, + err, + ) + } +} + func seedFreightUser( t *testing.T, db *sql.DB, @@ -200,6 +303,27 @@ func freightRun( } } +func freightDateRun( + index int, + userID string, + now time.Time, + createdFrom, createdTo string, + watermarkThrough time.Time, +) domain.FreightSyncRun { + return domain.FreightSyncRun{ + ID: uuid(index), + CreatorSubject: "local-admin", + CreatedByUserID: userID, + Mode: domain.FreightSyncCreatedRange, + CreatedFrom: createdFrom, + CreatedTo: createdTo, + WatermarkThrough: &watermarkThrough, + QuerySHA256: repeatHex("b"), + Status: domain.FreightSyncPending, + CreatedAt: now, + } +} + func freightBatch(index int, firstHash, secondHash string) domain.FreightImportBatch { quantityOne := 1 quantityTwo := 2 diff --git a/backend-api/internal/repository/sqlite/procurement_repository_test.go b/backend-api/internal/repository/sqlite/procurement_repository_test.go index df7bb99..6203a69 100644 --- a/backend-api/internal/repository/sqlite/procurement_repository_test.go +++ b/backend-api/internal/repository/sqlite/procurement_repository_test.go @@ -238,6 +238,9 @@ func TestProcurementRequestsArePerItemAndTaskSnapshotIsImmutable( if err != nil { t.Fatalf("migration.New() error = %v", err) } + if err := runner.Down(ctx); err != nil { + t.Fatalf("date sync migration down: %v", err) + } if err := runner.Down(ctx); err == nil { t.Fatal("procurement migration down succeeded with retained requests") } diff --git a/backend-api/internal/transport/httpapi/admin_handlers.go b/backend-api/internal/transport/httpapi/admin_handlers.go index 1a3d00c..68c2514 100644 --- a/backend-api/internal/transport/httpapi/admin_handlers.go +++ b/backend-api/internal/transport/httpapi/admin_handlers.go @@ -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) } diff --git a/backend-api/internal/transport/httpapi/admin_handlers_test.go b/backend-api/internal/transport/httpapi/admin_handlers_test.go index 723aa22..68640a1 100644 --- a/backend-api/internal/transport/httpapi/admin_handlers_test.go +++ b/backend-api/internal/transport/httpapi/admin_handlers_test.go @@ -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, diff --git a/backend-api/internal/transport/httpapi/device_handlers_test.go b/backend-api/internal/transport/httpapi/device_handlers_test.go index a26d336..b06f106 100644 --- a/backend-api/internal/transport/httpapi/device_handlers_test.go +++ b/backend-api/internal/transport/httpapi/device_handlers_test.go @@ -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) } diff --git a/backend-api/internal/transport/httpapi/freight_handlers.go b/backend-api/internal/transport/httpapi/freight_handlers.go index 93972ef..584be19 100644 --- a/backend-api/internal/transport/httpapi/freight_handlers.go +++ b/backend-api/internal/transport/httpapi/freight_handlers.go @@ -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, diff --git a/backend-api/internal/transport/webui/handler.go b/backend-api/internal/transport/webui/handler.go index f38e2a4..59ce058 100644 --- a/backend-api/internal/transport/webui/handler.go +++ b/backend-api/internal/transport/webui/handler.go @@ -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 { diff --git a/backend-api/internal/transport/webui/handler_test.go b/backend-api/internal/transport/webui/handler_test.go index 90957f1..344c7bb 100644 --- a/backend-api/internal/transport/webui/handler_test.go +++ b/backend-api/internal/transport/webui/handler_test.go @@ -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, diff --git a/backend-api/internal/transport/webui/static/admin.css b/backend-api/internal/transport/webui/static/admin.css index 4ec1d8b..4ed46a7 100644 --- a/backend-api/internal/transport/webui/static/admin.css +++ b/backend-api/internal/transport/webui/static/admin.css @@ -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; } diff --git a/backend-api/internal/transport/webui/templates/freight-import.gohtml b/backend-api/internal/transport/webui/templates/freight-import.gohtml index b25485e..3ae2bff 100644 --- a/backend-api/internal/transport/webui/templates/freight-import.gohtml +++ b/backend-api/internal/transport/webui/templates/freight-import.gohtml @@ -11,7 +11,7 @@
使用 ERP 页面“全部单号”中的完整单号
+按完整单号导入,或按创建日期发现新增货运