feat(t224): add incremental freight sync

This commit is contained in:
QiuSW
2026-07-29 00:26:05 +08:00
parent 433db45100
commit c69879650e
35 changed files with 2085 additions and 156 deletions
+28 -14
View File
@@ -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 {
@@ -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
}
@@ -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,
@@ -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)
}
@@ -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,
})
}
@@ -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)
}
}
@@ -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
@@ -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
@@ -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")
}
@@ -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>
+22 -7
View File
@@ -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
@@ -9,6 +9,11 @@ import (
type FreightSource interface {
QueryOrder(context.Context, string) (domain.FreightSourceBatch, error)
QueryCreatedRange(
context.Context,
string,
string,
) (domain.FreightSourceBatch, error)
}
type FreightRepository interface {
@@ -25,6 +30,13 @@ type FreightRepository interface {
domain.FreightImportBatch,
time.Time,
) error
CompleteFreightDateSync(
context.Context,
domain.FreightSyncRun,
domain.FreightImportBatch,
time.Time,
time.Time,
) error
FailFreightSync(context.Context, string, string, time.Time) error
RecoverFreightSyncs(context.Context, time.Time) (int64, error)
GetFreightSync(
@@ -32,6 +44,10 @@ type FreightRepository interface {
string,
string,
) (domain.FreightSyncRun, error)
GetFreightSyncWatermark(
context.Context,
string,
) (*domain.FreightSyncWatermark, error)
ListFreightOrders(
context.Context,
string,
+305 -8
View File
@@ -19,6 +19,8 @@ import (
const (
maxFreightOrdersPerSync = 100
maxFreightItemsPerOrder = 1000
maxFreightWindowDays = 7
freightWatermarkOverlap = 10 * time.Minute
)
type FreightService struct {
@@ -36,6 +38,15 @@ type CreateFreightSyncCommand struct {
OrderNumber string
}
type CreateFreightDateSyncCommand struct {
CreatorSubject string
ActorUserID string
IdempotencyKey string
CreatedFrom string
CreatedTo string
SyncToNow bool
}
type CreateFreightSyncResult struct {
Run domain.FreightSyncRun
Replayed bool
@@ -127,6 +138,147 @@ func (service *FreightService) CreateOrderSync(
return CreateFreightSyncResult{Run: run, Replayed: !created}, nil
}
func (service *FreightService) CreateDateSync(
ctx context.Context,
command CreateFreightDateSyncCommand,
) (CreateFreightSyncResult, error) {
command.CreatorSubject = strings.TrimSpace(command.CreatorSubject)
command.ActorUserID = strings.TrimSpace(command.ActorUserID)
command.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey)
command.CreatedFrom = strings.TrimSpace(command.CreatedFrom)
command.CreatedTo = strings.TrimSpace(command.CreatedTo)
fields := validateFreightActor(
command.CreatorSubject,
command.ActorUserID,
command.IdempotencyKey,
)
if command.SyncToNow &&
(command.CreatedFrom != "" || command.CreatedTo != "") {
fields["sync_to_now"] = "cannot be combined with a manual range"
}
if !command.SyncToNow &&
(command.CreatedFrom == "" || command.CreatedTo == "") {
fields["created_range"] = "created_from and created_to are required"
}
if len(fields) > 0 {
return CreateFreightSyncResult{}, invalidError(
"FREIGHT_SYNC_INVALID",
"freight sync request is invalid",
fields,
)
}
now := service.clock.Now().UTC()
location, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
return CreateFreightSyncResult{}, wrapRepositoryError(err)
}
var startDate, endDate time.Time
var watermarkThrough time.Time
if command.SyncToNow {
watermark, err := service.repository.GetFreightSyncWatermark(
ctx,
command.CreatorSubject,
)
if err != nil {
return CreateFreightSyncResult{}, wrapRepositoryError(err)
}
localNow := now.In(location)
endDate = startOfLocalDay(localNow, location)
startDate = endDate
if watermark != nil {
overlap := watermark.LastSuccessfulTo.Add(
-freightWatermarkOverlap,
).In(location)
startDate = startOfLocalDay(overlap, location)
if startDate.After(endDate) {
startDate = endDate
}
}
watermarkThrough = now
} else {
startDate, err = parseFreightDate(command.CreatedFrom, location)
if err != nil {
fields["created_from"] = "must be YYYY-MM-DD"
}
endDate, err = parseFreightDate(command.CreatedTo, location)
if err != nil {
fields["created_to"] = "must be YYYY-MM-DD"
}
if len(fields) == 0 {
if endDate.Before(startDate) {
fields["created_to"] = "must not be before created_from"
} else if daysInclusive(startDate, endDate) >
maxFreightWindowDays {
fields["created_range"] = "must not exceed 7 inclusive days"
}
if endDate.After(startOfLocalDay(now.In(location), location)) {
fields["created_to"] = "must not be in the future"
}
}
if len(fields) > 0 {
return CreateFreightSyncResult{}, invalidError(
"FREIGHT_SYNC_INVALID",
"freight sync request is invalid",
fields,
)
}
watermarkThrough = endDate.AddDate(0, 0, 1).Add(-time.Nanosecond).UTC()
if watermarkThrough.After(now) {
watermarkThrough = now
}
}
createdFrom := startDate.Format(time.DateOnly)
createdTo := endDate.Format(time.DateOnly)
runID, err := service.ids.NewID()
if err != nil {
return CreateFreightSyncResult{}, wrapRepositoryError(err)
}
queryHash := hashJSON(struct {
Mode string `json:"mode"`
CreatedFrom string `json:"created_from"`
CreatedTo string `json:"created_to"`
}{
domain.FreightSyncCreatedRange,
createdFrom,
createdTo,
})
requestHash := hashJSON(struct {
SyncToNow bool `json:"sync_to_now"`
CreatedFrom string `json:"created_from"`
CreatedTo string `json:"created_to"`
}{
command.SyncToNow,
command.CreatedFrom,
command.CreatedTo,
})
run, created, err := service.repository.CreateFreightSync(
ctx,
domain.FreightSyncRun{
ID: runID,
CreatorSubject: command.CreatorSubject,
CreatedByUserID: command.ActorUserID,
Mode: domain.FreightSyncCreatedRange,
CreatedFrom: createdFrom,
CreatedTo: createdTo,
WatermarkThrough: &watermarkThrough,
QuerySHA256: queryHash,
Status: domain.FreightSyncPending,
CreatedAt: now,
},
command.IdempotencyKey,
requestHash,
)
if err != nil {
return CreateFreightSyncResult{}, wrapRepositoryError(err)
}
if created {
go service.execute(run)
}
return CreateFreightSyncResult{Run: run, Replayed: !created}, nil
}
func (service *FreightService) execute(run domain.FreightSyncRun) {
ctx, cancel := context.WithTimeout(context.Background(), service.timeout)
defer cancel()
@@ -134,7 +286,13 @@ func (service *FreightService) execute(run domain.FreightSyncRun) {
if err := service.repository.StartFreightSync(ctx, run.ID, now); err != nil {
return
}
source, err := service.source.QueryOrder(ctx, run.OrderNumber)
var source domain.FreightSourceBatch
var err error
if run.Mode == domain.FreightSyncCreatedRange {
source, err = service.queryCreatedRange(ctx, run)
} else {
source, err = service.source.QueryOrder(ctx, run.OrderNumber)
}
if err != nil {
_ = service.repository.FailFreightSync(
ctx,
@@ -154,12 +312,25 @@ func (service *FreightService) execute(run domain.FreightSyncRun) {
)
return
}
if err := service.repository.CompleteFreightSync(
ctx,
run,
batch,
service.clock.Now().UTC(),
); err != nil {
finishedAt := service.clock.Now().UTC()
if run.Mode == domain.FreightSyncCreatedRange &&
run.WatermarkThrough != nil {
err = service.repository.CompleteFreightDateSync(
ctx,
run,
batch,
*run.WatermarkThrough,
finishedAt,
)
} else {
err = service.repository.CompleteFreightSync(
ctx,
run,
batch,
finishedAt,
)
}
if err != nil {
_ = service.repository.FailFreightSync(
ctx,
run.ID,
@@ -169,6 +340,76 @@ func (service *FreightService) execute(run domain.FreightSyncRun) {
}
}
func (service *FreightService) queryCreatedRange(
ctx context.Context,
run domain.FreightSyncRun,
) (domain.FreightSourceBatch, error) {
location, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
return domain.FreightSourceBatch{}, err
}
start, err := parseFreightDate(run.CreatedFrom, location)
if err != nil {
return domain.FreightSourceBatch{}, erpconnector.ErrProtocol
}
end, err := parseFreightDate(run.CreatedTo, location)
if err != nil || end.Before(start) {
return domain.FreightSourceBatch{}, erpconnector.ErrProtocol
}
orders := make([]domain.FreightSourceOrder, 0)
seen := make(map[string]domain.FreightSourceOrder)
for windowStart := start; !windowStart.After(end); {
windowEnd := windowStart.AddDate(0, 0, maxFreightWindowDays-1)
if windowEnd.After(end) {
windowEnd = end
}
fromValue := windowStart.Format(time.DateOnly)
toValue := windowEnd.Format(time.DateOnly)
batch, err := service.source.QueryCreatedRange(
ctx,
fromValue,
toValue,
)
if err != nil {
return domain.FreightSourceBatch{}, err
}
if batch.SchemaVersion != 1 ||
batch.Query.Mode != domain.FreightSyncCreatedRange ||
batch.Query.CreatedFrom == nil ||
batch.Query.CreatedTo == nil ||
*batch.Query.CreatedFrom != fromValue ||
*batch.Query.CreatedTo != toValue {
return domain.FreightSourceBatch{}, erpconnector.ErrProtocol
}
for _, order := range batch.Orders {
existing, exists := seen[order.ExternalStockID]
if exists {
if hashJSON(existing) != hashJSON(order) {
return domain.FreightSourceBatch{}, erpconnector.ErrProtocol
}
continue
}
seen[order.ExternalStockID] = order
orders = append(orders, order)
if len(orders) > maxFreightOrdersPerSync {
return domain.FreightSourceBatch{}, erpconnector.ErrProtocol
}
}
windowStart = windowEnd.AddDate(0, 0, 1)
}
createdFrom := run.CreatedFrom
createdTo := run.CreatedTo
return domain.FreightSourceBatch{
SchemaVersion: 1,
Query: domain.FreightSourceQuery{
Mode: domain.FreightSyncCreatedRange,
CreatedFrom: &createdFrom,
CreatedTo: &createdTo,
},
Orders: orders,
}, nil
}
func (service *FreightService) RecoverInterrupted(
ctx context.Context,
) (int64, error) {
@@ -197,6 +438,20 @@ func (service *FreightService) GetSync(
return run, nil
}
func (service *FreightService) GetWatermark(
ctx context.Context,
creatorSubject string,
) (*domain.FreightSyncWatermark, error) {
watermark, err := service.repository.GetFreightSyncWatermark(
ctx,
strings.TrimSpace(creatorSubject),
)
if err != nil {
return nil, wrapRepositoryError(err)
}
return watermark, nil
}
func (service *FreightService) ListOrders(
ctx context.Context,
creatorSubject string,
@@ -242,7 +497,8 @@ func (service *FreightService) normalize(
source domain.FreightSourceBatch,
) (domain.FreightImportBatch, error) {
if source.SchemaVersion != 1 ||
source.Query.Mode != domain.FreightSyncOrderNumber ||
(source.Query.Mode != domain.FreightSyncOrderNumber &&
source.Query.Mode != domain.FreightSyncCreatedRange) ||
len(source.Orders) > maxFreightOrdersPerSync {
return domain.FreightImportBatch{}, errors.New("invalid source envelope")
}
@@ -373,6 +629,47 @@ func (service *FreightService) normalize(
return result, nil
}
func validateFreightActor(
creatorSubject, actorUserID, idempotencyKey string,
) map[string]string {
fields := map[string]string{}
if creatorSubject == "" {
fields["creator_subject"] = "is required"
}
if actorUserID == "" {
fields["actor_user_id"] = "is required"
}
if idempotencyKey == "" || len([]byte(idempotencyKey)) > 128 {
fields["idempotency_key"] = "must contain 1 to 128 bytes"
}
return fields
}
func parseFreightDate(value string, location *time.Location) (time.Time, error) {
parsed, err := time.ParseInLocation(time.DateOnly, value, location)
if err != nil || parsed.Format(time.DateOnly) != value {
return time.Time{}, errors.New("invalid freight date")
}
return parsed, nil
}
func startOfLocalDay(value time.Time, location *time.Location) time.Time {
return time.Date(
value.Year(),
value.Month(),
value.Day(),
0,
0,
0,
0,
location,
)
}
func daysInclusive(start, end time.Time) int {
return int(end.Sub(start)/(24*time.Hour)) + 1
}
func freightSourceErrorCode(err error) string {
switch {
case errors.Is(err, erpconnector.ErrNotConfigured):
@@ -1,7 +1,10 @@
package usecase
import (
"context"
"errors"
"testing"
"time"
"cmroubao/backend-api/internal/domain"
)
@@ -52,6 +55,125 @@ func TestFreightNormalizationRejectsConflictingIdentityAndInvalidTime(
}
}
func TestFreightDateQuerySplitsIntoSevenDayWindows(t *testing.T) {
source := &recordingDateSource{}
service := &FreightService{source: source}
result, err := service.queryCreatedRange(
context.Background(),
domain.FreightSyncRun{
Mode: domain.FreightSyncCreatedRange,
CreatedFrom: "2026-07-01",
CreatedTo: "2026-07-15",
},
)
if err != nil {
t.Fatalf("queryCreatedRange() error = %v", err)
}
want := [][2]string{
{"2026-07-01", "2026-07-07"},
{"2026-07-08", "2026-07-14"},
{"2026-07-15", "2026-07-15"},
}
if len(source.calls) != len(want) {
t.Fatalf("calls = %#v", source.calls)
}
for index := range want {
if source.calls[index] != want[index] {
t.Fatalf("call %d = %#v, want %#v", index, source.calls[index], want[index])
}
}
if result.Query.CreatedFrom == nil ||
*result.Query.CreatedFrom != "2026-07-01" ||
result.Query.CreatedTo == nil ||
*result.Query.CreatedTo != "2026-07-15" {
t.Fatalf("merged query = %+v", result.Query)
}
}
func TestFreightDateQueryStopsOnMiddleWindowFailure(t *testing.T) {
source := &recordingDateSource{failOnCall: 2}
service := &FreightService{source: source}
_, err := service.queryCreatedRange(
context.Background(),
domain.FreightSyncRun{
Mode: domain.FreightSyncCreatedRange,
CreatedFrom: "2026-07-01",
CreatedTo: "2026-07-15",
},
)
if !errors.Is(err, errDateSourceFailure) || len(source.calls) != 2 {
t.Fatalf("queryCreatedRange() error/calls = %v / %#v", err, source.calls)
}
}
func TestCreateFreightSyncToNowUsesWatermarkOverlapDay(t *testing.T) {
watermarkTime := time.Date(2026, 7, 25, 16, 5, 0, 0, time.UTC)
repository := &dateCaptureRepository{
watermark: &domain.FreightSyncWatermark{
CreatorSubject: "local-admin",
SourceSystem: domain.FreightSourceShunyunbao,
LastSuccessfulTo: watermarkTime,
},
}
service, err := NewFreightService(
repository,
&recordingDateSource{},
fakeClock{},
&sequenceIDs{},
time.Minute,
)
if err != nil {
t.Fatalf("NewFreightService() error = %v", err)
}
result, err := service.CreateDateSync(
context.Background(),
CreateFreightDateSyncCommand{
CreatorSubject: "local-admin",
ActorUserID: "00000000-0000-4000-8000-000000000099",
IdempotencyKey: "sync-to-now",
SyncToNow: true,
},
)
if err != nil {
t.Fatalf("CreateDateSync() error = %v", err)
}
if result.Run.CreatedFrom != "2026-07-25" ||
result.Run.CreatedTo != "2026-07-26" ||
result.Run.WatermarkThrough == nil ||
!result.Run.WatermarkThrough.Equal(fakeClock{}.Now()) {
t.Fatalf("sync-to-now run = %+v", result.Run)
}
}
func TestCreateFreightManualRangeRejectsMoreThanSevenDays(t *testing.T) {
service, _ := NewFreightService(
&dateCaptureRepository{},
&recordingDateSource{},
fakeClock{},
&sequenceIDs{},
time.Minute,
)
_, err := service.CreateDateSync(
context.Background(),
CreateFreightDateSyncCommand{
CreatorSubject: "local-admin",
ActorUserID: "00000000-0000-4000-8000-000000000099",
IdempotencyKey: "too-wide",
CreatedFrom: "2026-07-19",
CreatedTo: "2026-07-26",
},
)
assertUsecaseError(t, err, ErrorKindInvalid, "FREIGHT_SYNC_INVALID")
}
func validFreightSource() domain.FreightSourceBatch {
shop := "测试店铺"
sourceCreatedAt := "2026-07-28 08:00:00"
@@ -86,3 +208,122 @@ func validFreightSource() domain.FreightSourceBatch {
}},
}
}
var errDateSourceFailure = errors.New("date source failed")
type recordingDateSource struct {
calls [][2]string
failOnCall int
}
func (source *recordingDateSource) QueryOrder(
context.Context,
string,
) (domain.FreightSourceBatch, error) {
return validFreightSource(), nil
}
func (source *recordingDateSource) QueryCreatedRange(
_ context.Context,
createdFrom, createdTo string,
) (domain.FreightSourceBatch, error) {
source.calls = append(source.calls, [2]string{createdFrom, createdTo})
if source.failOnCall == len(source.calls) {
return domain.FreightSourceBatch{}, errDateSourceFailure
}
return domain.FreightSourceBatch{
SchemaVersion: 1,
Query: domain.FreightSourceQuery{
Mode: domain.FreightSyncCreatedRange,
CreatedFrom: &createdFrom,
CreatedTo: &createdTo,
},
Orders: []domain.FreightSourceOrder{},
}, nil
}
type dateCaptureRepository struct {
watermark *domain.FreightSyncWatermark
}
func (repository *dateCaptureRepository) CreateFreightSync(
_ context.Context,
run domain.FreightSyncRun,
_, _ string,
) (domain.FreightSyncRun, bool, error) {
return run, false, nil
}
func (*dateCaptureRepository) StartFreightSync(
context.Context,
string,
time.Time,
) error {
return nil
}
func (*dateCaptureRepository) CompleteFreightSync(
context.Context,
domain.FreightSyncRun,
domain.FreightImportBatch,
time.Time,
) error {
return nil
}
func (*dateCaptureRepository) CompleteFreightDateSync(
context.Context,
domain.FreightSyncRun,
domain.FreightImportBatch,
time.Time,
time.Time,
) error {
return nil
}
func (*dateCaptureRepository) FailFreightSync(
context.Context,
string,
string,
time.Time,
) error {
return nil
}
func (*dateCaptureRepository) RecoverFreightSyncs(
context.Context,
time.Time,
) (int64, error) {
return 0, nil
}
func (*dateCaptureRepository) GetFreightSync(
context.Context,
string,
string,
) (domain.FreightSyncRun, error) {
return domain.FreightSyncRun{}, nil
}
func (repository *dateCaptureRepository) GetFreightSyncWatermark(
context.Context,
string,
) (*domain.FreightSyncWatermark, error) {
return repository.watermark, nil
}
func (*dateCaptureRepository) ListFreightOrders(
context.Context,
string,
int,
) ([]domain.FreightOrder, error) {
return nil, nil
}
func (*dateCaptureRepository) GetFreightOrder(
context.Context,
string,
string,
) (domain.FreightOrderDetail, error) {
return domain.FreightOrderDetail{}, nil
}
@@ -0,0 +1,234 @@
-- +goose NO TRANSACTION
-- +goose Up
PRAGMA foreign_keys = OFF;
PRAGMA legacy_alter_table = ON;
BEGIN IMMEDIATE;
ALTER TABLE erp_sync_runs RENAME TO erp_sync_runs_v13;
CREATE TABLE erp_sync_runs (
id TEXT PRIMARY KEY NOT NULL CHECK (length(id) = 36),
creator_subject TEXT NOT NULL,
created_by_user_id TEXT NOT NULL
REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
mode TEXT NOT NULL CHECK (mode IN ('ORDER_NUMBER', 'CREATED_RANGE')),
order_number TEXT
CHECK (
order_number IS NULL
OR (
length(trim(order_number)) > 0
AND length(CAST(order_number AS BLOB)) <= 128
)
),
created_from TEXT
CHECK (
created_from IS NULL
OR (
length(created_from) = 10
AND date(created_from) = created_from
)
),
created_to TEXT
CHECK (
created_to IS NULL
OR (
length(created_to) = 10
AND date(created_to) = created_to
)
),
watermark_through TEXT,
query_sha256 TEXT NOT NULL
CHECK (
length(query_sha256) = 64
AND query_sha256 NOT GLOB '*[^0-9a-f]*'
),
idempotency_key TEXT NOT NULL
CHECK (
length(trim(idempotency_key)) > 0
AND length(CAST(idempotency_key AS BLOB)) <= 128
),
request_sha256 TEXT NOT NULL
CHECK (
length(request_sha256) = 64
AND request_sha256 NOT GLOB '*[^0-9a-f]*'
),
status TEXT NOT NULL
CHECK (status IN ('PENDING', 'RUNNING', 'SUCCEEDED', 'FAILED')),
error_code TEXT
CHECK (
error_code IS NULL
OR length(CAST(error_code AS BLOB)) <= 64
),
order_count INTEGER NOT NULL DEFAULT 0 CHECK (order_count >= 0),
item_count INTEGER NOT NULL DEFAULT 0 CHECK (item_count >= 0),
created_at TEXT NOT NULL,
started_at TEXT,
finished_at TEXT,
CHECK (
(
mode = 'ORDER_NUMBER'
AND order_number IS NOT NULL
AND created_from IS NULL
AND created_to IS NULL
AND watermark_through IS NULL
)
OR (
mode = 'CREATED_RANGE'
AND order_number IS NULL
AND created_from IS NOT NULL
AND created_to IS NOT NULL
AND created_from <= created_to
AND watermark_through IS NOT NULL
)
),
CHECK (
(status = 'PENDING'
AND started_at IS NULL
AND finished_at IS NULL
AND error_code IS NULL)
OR (status = 'RUNNING'
AND started_at IS NOT NULL
AND finished_at IS NULL
AND error_code IS NULL)
OR (status = 'SUCCEEDED'
AND started_at IS NOT NULL
AND finished_at IS NOT NULL
AND error_code IS NULL)
OR (status = 'FAILED'
AND finished_at IS NOT NULL
AND error_code IS NOT NULL)
),
UNIQUE (creator_subject, idempotency_key)
);
INSERT INTO erp_sync_runs (
id, creator_subject, created_by_user_id, mode, order_number,
created_from, created_to, watermark_through, query_sha256,
idempotency_key, request_sha256, status, error_code, order_count,
item_count, created_at, started_at, finished_at
)
SELECT
id, creator_subject, created_by_user_id, mode, order_number,
NULL, NULL, NULL, query_sha256, idempotency_key, request_sha256,
status, error_code, order_count, item_count, created_at, started_at,
finished_at
FROM erp_sync_runs_v13;
DROP TABLE erp_sync_runs_v13;
CREATE INDEX erp_sync_runs_creator_created_idx
ON erp_sync_runs (creator_subject, created_at DESC, id DESC);
CREATE TABLE erp_sync_watermarks (
creator_subject TEXT NOT NULL,
source_system TEXT NOT NULL CHECK (source_system = 'SHUNYUNBAO'),
last_successful_to TEXT NOT NULL,
last_successful_run_id TEXT NOT NULL
REFERENCES erp_sync_runs(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
updated_at TEXT NOT NULL,
PRIMARY KEY (creator_subject, source_system)
);
COMMIT;
PRAGMA legacy_alter_table = OFF;
PRAGMA foreign_keys = ON;
-- +goose Down
DROP TABLE IF EXISTS erp_date_sync_v14_down_guard;
CREATE TEMP TABLE erp_date_sync_v14_down_guard (
allowed INTEGER NOT NULL CHECK (allowed = 1)
);
INSERT INTO erp_date_sync_v14_down_guard (allowed)
SELECT CASE
WHEN EXISTS (
SELECT 1 FROM erp_sync_runs WHERE mode = 'CREATED_RANGE'
) OR EXISTS (SELECT 1 FROM erp_sync_watermarks)
THEN 0
ELSE 1
END;
DROP TABLE erp_date_sync_v14_down_guard;
PRAGMA foreign_keys = OFF;
PRAGMA legacy_alter_table = ON;
BEGIN IMMEDIATE;
DROP TABLE erp_sync_watermarks;
ALTER TABLE erp_sync_runs RENAME TO erp_sync_runs_v14;
CREATE TABLE erp_sync_runs (
id TEXT PRIMARY KEY NOT NULL CHECK (length(id) = 36),
creator_subject TEXT NOT NULL,
created_by_user_id TEXT NOT NULL
REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
mode TEXT NOT NULL CHECK (mode = 'ORDER_NUMBER'),
order_number TEXT NOT NULL
CHECK (
length(trim(order_number)) > 0
AND length(CAST(order_number AS BLOB)) <= 128
),
query_sha256 TEXT NOT NULL
CHECK (
length(query_sha256) = 64
AND query_sha256 NOT GLOB '*[^0-9a-f]*'
),
idempotency_key TEXT NOT NULL
CHECK (
length(trim(idempotency_key)) > 0
AND length(CAST(idempotency_key AS BLOB)) <= 128
),
request_sha256 TEXT NOT NULL
CHECK (
length(request_sha256) = 64
AND request_sha256 NOT GLOB '*[^0-9a-f]*'
),
status TEXT NOT NULL
CHECK (status IN ('PENDING', 'RUNNING', 'SUCCEEDED', 'FAILED')),
error_code TEXT
CHECK (
error_code IS NULL
OR length(CAST(error_code AS BLOB)) <= 64
),
order_count INTEGER NOT NULL DEFAULT 0 CHECK (order_count >= 0),
item_count INTEGER NOT NULL DEFAULT 0 CHECK (item_count >= 0),
created_at TEXT NOT NULL,
started_at TEXT,
finished_at TEXT,
CHECK (
(status = 'PENDING'
AND started_at IS NULL
AND finished_at IS NULL
AND error_code IS NULL)
OR (status = 'RUNNING'
AND started_at IS NOT NULL
AND finished_at IS NULL
AND error_code IS NULL)
OR (status = 'SUCCEEDED'
AND started_at IS NOT NULL
AND finished_at IS NOT NULL
AND error_code IS NULL)
OR (status = 'FAILED'
AND finished_at IS NOT NULL
AND error_code IS NOT NULL)
),
UNIQUE (creator_subject, idempotency_key)
);
INSERT INTO erp_sync_runs (
id, creator_subject, created_by_user_id, mode, order_number,
query_sha256, idempotency_key, request_sha256, status, error_code,
order_count, item_count, created_at, started_at, finished_at
)
SELECT
id, creator_subject, created_by_user_id, mode, order_number,
query_sha256, idempotency_key, request_sha256, status, error_code,
order_count, item_count, created_at, started_at, finished_at
FROM erp_sync_runs_v14;
DROP TABLE erp_sync_runs_v14;
CREATE INDEX erp_sync_runs_creator_created_idx
ON erp_sync_runs (creator_subject, created_at DESC, id DESC);
COMMIT;
PRAGMA legacy_alter_table = OFF;
PRAGMA foreign_keys = ON;