diff --git a/backend-api/cmd/api/main.go b/backend-api/cmd/api/main.go
index c03c018..fa5f7c0 100644
--- a/backend-api/cmd/api/main.go
+++ b/backend-api/cmd/api/main.go
@@ -14,6 +14,7 @@ import (
"cmroubao/backend-api/internal/config"
"cmroubao/backend-api/internal/platform/assetstore"
"cmroubao/backend-api/internal/platform/database"
+ "cmroubao/backend-api/internal/platform/erpconnector"
"cmroubao/backend-api/internal/platform/migration"
"cmroubao/backend-api/internal/platform/password"
repository "cmroubao/backend-api/internal/repository/sqlite"
@@ -217,6 +218,31 @@ func buildRouter(
if err != nil {
return nil, err
}
+ erpTimeout := cfg.ERPConnectorTimeout
+ if erpTimeout <= 0 {
+ erpTimeout = 90 * time.Second
+ }
+ erpClient, err := erpconnector.New(
+ cfg.ERPConnectorURL,
+ cfg.ERPConnectorAPIKey,
+ erpTimeout,
+ )
+ if err != nil {
+ return nil, err
+ }
+ freight, err := usecase.NewFreightService(
+ store,
+ erpClient,
+ clock,
+ ids,
+ erpTimeout,
+ )
+ if err != nil {
+ return nil, err
+ }
+ if _, err := freight.RecoverInterrupted(ctx); err != nil {
+ return nil, err
+ }
passwords, err := password.NewBcrypt(12)
if err != nil {
return nil, err
@@ -249,6 +275,7 @@ func buildRouter(
tasks,
assets,
authorizations,
+ freight,
)
if err != nil {
return nil, err
@@ -294,6 +321,7 @@ func buildRouter(
Tasks: tasks,
Results: results,
Authorizations: authorizations,
+ Freight: freight,
},
webHandler,
)
diff --git a/backend-api/cmd/api/main_test.go b/backend-api/cmd/api/main_test.go
index 717bdd3..7bb7cc9 100644
--- a/backend-api/cmd/api/main_test.go
+++ b/backend-api/cmd/api/main_test.go
@@ -6,6 +6,7 @@ import (
"net"
"net/http"
"net/http/httptest"
+ "net/url"
"path/filepath"
"testing"
"time"
@@ -158,6 +159,36 @@ func TestBuildRouterRegistersProtectedLogoutRoute(t *testing.T) {
response.Header().Get("Location"),
)
}
+ for _, target := range []string{"/freight", "/freight/import"} {
+ request = httptest.NewRequest(http.MethodGet, target, nil)
+ response = httptest.NewRecorder()
+ router.ServeHTTP(response, request)
+ if response.Code != http.StatusSeeOther ||
+ response.Header().Get("Location") !=
+ "/login?next="+url.QueryEscape(target) {
+ t.Fatalf(
+ "%s status/location = %d / %q",
+ target,
+ response.Code,
+ response.Header().Get("Location"),
+ )
+ }
+ }
+ request = httptest.NewRequest(
+ http.MethodGet,
+ "/api/v1/freight-orders",
+ nil,
+ )
+ response = httptest.NewRecorder()
+ router.ServeHTTP(response, request)
+ if response.Code != http.StatusUnauthorized ||
+ response.Header().Get("Cache-Control") != "no-store" {
+ t.Fatalf(
+ "freight API status/cache = %d / %q",
+ response.Code,
+ response.Header().Get("Cache-Control"),
+ )
+ }
}
type stubMigrationStatusReader struct {
diff --git a/backend-api/internal/config/config.go b/backend-api/internal/config/config.go
index cfbbbdb..29db0e2 100644
--- a/backend-api/internal/config/config.go
+++ b/backend-api/internal/config/config.go
@@ -3,6 +3,7 @@ package config
import (
"errors"
"net"
+ "net/url"
"path/filepath"
"strconv"
"strings"
@@ -10,40 +11,46 @@ import (
)
const (
- HTTPAddressEnvironment = "CMROUBAO_HTTP_ADDR"
- DatabasePathEnvironment = "CMROUBAO_DATABASE_PATH"
- AssetDirectoryEnvironment = "CMROUBAO_ASSET_DIR"
- TLSCertificateEnvironment = "CMROUBAO_TLS_CERT_FILE"
- TLSPrivateKeyEnvironment = "CMROUBAO_TLS_KEY_FILE"
- ClaimLeaseEnvironment = "CMROUBAO_CLAIM_LEASE"
- RunningLeaseEnvironment = "CMROUBAO_RUNNING_LEASE"
- ReadinessTTLEnvironment = "CMROUBAO_READINESS_TTL"
+ HTTPAddressEnvironment = "CMROUBAO_HTTP_ADDR"
+ DatabasePathEnvironment = "CMROUBAO_DATABASE_PATH"
+ AssetDirectoryEnvironment = "CMROUBAO_ASSET_DIR"
+ TLSCertificateEnvironment = "CMROUBAO_TLS_CERT_FILE"
+ TLSPrivateKeyEnvironment = "CMROUBAO_TLS_KEY_FILE"
+ ClaimLeaseEnvironment = "CMROUBAO_CLAIM_LEASE"
+ RunningLeaseEnvironment = "CMROUBAO_RUNNING_LEASE"
+ ReadinessTTLEnvironment = "CMROUBAO_READINESS_TTL"
+ ERPConnectorURLEnvironment = "CMROUBAO_ERP_CONNECTOR_URL"
+ ERPConnectorAPIKeyEnvironment = "CMROUBAO_ERP_CONNECTOR_API_KEY"
- defaultHTTPAddress = "127.0.0.1:8080"
- defaultDatabasePath = "var/cmroubao.db"
- defaultAssetDirectory = "var/assets"
- defaultClaimLease = 10 * time.Minute
- defaultRunningLease = 30 * time.Minute
- defaultReadinessTTL = 2 * time.Minute
+ defaultHTTPAddress = "127.0.0.1:8080"
+ defaultDatabasePath = "var/cmroubao.db"
+ defaultAssetDirectory = "var/assets"
+ defaultClaimLease = 10 * time.Minute
+ defaultRunningLease = 30 * time.Minute
+ defaultReadinessTTL = 2 * time.Minute
+ defaultERPConnectorURL = "http://127.0.0.1:8091"
)
type LookupEnvironment func(string) (string, bool)
type Config struct {
- HTTPAddress string
- DatabasePath string
- AssetDirectory string
- TLSCertificate string
- TLSPrivateKey string
- ReadHeaderTimeout time.Duration
- ReadTimeout time.Duration
- WriteTimeout time.Duration
- IdleTimeout time.Duration
- ShutdownTimeout time.Duration
- MaxHeaderBytes int
- ClaimLease time.Duration
- RunningLease time.Duration
- ReadinessTTL time.Duration
+ HTTPAddress string
+ DatabasePath string
+ AssetDirectory string
+ TLSCertificate string
+ TLSPrivateKey string
+ ReadHeaderTimeout time.Duration
+ ReadTimeout time.Duration
+ WriteTimeout time.Duration
+ IdleTimeout time.Duration
+ ShutdownTimeout time.Duration
+ MaxHeaderBytes int
+ ClaimLease time.Duration
+ RunningLease time.Duration
+ ReadinessTTL time.Duration
+ ERPConnectorURL string
+ ERPConnectorAPIKey string
+ ERPConnectorTimeout time.Duration
}
func Load(lookup LookupEnvironment) (Config, error) {
@@ -137,25 +144,76 @@ func Load(lookup LookupEnvironment) (Config, error) {
if err != nil {
return Config{}, err
}
+ erpConnectorURL, err := environmentValue(
+ lookup,
+ ERPConnectorURLEnvironment,
+ defaultERPConnectorURL,
+ )
+ if err != nil {
+ return Config{}, err
+ }
+ if err := validateLoopbackURL(erpConnectorURL); err != nil {
+ return Config{}, err
+ }
+ erpConnectorAPIKey := ""
+ if value, exists := lookup(ERPConnectorAPIKeyEnvironment); exists {
+ erpConnectorAPIKey = strings.TrimSpace(value)
+ if len([]byte(erpConnectorAPIKey)) < 32 {
+ return Config{}, errors.New(
+ ERPConnectorAPIKeyEnvironment +
+ " must contain at least 32 UTF-8 bytes",
+ )
+ }
+ }
return Config{
- HTTPAddress: httpAddress,
- DatabasePath: filepath.Clean(databasePath),
- AssetDirectory: assetDirectory,
- TLSCertificate: cleanOptionalPath(tlsCertificate),
- TLSPrivateKey: cleanOptionalPath(tlsPrivateKey),
- ReadHeaderTimeout: 5 * time.Second,
- ReadTimeout: 15 * time.Second,
- WriteTimeout: 30 * time.Second,
- IdleTimeout: 60 * time.Second,
- ShutdownTimeout: 10 * time.Second,
- MaxHeaderBytes: 1 << 20,
- ClaimLease: claimLease,
- RunningLease: runningLease,
- ReadinessTTL: readinessTTL,
+ HTTPAddress: httpAddress,
+ DatabasePath: filepath.Clean(databasePath),
+ AssetDirectory: assetDirectory,
+ TLSCertificate: cleanOptionalPath(tlsCertificate),
+ TLSPrivateKey: cleanOptionalPath(tlsPrivateKey),
+ ReadHeaderTimeout: 5 * time.Second,
+ ReadTimeout: 15 * time.Second,
+ WriteTimeout: 30 * time.Second,
+ IdleTimeout: 60 * time.Second,
+ ShutdownTimeout: 10 * time.Second,
+ MaxHeaderBytes: 1 << 20,
+ ClaimLease: claimLease,
+ RunningLease: runningLease,
+ ReadinessTTL: readinessTTL,
+ ERPConnectorURL: strings.TrimRight(erpConnectorURL, "/"),
+ ERPConnectorAPIKey: erpConnectorAPIKey,
+ ERPConnectorTimeout: 90 * time.Second,
}, nil
}
+func validateLoopbackURL(value string) error {
+ parsed, err := url.Parse(value)
+ if err != nil || parsed.Scheme != "http" || parsed.User != nil ||
+ parsed.RawQuery != "" || parsed.Fragment != "" ||
+ (parsed.Path != "" && parsed.Path != "/") {
+ return errors.New(
+ ERPConnectorURLEnvironment +
+ " must be an http loopback origin without credentials or path",
+ )
+ }
+ host := parsed.Hostname()
+ ip := net.ParseIP(host)
+ if !strings.EqualFold(host, "localhost") &&
+ (ip == nil || !ip.IsLoopback()) {
+ return errors.New(
+ ERPConnectorURLEnvironment + " must use a loopback host",
+ )
+ }
+ port, err := strconv.Atoi(parsed.Port())
+ if err != nil || port < 1 || port > 65535 {
+ return errors.New(
+ ERPConnectorURLEnvironment + " must include a valid port",
+ )
+ }
+ return nil
+}
+
func durationEnvironment(
lookup LookupEnvironment,
name string,
diff --git a/backend-api/internal/config/config_test.go b/backend-api/internal/config/config_test.go
index 1355727..cc1b2f9 100644
--- a/backend-api/internal/config/config_test.go
+++ b/backend-api/internal/config/config_test.go
@@ -42,18 +42,30 @@ func TestLoadUsesSafeDefaults(t *testing.T) {
if cfg.ShutdownTimeout > 30*time.Second {
t.Fatalf("ShutdownTimeout = %s", cfg.ShutdownTimeout)
}
+ if cfg.ERPConnectorURL != "http://127.0.0.1:8091" ||
+ cfg.ERPConnectorAPIKey != "" ||
+ cfg.ERPConnectorTimeout != 90*time.Second {
+ t.Fatalf(
+ "ERP connector defaults = %q / %q / %s",
+ cfg.ERPConnectorURL,
+ cfg.ERPConnectorAPIKey,
+ cfg.ERPConnectorTimeout,
+ )
+ }
}
func TestLoadAcceptsExplicitConfiguration(t *testing.T) {
values := map[string]string{
- HTTPAddressEnvironment: "192.0.2.10:9090",
- DatabasePathEnvironment: "tmp/test.db",
- AssetDirectoryEnvironment: "tmp/assets",
- TLSCertificateEnvironment: "tmp/server.crt",
- TLSPrivateKeyEnvironment: "tmp/server.key",
- ClaimLeaseEnvironment: "15m",
- RunningLeaseEnvironment: "45m",
- ReadinessTTLEnvironment: "3m",
+ HTTPAddressEnvironment: "192.0.2.10:9090",
+ DatabasePathEnvironment: "tmp/test.db",
+ AssetDirectoryEnvironment: "tmp/assets",
+ TLSCertificateEnvironment: "tmp/server.crt",
+ TLSPrivateKeyEnvironment: "tmp/server.key",
+ ClaimLeaseEnvironment: "15m",
+ RunningLeaseEnvironment: "45m",
+ ReadinessTTLEnvironment: "3m",
+ ERPConnectorURLEnvironment: "http://localhost:18091",
+ ERPConnectorAPIKeyEnvironment: "12345678901234567890123456789012",
}
cfg, err := Load(mapEnvironment(values))
@@ -88,6 +100,14 @@ func TestLoadAcceptsExplicitConfiguration(t *testing.T) {
cfg.ReadinessTTL,
)
}
+ if cfg.ERPConnectorURL != values[ERPConnectorURLEnvironment] ||
+ cfg.ERPConnectorAPIKey != values[ERPConnectorAPIKeyEnvironment] {
+ t.Fatalf(
+ "ERP connector = %q / %q",
+ cfg.ERPConnectorURL,
+ cfg.ERPConnectorAPIKey,
+ )
+ }
}
func TestLoadRejectsUnsafeOrInvalidValues(t *testing.T) {
@@ -95,6 +115,24 @@ func TestLoadRejectsUnsafeOrInvalidValues(t *testing.T) {
name string
values map[string]string
}{
+ {
+ name: "non-loopback ERP connector",
+ values: map[string]string{
+ ERPConnectorURLEnvironment: "http://192.0.2.10:8091",
+ },
+ },
+ {
+ name: "ERP connector path",
+ values: map[string]string{
+ ERPConnectorURLEnvironment: "http://127.0.0.1:8091/private",
+ },
+ },
+ {
+ name: "short ERP connector key",
+ values: map[string]string{
+ ERPConnectorAPIKeyEnvironment: "short",
+ },
+ },
{
name: "blank explicit address",
values: map[string]string{
diff --git a/backend-api/internal/domain/freight.go b/backend-api/internal/domain/freight.go
new file mode 100644
index 0000000..409a70a
--- /dev/null
+++ b/backend-api/internal/domain/freight.go
@@ -0,0 +1,140 @@
+package domain
+
+import "time"
+
+const (
+ FreightSourceShunyunbao = "SHUNYUNBAO"
+ FreightSyncOrderNumber = "ORDER_NUMBER"
+)
+
+type FreightSyncStatus string
+
+const (
+ FreightSyncPending FreightSyncStatus = "PENDING"
+ FreightSyncRunning FreightSyncStatus = "RUNNING"
+ FreightSyncSucceeded FreightSyncStatus = "SUCCEEDED"
+ FreightSyncFailed FreightSyncStatus = "FAILED"
+)
+
+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
+}
+
+type FreightOrder struct {
+ ID string
+ CreatorSubject string
+ SourceSystem string
+ ExternalStockID string
+ SourceCode string
+ PlatformOrderNo *string
+ ShopName *string
+ SourceCreatedAt *time.Time
+ OrderStatus *string
+ PurchaseStatus *string
+ IsCanceled *bool
+ CanonicalSHA256 string
+ Revision int
+ FirstSyncRunID string
+ LastSyncRunID string
+ CreatedAt time.Time
+ UpdatedAt time.Time
+ ItemCount int
+}
+
+type FreightOrderItem struct {
+ ID string
+ FreightOrderID string
+ ExternalItemID string
+ Title string
+ ProductSpec string
+ SKU string
+ Quantity *int
+ ProductThumbRef *string
+ PurchaseStatus *string
+ CanonicalSHA256 string
+ Revision int
+ IsPresent bool
+ FirstSyncRunID string
+ LastSyncRunID string
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
+type FreightOrderDetail struct {
+ Order FreightOrder
+ Items []FreightOrderItem
+}
+
+type FreightSourceBatch struct {
+ SchemaVersion int `json:"schema_version"`
+ Query FreightSourceQuery `json:"query"`
+ Orders []FreightSourceOrder `json:"orders"`
+}
+
+type FreightSourceQuery struct {
+ Mode string `json:"mode"`
+}
+
+type FreightSourceOrder struct {
+ ExternalStockID string `json:"external_stock_id"`
+ SourceCode string `json:"source_code"`
+ PlatformOrderNo *string `json:"platform_order_no"`
+ ShopName *string `json:"shop_name"`
+ SourceCreatedAt *string `json:"source_created_at"`
+ OrderStatus *string `json:"order_status"`
+ PurchaseStatus *string `json:"purchase_status"`
+ IsCanceled *bool `json:"is_canceled"`
+ Items []FreightSourceItem `json:"items"`
+}
+
+type FreightSourceItem struct {
+ ExternalItemID string `json:"external_item_id"`
+ Title string `json:"title"`
+ ProductSpec string `json:"product_spec"`
+ SKU string `json:"sku"`
+ Quantity *int `json:"quantity"`
+ ProductThumbRef *string `json:"product_thumb_ref"`
+ PurchaseStatus *string `json:"purchase_status"`
+}
+
+type FreightImportBatch struct {
+ Orders []FreightImportOrder
+}
+
+type FreightImportOrder struct {
+ ID string
+ ExternalStockID string
+ SourceCode string
+ PlatformOrderNo *string
+ ShopName *string
+ SourceCreatedAt *time.Time
+ OrderStatus *string
+ PurchaseStatus *string
+ IsCanceled *bool
+ CanonicalSHA256 string
+ Items []FreightImportItem
+}
+
+type FreightImportItem struct {
+ ID string
+ ExternalItemID string
+ Title string
+ ProductSpec string
+ SKU string
+ Quantity *int
+ ProductThumbRef *string
+ PurchaseStatus *string
+ CanonicalSHA256 string
+}
diff --git a/backend-api/internal/platform/erpconnector/client.go b/backend-api/internal/platform/erpconnector/client.go
new file mode 100644
index 0000000..13c6b37
--- /dev/null
+++ b/backend-api/internal/platform/erpconnector/client.go
@@ -0,0 +1,114 @@
+package erpconnector
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+
+ "cmroubao/backend-api/internal/domain"
+)
+
+var (
+ ErrNotConfigured = errors.New("ERP connector is not configured")
+ ErrSessionRequired = errors.New("ERP session is required")
+ ErrNotFound = errors.New("ERP freight order not found")
+ ErrUnavailable = errors.New("ERP connector is unavailable")
+ ErrProtocol = errors.New("ERP connector protocol is invalid")
+)
+
+const maxResponseBytes = 4 << 20
+
+type Client struct {
+ baseURL string
+ apiKey string
+ http *http.Client
+}
+
+func New(baseURL, apiKey string, timeout time.Duration) (*Client, error) {
+ if strings.TrimSpace(baseURL) == "" {
+ baseURL = "http://127.0.0.1:8091"
+ }
+ if timeout <= 0 {
+ timeout = 90 * time.Second
+ }
+ if timeout <= 0 {
+ return nil, errors.New("ERP connector client configuration is invalid")
+ }
+ return &Client{
+ baseURL: strings.TrimRight(baseURL, "/"),
+ apiKey: strings.TrimSpace(apiKey),
+ http: &http.Client{
+ Timeout: timeout,
+ CheckRedirect: func(
+ _ *http.Request,
+ _ []*http.Request,
+ ) error {
+ return http.ErrUseLastResponse
+ },
+ },
+ }, nil
+}
+
+func (client *Client) QueryOrder(
+ ctx context.Context,
+ orderNumber string,
+) (domain.FreightSourceBatch, error) {
+ if client.apiKey == "" {
+ return domain.FreightSourceBatch{}, ErrNotConfigured
+ }
+ body, err := json.Marshal(map[string]string{"order_number": orderNumber})
+ if err != nil {
+ return domain.FreightSourceBatch{}, ErrProtocol
+ }
+ request, err := http.NewRequestWithContext(
+ ctx,
+ http.MethodPost,
+ client.baseURL+"/v1/freight/query",
+ bytes.NewReader(body),
+ )
+ if err != nil {
+ return domain.FreightSourceBatch{}, ErrUnavailable
+ }
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("X-API-Key", client.apiKey)
+ response, err := client.http.Do(request)
+ if err != nil {
+ return domain.FreightSourceBatch{}, ErrUnavailable
+ }
+ defer response.Body.Close()
+ limited := io.LimitReader(response.Body, maxResponseBytes+1)
+ content, err := io.ReadAll(limited)
+ if err != nil || len(content) > maxResponseBytes {
+ return domain.FreightSourceBatch{}, ErrUnavailable
+ }
+ if response.StatusCode != http.StatusOK {
+ switch response.StatusCode {
+ case http.StatusUnauthorized:
+ return domain.FreightSourceBatch{}, ErrSessionRequired
+ case http.StatusNotFound:
+ return domain.FreightSourceBatch{}, ErrNotFound
+ default:
+ return domain.FreightSourceBatch{}, ErrUnavailable
+ }
+ }
+ var result domain.FreightSourceBatch
+ decoder := json.NewDecoder(bytes.NewReader(content))
+ decoder.DisallowUnknownFields()
+ if err := decoder.Decode(&result); err != nil {
+ return domain.FreightSourceBatch{}, ErrProtocol
+ }
+ var extra any
+ if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
+ return domain.FreightSourceBatch{}, ErrProtocol
+ }
+ if result.SchemaVersion != 1 || result.Query.Mode != "ORDER_NUMBER" ||
+ result.Orders == nil {
+ 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
new file mode 100644
index 0000000..a20e02d
--- /dev/null
+++ b/backend-api/internal/platform/erpconnector/client_test.go
@@ -0,0 +1,143 @@
+package erpconnector
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+)
+
+func TestQueryOrderAcceptsAllowlistResponse(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(
+ writer http.ResponseWriter,
+ request *http.Request,
+ ) {
+ if request.Header.Get("X-API-Key") != "12345678901234567890123456789012" {
+ t.Fatal("service key was not forwarded")
+ }
+ writer.Header().Set("Content-Type", "application/json")
+ _, _ = writer.Write([]byte(`{
+ "schema_version":1,
+ "query":{"mode":"ORDER_NUMBER"},
+ "orders":[{
+ "external_stock_id":"12",
+ "source_code":"SOURCE-12",
+ "platform_order_no":null,
+ "shop_name":"测试店铺",
+ "source_created_at":"2026-07-28 08:00:00",
+ "order_status":"0",
+ "purchase_status":"1",
+ "is_canceled":false,
+ "items":[{
+ "external_item_id":"88",
+ "title":"商品",
+ "product_spec":"黑色,L",
+ "sku":"BLACK-L",
+ "quantity":2,
+ "product_thumb_ref":"190",
+ "purchase_status":"0"
+ }]
+ }]
+ }`))
+ }))
+ defer server.Close()
+ client, err := New(
+ server.URL,
+ "12345678901234567890123456789012",
+ time.Second,
+ )
+ if err != nil {
+ t.Fatalf("New() error = %v", err)
+ }
+ result, err := client.QueryOrder(context.Background(), "SOURCE-12")
+ if err != nil {
+ t.Fatalf("QueryOrder() error = %v", err)
+ }
+ if len(result.Orders) != 1 || len(result.Orders[0].Items) != 1 ||
+ result.Orders[0].Items[0].SKU != "BLACK-L" {
+ t.Fatalf("result = %+v", result)
+ }
+}
+
+func TestQueryOrderRejectsUnexpectedPIIField(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":"ORDER_NUMBER"},
+ "orders":[],
+ "receiverTel":"private"
+ }`))
+ }))
+ defer server.Close()
+ client, _ := New(server.URL, "12345678901234567890123456789012", time.Second)
+ _, err := client.QueryOrder(context.Background(), "SOURCE-12")
+ if !errors.Is(err, ErrProtocol) {
+ t.Fatalf("QueryOrder() error = %v, want protocol error", err)
+ }
+}
+
+func TestQueryOrderMapsStableErrorsWithoutReadingDetails(t *testing.T) {
+ tests := []struct {
+ status int
+ want error
+ }{
+ {http.StatusUnauthorized, ErrSessionRequired},
+ {http.StatusNotFound, ErrNotFound},
+ {http.StatusBadGateway, ErrUnavailable},
+ }
+ for _, test := range tests {
+ t.Run(http.StatusText(test.status), func(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(
+ writer http.ResponseWriter,
+ _ *http.Request,
+ ) {
+ writer.WriteHeader(test.status)
+ _, _ = writer.Write([]byte(`{"detail":"private upstream text"}`))
+ }))
+ defer server.Close()
+ client, _ := New(
+ server.URL,
+ "12345678901234567890123456789012",
+ time.Second,
+ )
+ _, err := client.QueryOrder(context.Background(), "SOURCE-12")
+ if !errors.Is(err, test.want) {
+ t.Fatalf("QueryOrder() error = %v, want %v", err, test.want)
+ }
+ })
+ }
+}
+
+func TestQueryOrderDoesNotFollowRedirects(t *testing.T) {
+ followed := false
+ target := httptest.NewServer(http.HandlerFunc(func(
+ http.ResponseWriter,
+ *http.Request,
+ ) {
+ followed = true
+ }))
+ defer target.Close()
+ redirect := httptest.NewServer(http.HandlerFunc(func(
+ writer http.ResponseWriter,
+ _ *http.Request,
+ ) {
+ writer.Header().Set("Location", target.URL)
+ writer.WriteHeader(http.StatusTemporaryRedirect)
+ }))
+ defer redirect.Close()
+ client, _ := New(
+ redirect.URL,
+ "12345678901234567890123456789012",
+ time.Second,
+ )
+ _, err := client.QueryOrder(context.Background(), "SOURCE-12")
+ if !errors.Is(err, ErrUnavailable) || followed {
+ t.Fatalf("redirect error/followed = %v / %t", err, followed)
+ }
+}
diff --git a/backend-api/internal/platform/migration/claims_migration_test.go b/backend-api/internal/platform/migration/claims_migration_test.go
index fdae6b9..7a1dfb7 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 != 11 {
- t.Fatalf("initial Up() applied = %d, want 11", applied)
+ } else if applied != 12 {
+ t.Fatalf("initial Up() applied = %d, want 12", applied)
+ }
+ if err := runner.Down(ctx); err != nil {
+ t.Fatalf("initial Down(v12) error = %v", err)
}
if err := runner.Down(ctx); err != nil {
t.Fatalf("initial Down(v11) error = %v", err)
@@ -62,9 +65,14 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
seedClaimsHistoricalFixture(t, db)
if applied, err := runner.Up(ctx); err != nil {
- t.Fatalf("Up(v5-v11) over historical data error = %v", err)
- } else if applied != 7 {
- t.Fatalf("Up(v5-v11) applied = %d, want 7", applied)
+ t.Fatalf("Up(v5-v12) over historical data error = %v", err)
+ } else if applied != 8 {
+ t.Fatalf("Up(v5-v12) applied = %d, want 8", applied)
+ }
+ assertClaimsHistory(t, db, true)
+
+ if err := runner.Down(ctx); err != nil {
+ t.Fatalf("Down(v12) with compatible history error = %v", err)
}
assertClaimsHistory(t, db, true)
@@ -109,9 +117,9 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
assertClaimsHistory(t, db, false)
if applied, err := runner.Up(ctx); err != nil {
- t.Fatalf("final Up(v4-v11) error = %v", err)
- } else if applied != 8 {
- t.Fatalf("final Up(v4-v11) applied = %d, want 8", applied)
+ t.Fatalf("final Up(v4-v12) error = %v", err)
+ } else if applied != 9 {
+ t.Fatalf("final Up(v4-v12) applied = %d, want 9", applied)
}
assertClaimsHistory(t, db, true)
}
@@ -347,6 +355,9 @@ func TestClaimsMigrationDownFailsClosedForNewAuditData(t *testing.T) {
t.Fatalf("insert v4 audit event: %v", err)
}
+ if err := runner.Down(ctx); err != nil {
+ t.Fatalf("Down(v12) error = %v", err)
+ }
if err := runner.Down(ctx); err != nil {
t.Fatalf("Down(v11) error = %v", err)
}
diff --git a/backend-api/internal/platform/migration/runner_test.go b/backend-api/internal/platform/migration/runner_test.go
index ca0445a..c219091 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 != 11 {
- t.Fatalf("Up() applied = %d, want 11", applied)
+ if applied != 12 {
+ t.Fatalf("Up() applied = %d, want 12", applied)
}
assertStatuses(t, runner, map[int64]bool{
1: true,
@@ -42,6 +42,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
9: true,
10: true,
11: true,
+ 12: true,
})
applied, err = runner.Up(context.Background())
@@ -66,7 +67,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
8: true,
9: true,
10: true,
- 11: false,
+ 11: true,
+ 12: false,
})
applied, err = runner.Up(context.Background())
@@ -88,6 +90,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
9: true,
10: true,
11: true,
+ 12: true,
})
}
diff --git a/backend-api/internal/repository/sqlite/auth_repository_test.go b/backend-api/internal/repository/sqlite/auth_repository_test.go
index 66304ba..1e9bef1 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(v12) error = %v", err)
+ }
if err := runner.Down(context.Background()); err != nil {
t.Fatalf("Down(v11) error = %v", err)
}
@@ -423,9 +426,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-v11) error = %v", err)
- } else if applied != 9 {
- t.Fatalf("Up(v3-v11) applied = %d, want 9", applied)
+ t.Fatalf("Up(v3-v12) error = %v", err)
+ } else if applied != 10 {
+ t.Fatalf("Up(v3-v12) applied = %d, want 10", applied)
}
}
diff --git a/backend-api/internal/repository/sqlite/freight_repository.go b/backend-api/internal/repository/sqlite/freight_repository.go
new file mode 100644
index 0000000..089b9ec
--- /dev/null
+++ b/backend-api/internal/repository/sqlite/freight_repository.go
@@ -0,0 +1,630 @@
+package sqlite
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "time"
+
+ "cmroubao/backend-api/internal/domain"
+ "cmroubao/backend-api/internal/usecase"
+)
+
+func (store *Store) CreateFreightSync(
+ ctx context.Context,
+ run domain.FreightSyncRun,
+ idempotencyKey string,
+ requestSHA256 string,
+) (domain.FreightSyncRun, bool, error) {
+ tx, err := store.db.BeginTx(ctx, nil)
+ if err != nil {
+ return domain.FreightSyncRun{}, false, repositoryFailure(err)
+ }
+ defer tx.Rollback()
+ existing, err := scanFreightSync(tx.QueryRowContext(
+ ctx,
+ freightSyncSelect+`
+ WHERE creator_subject = ? AND idempotency_key = ?`,
+ run.CreatorSubject,
+ idempotencyKey,
+ ))
+ if err == nil {
+ var storedHash string
+ if err := tx.QueryRowContext(
+ ctx,
+ `SELECT request_sha256
+ FROM erp_sync_runs
+ WHERE creator_subject = ? AND idempotency_key = ?`,
+ run.CreatorSubject,
+ idempotencyKey,
+ ).Scan(&storedHash); err != nil {
+ return domain.FreightSyncRun{}, false, repositoryFailure(err)
+ }
+ if storedHash != requestSHA256 {
+ return domain.FreightSyncRun{}, false, usecase.ErrIdempotencyConflict
+ }
+ return existing, false, nil
+ }
+ if !errors.Is(err, sql.ErrNoRows) {
+ return domain.FreightSyncRun{}, false, repositoryFailure(err)
+ }
+ _, err = tx.ExecContext(
+ 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, ?)`,
+ run.ID,
+ run.CreatorSubject,
+ run.CreatedByUserID,
+ run.Mode,
+ run.OrderNumber,
+ run.QuerySHA256,
+ idempotencyKey,
+ requestSHA256,
+ run.Status,
+ formatTimestamp(run.CreatedAt),
+ )
+ if err != nil {
+ return domain.FreightSyncRun{}, false, repositoryFailure(err)
+ }
+ if err := tx.Commit(); err != nil {
+ return domain.FreightSyncRun{}, false, repositoryFailure(err)
+ }
+ return run, true, nil
+}
+
+func (store *Store) StartFreightSync(
+ ctx context.Context,
+ runID string,
+ startedAt time.Time,
+) error {
+ result, err := store.db.ExecContext(
+ ctx,
+ `UPDATE erp_sync_runs
+ SET status = 'RUNNING', started_at = ?
+ WHERE id = ? AND status = 'PENDING'`,
+ formatTimestamp(startedAt),
+ runID,
+ )
+ if err != nil {
+ return repositoryFailure(err)
+ }
+ changed, err := result.RowsAffected()
+ if err != nil {
+ return repositoryFailure(err)
+ }
+ if changed != 1 {
+ return usecase.ErrTaskStateConflict
+ }
+ return nil
+}
+
+func (store *Store) CompleteFreightSync(
+ ctx context.Context,
+ run domain.FreightSyncRun,
+ batch domain.FreightImportBatch,
+ finishedAt time.Time,
+) error {
+ tx, err := store.db.BeginTx(ctx, nil)
+ if err != nil {
+ return repositoryFailure(err)
+ }
+ defer tx.Rollback()
+ var status string
+ if err := tx.QueryRowContext(
+ ctx,
+ `SELECT status FROM erp_sync_runs WHERE id = ?`,
+ run.ID,
+ ).Scan(&status); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return usecase.ErrRepositoryNotFound
+ }
+ return repositoryFailure(err)
+ }
+ if status != string(domain.FreightSyncRunning) {
+ return usecase.ErrTaskStateConflict
+ }
+ itemCount := 0
+ for _, order := range batch.Orders {
+ orderID, err := upsertFreightOrder(
+ ctx,
+ tx,
+ run,
+ order,
+ finishedAt,
+ )
+ if err != nil {
+ return err
+ }
+ if _, err := tx.ExecContext(
+ ctx,
+ `UPDATE freight_order_items
+ SET is_present = 0, last_sync_run_id = ?, updated_at = ?
+ WHERE freight_order_id = ?`,
+ run.ID,
+ formatTimestamp(finishedAt),
+ orderID,
+ ); err != nil {
+ return repositoryFailure(err)
+ }
+ for _, item := range order.Items {
+ if err := upsertFreightOrderItem(
+ ctx,
+ tx,
+ run.ID,
+ orderID,
+ item,
+ finishedAt,
+ ); err != nil {
+ return err
+ }
+ itemCount++
+ }
+ }
+ result, err := tx.ExecContext(
+ ctx,
+ `UPDATE erp_sync_runs
+ SET status = 'SUCCEEDED', error_code = NULL, order_count = ?,
+ item_count = ?, finished_at = ?
+ WHERE id = ? AND status = 'RUNNING'`,
+ len(batch.Orders),
+ itemCount,
+ formatTimestamp(finishedAt),
+ run.ID,
+ )
+ if err != nil {
+ return repositoryFailure(err)
+ }
+ changed, err := result.RowsAffected()
+ if err != nil {
+ return repositoryFailure(err)
+ }
+ if changed != 1 {
+ return usecase.ErrTaskStateConflict
+ }
+ if err := tx.Commit(); err != nil {
+ return repositoryFailure(err)
+ }
+ return nil
+}
+
+func upsertFreightOrder(
+ ctx context.Context,
+ tx *sql.Tx,
+ run domain.FreightSyncRun,
+ order domain.FreightImportOrder,
+ now time.Time,
+) (string, error) {
+ _, err := tx.ExecContext(
+ ctx,
+ `INSERT INTO freight_orders (
+ id, creator_subject, source_system, external_stock_id,
+ source_code, platform_order_no, shop_name, source_created_at,
+ order_status, purchase_status, is_canceled, canonical_sha256,
+ revision, first_sync_run_id, last_sync_run_id, created_at, updated_at
+ ) VALUES (?, ?, 'SHUNYUNBAO', ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?)
+ ON CONFLICT (creator_subject, source_system, external_stock_id)
+ DO UPDATE SET
+ source_code = excluded.source_code,
+ platform_order_no = excluded.platform_order_no,
+ shop_name = excluded.shop_name,
+ source_created_at = excluded.source_created_at,
+ order_status = excluded.order_status,
+ purchase_status = excluded.purchase_status,
+ is_canceled = excluded.is_canceled,
+ revision = CASE
+ WHEN freight_orders.canonical_sha256 != excluded.canonical_sha256
+ THEN freight_orders.revision + 1
+ ELSE freight_orders.revision
+ END,
+ canonical_sha256 = excluded.canonical_sha256,
+ last_sync_run_id = excluded.last_sync_run_id,
+ updated_at = excluded.updated_at`,
+ order.ID,
+ run.CreatorSubject,
+ order.ExternalStockID,
+ order.SourceCode,
+ nullableString(order.PlatformOrderNo),
+ nullableString(order.ShopName),
+ nullableTimestamp(order.SourceCreatedAt),
+ nullableString(order.OrderStatus),
+ nullableString(order.PurchaseStatus),
+ nullableBool(order.IsCanceled),
+ order.CanonicalSHA256,
+ run.ID,
+ run.ID,
+ formatTimestamp(now),
+ formatTimestamp(now),
+ )
+ if err != nil {
+ return "", repositoryFailure(err)
+ }
+ var orderID string
+ if err := tx.QueryRowContext(
+ ctx,
+ `SELECT id FROM freight_orders
+ WHERE creator_subject = ? AND source_system = 'SHUNYUNBAO'
+ AND external_stock_id = ?`,
+ run.CreatorSubject,
+ order.ExternalStockID,
+ ).Scan(&orderID); err != nil {
+ return "", repositoryFailure(err)
+ }
+ return orderID, nil
+}
+
+func upsertFreightOrderItem(
+ ctx context.Context,
+ tx *sql.Tx,
+ runID, orderID string,
+ item domain.FreightImportItem,
+ now time.Time,
+) error {
+ _, err := tx.ExecContext(
+ ctx,
+ `INSERT INTO freight_order_items (
+ id, freight_order_id, external_item_id, title, product_spec,
+ sku, quantity, product_thumb_ref, purchase_status,
+ canonical_sha256, revision, is_present, first_sync_run_id,
+ last_sync_run_id, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?, ?, ?)
+ ON CONFLICT (freight_order_id, external_item_id)
+ DO UPDATE SET
+ title = excluded.title,
+ product_spec = excluded.product_spec,
+ sku = excluded.sku,
+ quantity = excluded.quantity,
+ product_thumb_ref = excluded.product_thumb_ref,
+ purchase_status = excluded.purchase_status,
+ revision = CASE
+ WHEN freight_order_items.canonical_sha256 != excluded.canonical_sha256
+ THEN freight_order_items.revision + 1
+ ELSE freight_order_items.revision
+ END,
+ canonical_sha256 = excluded.canonical_sha256,
+ is_present = 1,
+ last_sync_run_id = excluded.last_sync_run_id,
+ updated_at = excluded.updated_at`,
+ item.ID,
+ orderID,
+ item.ExternalItemID,
+ item.Title,
+ item.ProductSpec,
+ item.SKU,
+ nullableFreightQuantity(item.Quantity),
+ nullableString(item.ProductThumbRef),
+ nullableString(item.PurchaseStatus),
+ item.CanonicalSHA256,
+ runID,
+ runID,
+ formatTimestamp(now),
+ formatTimestamp(now),
+ )
+ if err != nil {
+ return repositoryFailure(err)
+ }
+ return nil
+}
+
+func (store *Store) FailFreightSync(
+ ctx context.Context,
+ runID, errorCode string,
+ finishedAt time.Time,
+) error {
+ result, err := store.db.ExecContext(
+ ctx,
+ `UPDATE erp_sync_runs
+ SET status = 'FAILED', error_code = ?, finished_at = ?
+ WHERE id = ? AND status IN ('PENDING', 'RUNNING')`,
+ errorCode,
+ formatTimestamp(finishedAt),
+ runID,
+ )
+ if err != nil {
+ return repositoryFailure(err)
+ }
+ changed, err := result.RowsAffected()
+ if err != nil {
+ return repositoryFailure(err)
+ }
+ if changed != 1 {
+ return usecase.ErrTaskStateConflict
+ }
+ return nil
+}
+
+func (store *Store) RecoverFreightSyncs(
+ ctx context.Context,
+ finishedAt time.Time,
+) (int64, error) {
+ result, err := store.db.ExecContext(
+ ctx,
+ `UPDATE erp_sync_runs
+ SET status = 'FAILED', error_code = 'PROCESS_INTERRUPTED',
+ finished_at = ?
+ WHERE status IN ('PENDING', 'RUNNING')`,
+ formatTimestamp(finishedAt),
+ )
+ if err != nil {
+ return 0, repositoryFailure(err)
+ }
+ changed, err := result.RowsAffected()
+ if err != nil {
+ return 0, repositoryFailure(err)
+ }
+ return changed, nil
+}
+
+func (store *Store) GetFreightSync(
+ ctx context.Context,
+ creatorSubject, runID string,
+) (domain.FreightSyncRun, error) {
+ run, err := scanFreightSync(store.db.QueryRowContext(
+ ctx,
+ freightSyncSelect+`
+ WHERE creator_subject = ? AND id = ?`,
+ creatorSubject,
+ runID,
+ ))
+ if errors.Is(err, sql.ErrNoRows) {
+ return domain.FreightSyncRun{}, usecase.ErrRepositoryNotFound
+ }
+ if err != nil {
+ return domain.FreightSyncRun{}, repositoryFailure(err)
+ }
+ return run, nil
+}
+
+func (store *Store) ListFreightOrders(
+ ctx context.Context,
+ creatorSubject string,
+ limit int,
+) ([]domain.FreightOrder, error) {
+ rows, err := store.db.QueryContext(
+ ctx,
+ freightOrderSelect+`
+ WHERE freight_orders.creator_subject = ?
+ ORDER BY freight_orders.updated_at DESC, freight_orders.id DESC
+ LIMIT ?`,
+ creatorSubject,
+ limit,
+ )
+ if err != nil {
+ return nil, repositoryFailure(err)
+ }
+ defer rows.Close()
+ orders := make([]domain.FreightOrder, 0)
+ for rows.Next() {
+ order, err := scanFreightOrder(rows)
+ if err != nil {
+ return nil, repositoryFailure(err)
+ }
+ orders = append(orders, order)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, repositoryFailure(err)
+ }
+ return orders, nil
+}
+
+func (store *Store) GetFreightOrder(
+ ctx context.Context,
+ creatorSubject, orderID string,
+) (domain.FreightOrderDetail, error) {
+ order, err := scanFreightOrder(store.db.QueryRowContext(
+ ctx,
+ freightOrderSelect+`
+ WHERE freight_orders.creator_subject = ? AND freight_orders.id = ?`,
+ creatorSubject,
+ orderID,
+ ))
+ if errors.Is(err, sql.ErrNoRows) {
+ return domain.FreightOrderDetail{}, usecase.ErrRepositoryNotFound
+ }
+ if err != nil {
+ return domain.FreightOrderDetail{}, repositoryFailure(err)
+ }
+ rows, err := store.db.QueryContext(
+ ctx,
+ `SELECT
+ id, freight_order_id, external_item_id, title, product_spec,
+ sku, quantity, product_thumb_ref, purchase_status,
+ canonical_sha256, revision, is_present, first_sync_run_id,
+ last_sync_run_id, created_at, updated_at
+ FROM freight_order_items
+ WHERE freight_order_id = ? AND is_present = 1
+ ORDER BY CAST(external_item_id AS INTEGER), external_item_id`,
+ order.ID,
+ )
+ if err != nil {
+ return domain.FreightOrderDetail{}, repositoryFailure(err)
+ }
+ defer rows.Close()
+ items := make([]domain.FreightOrderItem, 0)
+ for rows.Next() {
+ item, err := scanFreightOrderItem(rows)
+ if err != nil {
+ return domain.FreightOrderDetail{}, repositoryFailure(err)
+ }
+ items = append(items, item)
+ }
+ if err := rows.Err(); err != nil {
+ return domain.FreightOrderDetail{}, repositoryFailure(err)
+ }
+ return domain.FreightOrderDetail{Order: order, Items: items}, nil
+}
+
+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
+ FROM erp_sync_runs
+ `
+
+func scanFreightSync(scanner rowScanner) (domain.FreightSyncRun, error) {
+ var run domain.FreightSyncRun
+ var errorCode sql.NullString
+ var createdAt string
+ var startedAt sql.NullString
+ var finishedAt sql.NullString
+ err := scanner.Scan(
+ &run.ID,
+ &run.CreatorSubject,
+ &run.CreatedByUserID,
+ &run.Mode,
+ &run.OrderNumber,
+ &run.QuerySHA256,
+ &run.Status,
+ &errorCode,
+ &run.OrderCount,
+ &run.ItemCount,
+ &createdAt,
+ &startedAt,
+ &finishedAt,
+ )
+ if err != nil {
+ return domain.FreightSyncRun{}, err
+ }
+ if errorCode.Valid {
+ run.ErrorCode = &errorCode.String
+ }
+ run.CreatedAt, err = parseTimestamp(createdAt)
+ if err != nil {
+ return domain.FreightSyncRun{}, err
+ }
+ run.StartedAt, err = parseNullableTimestamp(startedAt)
+ if err != nil {
+ return domain.FreightSyncRun{}, err
+ }
+ run.FinishedAt, err = parseNullableTimestamp(finishedAt)
+ return run, err
+}
+
+const freightOrderSelect = `SELECT
+ freight_orders.id, freight_orders.creator_subject,
+ freight_orders.source_system, freight_orders.external_stock_id,
+ freight_orders.source_code, freight_orders.platform_order_no,
+ freight_orders.shop_name, freight_orders.source_created_at,
+ freight_orders.order_status, freight_orders.purchase_status,
+ freight_orders.is_canceled, freight_orders.canonical_sha256,
+ freight_orders.revision, freight_orders.first_sync_run_id,
+ freight_orders.last_sync_run_id, freight_orders.created_at,
+ freight_orders.updated_at,
+ (SELECT count(*) FROM freight_order_items
+ WHERE freight_order_id = freight_orders.id AND is_present = 1)
+ FROM freight_orders
+ `
+
+func scanFreightOrder(scanner rowScanner) (domain.FreightOrder, error) {
+ var order domain.FreightOrder
+ var platformOrderNo, shopName, sourceCreatedAt sql.NullString
+ var orderStatus, purchaseStatus sql.NullString
+ var isCanceled sql.NullBool
+ var createdAt, updatedAt string
+ err := scanner.Scan(
+ &order.ID,
+ &order.CreatorSubject,
+ &order.SourceSystem,
+ &order.ExternalStockID,
+ &order.SourceCode,
+ &platformOrderNo,
+ &shopName,
+ &sourceCreatedAt,
+ &orderStatus,
+ &purchaseStatus,
+ &isCanceled,
+ &order.CanonicalSHA256,
+ &order.Revision,
+ &order.FirstSyncRunID,
+ &order.LastSyncRunID,
+ &createdAt,
+ &updatedAt,
+ &order.ItemCount,
+ )
+ if err != nil {
+ return domain.FreightOrder{}, err
+ }
+ order.PlatformOrderNo = optionalString(platformOrderNo)
+ order.ShopName = optionalString(shopName)
+ order.OrderStatus = optionalString(orderStatus)
+ order.PurchaseStatus = optionalString(purchaseStatus)
+ if isCanceled.Valid {
+ order.IsCanceled = &isCanceled.Bool
+ }
+ if sourceCreatedAt.Valid {
+ parsed, err := parseTimestamp(sourceCreatedAt.String)
+ if err != nil {
+ return domain.FreightOrder{}, err
+ }
+ order.SourceCreatedAt = &parsed
+ }
+ order.CreatedAt, err = parseTimestamp(createdAt)
+ if err != nil {
+ return domain.FreightOrder{}, err
+ }
+ order.UpdatedAt, err = parseTimestamp(updatedAt)
+ return order, err
+}
+
+func scanFreightOrderItem(scanner rowScanner) (domain.FreightOrderItem, error) {
+ var item domain.FreightOrderItem
+ var quantity sql.NullInt64
+ var thumb, purchaseStatus sql.NullString
+ var createdAt, updatedAt string
+ err := scanner.Scan(
+ &item.ID,
+ &item.FreightOrderID,
+ &item.ExternalItemID,
+ &item.Title,
+ &item.ProductSpec,
+ &item.SKU,
+ &quantity,
+ &thumb,
+ &purchaseStatus,
+ &item.CanonicalSHA256,
+ &item.Revision,
+ &item.IsPresent,
+ &item.FirstSyncRunID,
+ &item.LastSyncRunID,
+ &createdAt,
+ &updatedAt,
+ )
+ if err != nil {
+ return domain.FreightOrderItem{}, err
+ }
+ if quantity.Valid {
+ value := int(quantity.Int64)
+ item.Quantity = &value
+ }
+ item.ProductThumbRef = optionalString(thumb)
+ item.PurchaseStatus = optionalString(purchaseStatus)
+ item.CreatedAt, err = parseTimestamp(createdAt)
+ if err != nil {
+ return domain.FreightOrderItem{}, err
+ }
+ item.UpdatedAt, err = parseTimestamp(updatedAt)
+ return item, err
+}
+
+func nullableTimestamp(value *time.Time) any {
+ if value == nil {
+ return nil
+ }
+ return formatTimestamp(*value)
+}
+
+func nullableFreightQuantity(value *int) any {
+ if value == nil {
+ return nil
+ }
+ return *value
+}
+
+func optionalString(value sql.NullString) *string {
+ if !value.Valid {
+ return nil
+ }
+ return &value.String
+}
diff --git a/backend-api/internal/repository/sqlite/freight_repository_test.go b/backend-api/internal/repository/sqlite/freight_repository_test.go
new file mode 100644
index 0000000..d251bb7
--- /dev/null
+++ b/backend-api/internal/repository/sqlite/freight_repository_test.go
@@ -0,0 +1,253 @@
+package sqlite_test
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "testing"
+ "time"
+
+ "cmroubao/backend-api/internal/domain"
+ "cmroubao/backend-api/internal/platform/migration"
+ repository "cmroubao/backend-api/internal/repository/sqlite"
+ "cmroubao/backend-api/internal/usecase"
+)
+
+func TestFreightImportIsAtomicIdempotentAndRevisioned(t *testing.T) {
+ db := openDatabase(t)
+ store, err := repository.New(db)
+ if err != nil {
+ t.Fatalf("repository.New() error = %v", err)
+ }
+ ctx := context.Background()
+ now := time.Date(2026, 7, 28, 1, 2, 3, 0, time.UTC)
+ userID := uuid(900)
+ seedFreightUser(t, db, userID, now)
+
+ first := freightRun(901, userID, now)
+ stored, created, err := store.CreateFreightSync(
+ ctx,
+ first,
+ "freight-key-1",
+ repeatHex("1"),
+ )
+ if err != nil || !created || stored.ID != first.ID {
+ t.Fatalf("CreateFreightSync() = %+v, %t, %v", stored, created, err)
+ }
+ replayed, created, err := store.CreateFreightSync(
+ ctx,
+ freightRun(902, userID, now),
+ "freight-key-1",
+ repeatHex("1"),
+ )
+ if err != nil || created || replayed.ID != first.ID {
+ t.Fatalf("sync replay = %+v, %t, %v", replayed, created, err)
+ }
+ _, _, err = store.CreateFreightSync(
+ ctx,
+ freightRun(903, userID, now),
+ "freight-key-1",
+ repeatHex("2"),
+ )
+ if !errors.Is(err, usecase.ErrIdempotencyConflict) {
+ t.Fatalf("conflicting replay error = %v", err)
+ }
+
+ if err := store.StartFreightSync(ctx, first.ID, now.Add(time.Second)); err != nil {
+ t.Fatalf("StartFreightSync() error = %v", err)
+ }
+ batch := freightBatch(910, "a", "b")
+ if err := store.CompleteFreightSync(
+ ctx,
+ first,
+ batch,
+ now.Add(2*time.Second),
+ ); err != nil {
+ t.Fatalf("CompleteFreightSync() error = %v", err)
+ }
+ orders, err := store.ListFreightOrders(ctx, "local-admin", 10)
+ if err != nil || len(orders) != 1 || orders[0].ItemCount != 2 ||
+ orders[0].Revision != 1 {
+ t.Fatalf("orders = %+v, error = %v", orders, err)
+ }
+ detail, err := store.GetFreightOrder(ctx, "local-admin", orders[0].ID)
+ if err != nil || len(detail.Items) != 2 {
+ t.Fatalf("detail = %+v, error = %v", detail, err)
+ }
+
+ second := freightRun(904, userID, now.Add(time.Minute))
+ createAndStartFreightRun(t, store, second, "freight-key-2", "3")
+ unchanged := freightBatch(920, "a", "b")
+ if err := store.CompleteFreightSync(
+ ctx,
+ second,
+ unchanged,
+ now.Add(time.Minute+time.Second),
+ ); err != nil {
+ t.Fatalf("unchanged import error = %v", err)
+ }
+ detail, _ = store.GetFreightOrder(ctx, "local-admin", orders[0].ID)
+ if detail.Order.Revision != 1 ||
+ detail.Items[0].Revision != 1 ||
+ detail.Items[1].Revision != 1 {
+ t.Fatalf("unchanged revisions = %+v", detail)
+ }
+
+ third := freightRun(905, userID, now.Add(2*time.Minute))
+ createAndStartFreightRun(t, store, third, "freight-key-3", "4")
+ changed := freightBatch(930, "a", "c")
+ changed.Orders[0].CanonicalSHA256 = repeatHex("d")
+ changed.Orders[0].Items[1].Title = "变化后的商品"
+ changed.Orders[0].Items[1].CanonicalSHA256 = repeatHex("e")
+ if err := store.CompleteFreightSync(
+ ctx,
+ third,
+ changed,
+ now.Add(2*time.Minute+time.Second),
+ ); err != nil {
+ t.Fatalf("changed import error = %v", err)
+ }
+ detail, _ = store.GetFreightOrder(ctx, "local-admin", orders[0].ID)
+ if detail.Order.Revision != 2 ||
+ detail.Items[0].Revision != 1 ||
+ detail.Items[1].Revision != 2 {
+ t.Fatalf("changed revisions = %+v", detail)
+ }
+ runner, err := migration.New(db)
+ if err != nil {
+ t.Fatalf("migration.New() error = %v", err)
+ }
+ if err := runner.Down(ctx); err == nil {
+ t.Fatal("freight migration down succeeded with retained source data")
+ }
+ var retained int
+ if err := db.QueryRow(`SELECT count(*) FROM freight_orders`).Scan(
+ &retained,
+ ); err != nil || retained != 1 {
+ t.Fatalf("retained freight orders = %d, error = %v", retained, err)
+ }
+}
+
+func TestFreightSyncRecoveryAndFailedBatchDoNotPersistOrders(t *testing.T) {
+ db := openDatabase(t)
+ store, _ := repository.New(db)
+ ctx := context.Background()
+ now := time.Date(2026, 7, 28, 2, 3, 4, 0, time.UTC)
+ userID := uuid(940)
+ seedFreightUser(t, db, userID, now)
+ run := freightRun(941, userID, now)
+ createAndStartFreightRun(t, store, run, "freight-recovery", "5")
+ count, err := store.RecoverFreightSyncs(ctx, now.Add(time.Minute))
+ if err != nil || count != 1 {
+ t.Fatalf("RecoverFreightSyncs() = %d, %v", count, err)
+ }
+ stored, err := store.GetFreightSync(ctx, "local-admin", run.ID)
+ if err != nil || stored.Status != domain.FreightSyncFailed ||
+ stored.ErrorCode == nil || *stored.ErrorCode != "PROCESS_INTERRUPTED" {
+ t.Fatalf("recovered run = %+v, error = %v", stored, err)
+ }
+ if err := store.CompleteFreightSync(
+ ctx,
+ run,
+ freightBatch(950, "a", "b"),
+ now.Add(2*time.Minute),
+ ); !errors.Is(err, usecase.ErrTaskStateConflict) {
+ t.Fatalf("late completion error = %v", err)
+ }
+ orders, err := store.ListFreightOrders(ctx, "local-admin", 10)
+ if err != nil || len(orders) != 0 {
+ t.Fatalf("orders after failed batch = %+v, error = %v", orders, err)
+ }
+}
+
+func seedFreightUser(
+ t *testing.T,
+ db *sql.DB,
+ userID string,
+ now time.Time,
+) {
+ t.Helper()
+ _, err := db.Exec(
+ `INSERT INTO users (
+ id, username, password_hash, role, is_active, created_at, updated_at
+ ) VALUES (?, 'freight-admin', 'hash', 'ADMIN', 1, ?, ?)`,
+ userID,
+ now.Format(time.RFC3339Nano),
+ now.Format(time.RFC3339Nano),
+ )
+ if err != nil {
+ t.Fatalf("seed freight user: %v", err)
+ }
+}
+
+func freightRun(
+ index int,
+ userID string,
+ now time.Time,
+) domain.FreightSyncRun {
+ return domain.FreightSyncRun{
+ ID: uuid(index),
+ CreatorSubject: "local-admin",
+ CreatedByUserID: userID,
+ Mode: domain.FreightSyncOrderNumber,
+ OrderNumber: "SOURCE-12",
+ QuerySHA256: repeatHex("a"),
+ Status: domain.FreightSyncPending,
+ CreatedAt: now,
+ }
+}
+
+func freightBatch(index int, firstHash, secondHash string) domain.FreightImportBatch {
+ quantityOne := 1
+ quantityTwo := 2
+ return domain.FreightImportBatch{Orders: []domain.FreightImportOrder{{
+ ID: uuid(index),
+ ExternalStockID: "12",
+ SourceCode: "SOURCE-12",
+ CanonicalSHA256: repeatHex("c"),
+ Items: []domain.FreightImportItem{
+ {
+ ID: uuid(index + 1),
+ ExternalItemID: "88",
+ Title: "商品一",
+ ProductSpec: "黑色,L",
+ SKU: "BLACK-L",
+ Quantity: &quantityOne,
+ CanonicalSHA256: repeatHex(firstHash),
+ },
+ {
+ ID: uuid(index + 2),
+ ExternalItemID: "89",
+ Title: "商品二",
+ ProductSpec: "白色,M",
+ SKU: "WHITE-M",
+ Quantity: &quantityTwo,
+ CanonicalSHA256: repeatHex(secondHash),
+ },
+ },
+ }}}
+}
+
+func createAndStartFreightRun(
+ t *testing.T,
+ store *repository.Store,
+ run domain.FreightSyncRun,
+ key, hash string,
+) {
+ t.Helper()
+ if _, _, err := store.CreateFreightSync(
+ context.Background(),
+ run,
+ key,
+ repeatHex(hash),
+ ); err != nil {
+ t.Fatalf("CreateFreightSync() error = %v", err)
+ }
+ if err := store.StartFreightSync(
+ context.Background(),
+ run.ID,
+ run.CreatedAt.Add(time.Second),
+ ); err != nil {
+ t.Fatalf("StartFreightSync() error = %v", err)
+ }
+}
diff --git a/backend-api/internal/transport/httpapi/admin_handlers.go b/backend-api/internal/transport/httpapi/admin_handlers.go
index 80d3fac..e24fe4d 100644
--- a/backend-api/internal/transport/httpapi/admin_handlers.go
+++ b/backend-api/internal/transport/httpapi/admin_handlers.go
@@ -28,6 +28,7 @@ type AdminServices struct {
Tasks *usecase.TaskService
Results *usecase.ExecutionResultService
Authorizations *usecase.OrderAuthorizationService
+ Freight *usecase.FreightService
}
func (s AdminServices) validate() error {
@@ -61,6 +62,12 @@ func registerAdminAPI(routes gin.IRoutes, services AdminServices) error {
"/api/v1/tasks/:id/order-authorizations",
handler.createOrderAuthorization,
)
+ 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-orders", handler.listFreightOrders)
+ routes.GET("/api/v1/freight-orders/:id", handler.freightOrderDetail)
+ }
return nil
}
diff --git a/backend-api/internal/transport/httpapi/admin_handlers_test.go b/backend-api/internal/transport/httpapi/admin_handlers_test.go
index 9de21cf..443f484 100644
--- a/backend-api/internal/transport/httpapi/admin_handlers_test.go
+++ b/backend-api/internal/transport/httpapi/admin_handlers_test.go
@@ -294,6 +294,107 @@ func TestAdminAPIAssetAndTaskLifecycle(t *testing.T) {
}
}
+func TestAdminFreightAPIImportsAllItemsWithoutPII(t *testing.T) {
+ router := newAdminIntegrationRouter(t)
+ create := performAdminRequest(
+ t,
+ router,
+ http.MethodPost,
+ "/api/v1/freight-syncs",
+ "application/json",
+ strings.NewReader(
+ `{"mode":"ORDER_NUMBER","order_number":"SOURCE-12"}`,
+ ),
+ "freight-sync-1",
+ )
+ if create.Code != http.StatusAccepted {
+ t.Fatalf("create status/body = %d / %s", create.Code, create.Body)
+ }
+ var createBody struct {
+ Sync struct {
+ ID string `json:"id"`
+ } `json:"sync"`
+ }
+ decodeResponse(t, create, &createBody)
+ if createBody.Sync.ID == "" ||
+ strings.Contains(create.Body.String(), "SOURCE-12") {
+ t.Fatalf("create response exposes query or lacks ID: %s", create.Body)
+ }
+ var sync *httptest.ResponseRecorder
+ for attempt := 0; attempt < 50; attempt++ {
+ sync = performAdminRequest(
+ t,
+ router,
+ http.MethodGet,
+ "/api/v1/freight-syncs/"+createBody.Sync.ID,
+ "",
+ nil,
+ "",
+ )
+ if strings.Contains(sync.Body.String(), `"status":"SUCCEEDED"`) {
+ break
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ if sync == nil || sync.Code != http.StatusOK ||
+ !strings.Contains(sync.Body.String(), `"item_count":2`) {
+ t.Fatalf("sync status/body = %d / %s", sync.Code, sync.Body)
+ }
+ list := performAdminRequest(
+ t,
+ router,
+ http.MethodGet,
+ "/api/v1/freight-orders",
+ "",
+ nil,
+ "",
+ )
+ if list.Code != http.StatusOK ||
+ !strings.Contains(list.Body.String(), `"item_count":2`) ||
+ responseContainsKey(mustDecodeAny(t, list), "receiver") ||
+ responseContainsKey(mustDecodeAny(t, list), "receiverTel") ||
+ responseContainsKey(mustDecodeAny(t, list), "receiverAddr") {
+ t.Fatalf("freight list status/body = %d / %s", list.Code, list.Body)
+ }
+ var listBody struct {
+ Items []struct {
+ ID string `json:"id"`
+ } `json:"items"`
+ }
+ decodeResponse(t, list, &listBody)
+ detail := performAdminRequest(
+ t,
+ router,
+ http.MethodGet,
+ "/api/v1/freight-orders/"+listBody.Items[0].ID,
+ "",
+ nil,
+ "",
+ )
+ if detail.Code != http.StatusOK ||
+ !strings.Contains(detail.Body.String(), `"sku":"BLACK-L"`) ||
+ !strings.Contains(detail.Body.String(), `"sku":"WHITE-M"`) ||
+ detail.Header().Get("Cache-Control") != "no-store" {
+ t.Fatalf("freight detail status/body = %d / %s", detail.Code, detail.Body)
+ }
+ replay := performAdminRequest(
+ t,
+ router,
+ http.MethodPost,
+ "/api/v1/freight-syncs",
+ "application/json",
+ strings.NewReader(
+ `{"mode":"ORDER_NUMBER","order_number":"SOURCE-12"}`,
+ ),
+ "freight-sync-1",
+ )
+ if replay.Code != http.StatusAccepted ||
+ !strings.Contains(replay.Body.String(), `"replayed":true`) ||
+ !strings.Contains(replay.Body.String(), createBody.Sync.ID) {
+ t.Fatalf("replay status/body = %d / %s", replay.Code, replay.Body)
+ }
+}
+
func TestAdminOrderAuthorizationIsIdempotentAndRevisioned(t *testing.T) {
fixture := newAdminIntegrationFixture(t)
taskID, executionID, taskHash, firstKey, secondKey :=
@@ -414,6 +515,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("freight migration down: %v", err)
+ }
if err := runner.Down(context.Background()); err != nil {
t.Fatalf("order submission migration down: %v", err)
}
@@ -738,12 +842,23 @@ func newAdminIntegrationFixture(t *testing.T) *adminIntegrationFixture {
if err != nil {
t.Fatalf("usecase.NewOrderAuthorizationService() error = %v", err)
}
+ freight, err := usecase.NewFreightService(
+ repositories,
+ staticFreightSource{},
+ clock,
+ ids,
+ time.Second,
+ )
+ if err != nil {
+ t.Fatalf("usecase.NewFreightService() error = %v", err)
+ }
registrar, err := NewAdminRouteRegistrar(
AdminServices{
Assets: assets,
Tasks: tasks,
Results: results,
Authorizations: authorizations,
+ Freight: freight,
},
emptyAdminWeb{},
)
@@ -765,6 +880,56 @@ func newAdminIntegrationFixture(t *testing.T) *adminIntegrationFixture {
return &adminIntegrationFixture{router: router, db: db}
}
+type staticFreightSource struct{}
+
+func (staticFreightSource) QueryOrder(
+ context.Context,
+ string,
+) (domain.FreightSourceBatch, error) {
+ shop := "测试店铺"
+ created := "2026-07-28 08:00:00"
+ quantityOne := 1
+ quantityTwo := 2
+ return domain.FreightSourceBatch{
+ SchemaVersion: 1,
+ Query: domain.FreightSourceQuery{
+ Mode: domain.FreightSyncOrderNumber,
+ },
+ Orders: []domain.FreightSourceOrder{{
+ ExternalStockID: "12",
+ SourceCode: "SOURCE-12",
+ ShopName: &shop,
+ SourceCreatedAt: &created,
+ Items: []domain.FreightSourceItem{
+ {
+ ExternalItemID: "88",
+ Title: "商品一",
+ ProductSpec: "黑色,L",
+ SKU: "BLACK-L",
+ Quantity: &quantityOne,
+ },
+ {
+ ExternalItemID: "89",
+ Title: "商品二",
+ ProductSpec: "白色,M",
+ SKU: "WHITE-M",
+ Quantity: &quantityTwo,
+ },
+ },
+ }},
+ }, nil
+}
+
+func mustDecodeAny(
+ t *testing.T,
+ response *httptest.ResponseRecorder,
+) any {
+ t.Helper()
+ var decoded any
+ decodeResponse(t, response, &decoded)
+ return decoded
+}
+
func referenceUpload(t *testing.T, key string) (io.Reader, string) {
t.Helper()
var imageBytes bytes.Buffer
diff --git a/backend-api/internal/transport/httpapi/auth_handlers.go b/backend-api/internal/transport/httpapi/auth_handlers.go
index c5ab84f..6275a58 100644
--- a/backend-api/internal/transport/httpapi/auth_handlers.go
+++ b/backend-api/internal/transport/httpapi/auth_handlers.go
@@ -231,7 +231,9 @@ func denyAdminSession(ctx *gin.Context) {
next := ctx.Request.URL.RequestURI()
if next == "" ||
(next != "/tasks" && !strings.HasPrefix(next, "/tasks?") &&
- !strings.HasPrefix(next, "/tasks/")) {
+ !strings.HasPrefix(next, "/tasks/") &&
+ next != "/freight" && !strings.HasPrefix(next, "/freight?") &&
+ !strings.HasPrefix(next, "/freight/")) {
next = "/tasks"
}
ctx.Abort()
diff --git a/backend-api/internal/transport/httpapi/device_handlers_test.go b/backend-api/internal/transport/httpapi/device_handlers_test.go
index 2d5e631..69e9454 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("freight migration down: %v", err)
+ }
if err := runner.Down(context.Background()); err != nil {
t.Fatalf("order submission migration down: %v", err)
}
@@ -727,8 +730,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 != 3 {
- t.Fatalf("restored migrations = %d, want 3", applied)
+ } else if applied != 4 {
+ t.Fatalf("restored migrations = %d, want 4", applied)
}
completePayload := fmt.Sprintf(
@@ -1417,6 +1420,9 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable(
if err != nil {
t.Fatalf("migration.New() error = %v", err)
}
+ if err := runner.Down(context.Background()); err != nil {
+ t.Fatalf("freight migration down: %v", err)
+ }
if err := runner.Down(context.Background()); err == nil {
t.Fatal("order submission migration down succeeded with retained data")
}
diff --git a/backend-api/internal/transport/httpapi/freight_handlers.go b/backend-api/internal/transport/httpapi/freight_handlers.go
new file mode 100644
index 0000000..d0e3a8e
--- /dev/null
+++ b/backend-api/internal/transport/httpapi/freight_handlers.go
@@ -0,0 +1,188 @@
+package httpapi
+
+import (
+ "net/http"
+ "strconv"
+ "strings"
+
+ "cmroubao/backend-api/internal/domain"
+ "cmroubao/backend-api/internal/usecase"
+
+ "github.com/gin-gonic/gin"
+)
+
+func (h *adminHandlers) createFreightSync(ctx *gin.Context) {
+ if !hasMediaType(ctx, "application/json") {
+ writePublicError(
+ ctx,
+ http.StatusUnsupportedMediaType,
+ "UNSUPPORTED_MEDIA_TYPE",
+ "application/json is required",
+ false,
+ gin.H{},
+ )
+ return
+ }
+ var request struct {
+ Mode string `json:"mode"`
+ OrderNumber string `json:"order_number"`
+ }
+ if err := decodeJSON(ctx, &request); err != nil {
+ writePublicError(
+ ctx,
+ http.StatusBadRequest,
+ "INVALID_JSON",
+ "request body must be valid JSON",
+ false,
+ gin.H{},
+ )
+ return
+ }
+ if request.Mode != domain.FreightSyncOrderNumber {
+ writePublicError(
+ ctx,
+ http.StatusUnprocessableEntity,
+ "FREIGHT_SYNC_MODE_INVALID",
+ "freight sync mode is not supported",
+ false,
+ fieldDetails("mode", "must be ORDER_NUMBER"),
+ )
+ 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
+ }
+ ctx.Header("Cache-Control", "no-store")
+ ctx.JSON(http.StatusAccepted, gin.H{
+ "sync": freightSyncResponse(result.Run),
+ "replayed": result.Replayed,
+ })
+}
+
+func (h *adminHandlers) freightSyncDetail(ctx *gin.Context) {
+ run, err := h.services.Freight.GetSync(
+ ctx.Request.Context(),
+ localAdminSubject,
+ ctx.Param("id"),
+ )
+ if err != nil {
+ writeUsecaseError(ctx, err)
+ return
+ }
+ ctx.Header("Cache-Control", "no-store")
+ ctx.JSON(http.StatusOK, gin.H{"sync": freightSyncResponse(run)})
+}
+
+func (h *adminHandlers) listFreightOrders(ctx *gin.Context) {
+ limit := 0
+ if value := strings.TrimSpace(ctx.Query("limit")); value != "" {
+ parsed, err := strconv.Atoi(value)
+ if err != nil {
+ writePublicError(
+ ctx,
+ http.StatusBadRequest,
+ "FREIGHT_LIST_INVALID",
+ "freight list filter is invalid",
+ false,
+ fieldDetails("limit", "must be an integer"),
+ )
+ return
+ }
+ limit = parsed
+ }
+ orders, err := h.services.Freight.ListOrders(
+ ctx.Request.Context(),
+ localAdminSubject,
+ limit,
+ )
+ if err != nil {
+ writeUsecaseError(ctx, err)
+ return
+ }
+ items := make([]gin.H, 0, len(orders))
+ for _, order := range orders {
+ items = append(items, freightOrderResponse(order))
+ }
+ ctx.Header("Cache-Control", "no-store")
+ ctx.JSON(http.StatusOK, gin.H{
+ "items": items,
+ "next_cursor": nil,
+ })
+}
+
+func (h *adminHandlers) freightOrderDetail(ctx *gin.Context) {
+ detail, err := h.services.Freight.GetOrder(
+ ctx.Request.Context(),
+ localAdminSubject,
+ ctx.Param("id"),
+ )
+ if err != nil {
+ writeUsecaseError(ctx, err)
+ return
+ }
+ items := make([]gin.H, 0, len(detail.Items))
+ for _, item := range detail.Items {
+ items = append(items, gin.H{
+ "id": item.ID,
+ "external_item_id": item.ExternalItemID,
+ "title": item.Title,
+ "product_spec": item.ProductSpec,
+ "sku": item.SKU,
+ "quantity": item.Quantity,
+ "product_thumb_ref": item.ProductThumbRef,
+ "purchase_status": item.PurchaseStatus,
+ "revision": item.Revision,
+ "canonical_sha256": item.CanonicalSHA256,
+ "updated_at": formatTime(item.UpdatedAt),
+ })
+ }
+ ctx.Header("Cache-Control", "no-store")
+ ctx.JSON(http.StatusOK, gin.H{
+ "order": freightOrderResponse(detail.Order),
+ "items": items,
+ })
+}
+
+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),
+ }
+}
+
+func freightOrderResponse(order domain.FreightOrder) gin.H {
+ return gin.H{
+ "id": order.ID,
+ "source_system": order.SourceSystem,
+ "external_stock_id": order.ExternalStockID,
+ "source_code": order.SourceCode,
+ "platform_order_no": order.PlatformOrderNo,
+ "shop_name": order.ShopName,
+ "source_created_at": formatOptionalTime(order.SourceCreatedAt),
+ "order_status": order.OrderStatus,
+ "purchase_status": order.PurchaseStatus,
+ "is_canceled": order.IsCanceled,
+ "revision": order.Revision,
+ "canonical_sha256": order.CanonicalSHA256,
+ "item_count": order.ItemCount,
+ "updated_at": formatTime(order.UpdatedAt),
+ }
+}
diff --git a/backend-api/internal/transport/webui/auth_handler.go b/backend-api/internal/transport/webui/auth_handler.go
index eabbc9b..e509ca5 100644
--- a/backend-api/internal/transport/webui/auth_handler.go
+++ b/backend-api/internal/transport/webui/auth_handler.go
@@ -267,7 +267,9 @@ func safeNext(value string) string {
return "/tasks"
}
if parsed.Path != "/tasks" &&
- !strings.HasPrefix(parsed.Path, "/tasks/") {
+ !strings.HasPrefix(parsed.Path, "/tasks/") &&
+ parsed.Path != "/freight" &&
+ !strings.HasPrefix(parsed.Path, "/freight/") {
return "/tasks"
}
return parsed.String()
diff --git a/backend-api/internal/transport/webui/auth_handler_test.go b/backend-api/internal/transport/webui/auth_handler_test.go
index b75abed..d0f7793 100644
--- a/backend-api/internal/transport/webui/auth_handler_test.go
+++ b/backend-api/internal/transport/webui/auth_handler_test.go
@@ -294,6 +294,9 @@ func TestSafeNextRejectsExternalAndAmbiguousPaths(t *testing.T) {
if actual := safeNext("/tasks/item?id=1"); actual != "/tasks/item?id=1" {
t.Fatalf("safeNext(valid) = %q", actual)
}
+ if actual := safeNext("/freight/import"); actual != "/freight/import" {
+ t.Fatalf("safeNext(freight) = %q", actual)
+ }
}
func TestLogoutRequiresCSRFAndRevokesSession(t *testing.T) {
diff --git a/backend-api/internal/transport/webui/handler.go b/backend-api/internal/transport/webui/handler.go
index 059dcc6..e3c6c3c 100644
--- a/backend-api/internal/transport/webui/handler.go
+++ b/backend-api/internal/transport/webui/handler.go
@@ -71,6 +71,136 @@ func (h *Handler) RegisterProtected(routes gin.IRoutes) {
SecurityHeaders(),
h.AuthorizeOrder,
)
+ if _, ok := h.service.(FreightService); ok {
+ routes.GET("/freight", SecurityHeaders(), h.ListFreight)
+ routes.GET("/freight/import", SecurityHeaders(), h.ImportFreight)
+ routes.POST("/freight/import", SecurityHeaders(), h.CreateFreightImport)
+ routes.GET("/freight/:id", SecurityHeaders(), h.FreightDetail)
+ }
+}
+
+func (h *Handler) ListFreight(ctx *gin.Context) {
+ service := h.service.(FreightService)
+ orders, err := service.ListFreightOrders(
+ ctx.Request.Context(),
+ defaultListLimit,
+ )
+ if err != nil {
+ h.renderServiceError(ctx, err, "无法加载货运列表,请稍后重试。")
+ return
+ }
+ token, err := csrfToken(ctx)
+ if err != nil {
+ h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
+ return
+ }
+ h.render(ctx, http.StatusOK, "freight", freightPage{
+ Page: pageView{
+ Title: "ERP 货运",
+ FreightCurrent: true,
+ CSRFToken: token,
+ },
+ Orders: orders,
+ })
+}
+
+func (h *Handler) ImportFreight(ctx *gin.Context) {
+ service := h.service.(FreightService)
+ token, err := csrfToken(ctx)
+ if err != nil {
+ h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
+ return
+ }
+ key, err := newToken()
+ if err != nil {
+ h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
+ return
+ }
+ page := freightImportPage{
+ Page: pageView{
+ Title: "导入 ERP 货运",
+ FreightCurrent: true,
+ CSRFToken: token,
+ },
+ IdempotencyKey: key,
+ }
+ if syncID := strings.TrimSpace(ctx.Query("sync")); syncID != "" {
+ run, getErr := service.GetFreightSync(ctx.Request.Context(), syncID)
+ if getErr == nil {
+ page.Sync = &run
+ }
+ }
+ h.render(ctx, http.StatusOK, "freight-import", page)
+}
+
+func (h *Handler) CreateFreightImport(ctx *gin.Context) {
+ ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, 16<<10)
+ if err := ctx.Request.ParseForm(); err != nil || !validCSRF(ctx) {
+ h.renderError(ctx, http.StatusForbidden, "请求已失效", "请返回导入页面后重新提交。")
+ return
+ }
+ orderNumber := strings.TrimSpace(ctx.PostForm("order_number"))
+ key := strings.TrimSpace(ctx.PostForm("idempotency_key"))
+ if orderNumber == "" || len([]byte(orderNumber)) > 128 ||
+ !validToken(key) {
+ token, _ := csrfToken(ctx)
+ h.render(ctx, http.StatusUnprocessableEntity, "freight-import", freightImportPage{
+ Page: pageView{
+ Title: "导入 ERP 货运",
+ FreightCurrent: true,
+ CSRFToken: token,
+ },
+ OrderNumber: orderNumber,
+ IdempotencyKey: key,
+ Error: "请输入完整单号后重试。",
+ })
+ return
+ }
+ service := h.service.(FreightService)
+ run, err := service.CreateFreightSync(
+ ctx.Request.Context(),
+ CreateFreightSyncInput{
+ ActorUserID: actorUserID(ctx.Request.Context()),
+ IdempotencyKey: key,
+ OrderNumber: orderNumber,
+ },
+ )
+ if err != nil {
+ token, _ := csrfToken(ctx)
+ h.render(ctx, serviceErrorStatus(err), "freight-import", freightImportPage{
+ Page: pageView{
+ Title: "导入 ERP 货运",
+ FreightCurrent: true,
+ CSRFToken: token,
+ },
+ OrderNumber: orderNumber,
+ IdempotencyKey: key,
+ Error: "同步任务创建失败,请稍后使用相同提交标识重试。",
+ })
+ return
+ }
+ ctx.Redirect(http.StatusSeeOther, "/freight/import?sync="+pathEscape(run.ID))
+}
+
+func (h *Handler) FreightDetail(ctx *gin.Context) {
+ service := h.service.(FreightService)
+ detail, err := service.GetFreightOrder(
+ ctx.Request.Context(),
+ strings.TrimSpace(ctx.Param("id")),
+ )
+ if err != nil {
+ h.renderServiceError(ctx, err, "无法加载货运详情,请稍后重试。")
+ return
+ }
+ token, _ := csrfToken(ctx)
+ h.render(ctx, http.StatusOK, "freight-detail", freightDetailPage{
+ Page: pageView{
+ Title: "货运详情",
+ FreightCurrent: true,
+ CSRFToken: token,
+ },
+ Detail: detail,
+ })
}
func SecurityHeaders() gin.HandlerFunc {
@@ -739,10 +869,29 @@ func fallback(value string, fallbackValue string) string {
}
type pageView struct {
- Title string
- TasksCurrent bool
- NewCurrent bool
- CSRFToken string
+ Title string
+ TasksCurrent bool
+ NewCurrent bool
+ FreightCurrent bool
+ CSRFToken string
+}
+
+type freightPage struct {
+ Page pageView
+ Orders []FreightOrder
+}
+
+type freightImportPage struct {
+ Page pageView
+ OrderNumber string
+ IdempotencyKey string
+ Error string
+ Sync *FreightSync
+}
+
+type freightDetailPage struct {
+ Page pageView
+ Detail FreightOrderDetail
}
type statusOption struct {
diff --git a/backend-api/internal/transport/webui/handler_test.go b/backend-api/internal/transport/webui/handler_test.go
index 4f96ba5..f86a549 100644
--- a/backend-api/internal/transport/webui/handler_test.go
+++ b/backend-api/internal/transport/webui/handler_test.go
@@ -922,6 +922,78 @@ func TestRendererUsesMissingKeyErrors(t *testing.T) {
}
}
+func TestFreightPagesEscapeSourceDataAndCreateAsyncSync(t *testing.T) {
+ now := time.Date(2026, 7, 28, 3, 4, 5, 0, time.UTC)
+ service := &fakeFreightService{
+ fakeService: &fakeService{},
+ orders: []FreightOrder{{
+ ID: testTaskID,
+ ExternalStockID: "12",
+ SourceCode: ``,
+ ShopName: "测试店铺",
+ ItemCount: 2,
+ Revision: 1,
+ UpdatedAt: now,
+ }},
+ createResult: FreightSync{
+ ID: testTaskID,
+ Status: "PENDING",
+ CreatedAt: now,
+ },
+ }
+ router := newTestRouter(t, service)
+ list := performRequest(t, router, http.MethodGet, "/freight", nil, "")
+ if list.Code != http.StatusOK ||
+ strings.Contains(list.Body.String(), ``) ||
+ !strings.Contains(list.Body.String(), "<script>private") ||
+ !strings.Contains(list.Body.String(), "2 项") {
+ t.Fatalf("freight list status/body = %d / %s", list.Code, list.Body)
+ }
+ assertSecurityHeaders(t, list)
+
+ form := performRequest(
+ t,
+ router,
+ http.MethodGet,
+ "/freight/import",
+ nil,
+ "",
+ )
+ cookie := csrfCookie(t, form)
+ idempotencyKey := hiddenValue(
+ t,
+ form.Body.String(),
+ "idempotency_key",
+ )
+ values := url.Values{
+ "csrf_token": {cookie.Value},
+ "idempotency_key": {idempotencyKey},
+ "order_number": {"SOURCE-12"},
+ }
+ 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 ||
+ response.Header().Get("Location") !=
+ "/freight/import?sync="+testTaskID {
+ t.Fatalf(
+ "create sync status/location = %d / %q",
+ response.Code,
+ response.Header().Get("Location"),
+ )
+ }
+ if service.createInput.OrderNumber != "SOURCE-12" ||
+ service.createInput.IdempotencyKey != idempotencyKey {
+ t.Fatalf("create input = %+v", service.createInput)
+ }
+}
+
type fakeService struct {
listInput ListTasksInput
listResult TaskList
@@ -945,6 +1017,45 @@ type fakeService struct {
authorizeInput AuthorizeOrderInput
}
+type fakeFreightService struct {
+ *fakeService
+ orders []FreightOrder
+ orderDetail FreightOrderDetail
+ sync FreightSync
+ createInput CreateFreightSyncInput
+ createResult FreightSync
+ err error
+}
+
+func (service *fakeFreightService) ListFreightOrders(
+ context.Context,
+ int,
+) ([]FreightOrder, error) {
+ return service.orders, service.err
+}
+
+func (service *fakeFreightService) GetFreightOrder(
+ context.Context,
+ string,
+) (FreightOrderDetail, error) {
+ return service.orderDetail, service.err
+}
+
+func (service *fakeFreightService) GetFreightSync(
+ context.Context,
+ string,
+) (FreightSync, error) {
+ return service.sync, service.err
+}
+
+func (service *fakeFreightService) CreateFreightSync(
+ _ context.Context,
+ input CreateFreightSyncInput,
+) (FreightSync, error) {
+ service.createInput = input
+ return service.createResult, service.err
+}
+
func (service *fakeService) ListTasks(
_ context.Context,
input ListTasksInput,
diff --git a/backend-api/internal/transport/webui/static/admin.css b/backend-api/internal/transport/webui/static/admin.css
index 279dfc2..dc78496 100644
--- a/backend-api/internal/transport/webui/static/admin.css
+++ b/backend-api/internal/transport/webui/static/admin.css
@@ -673,6 +673,54 @@ tbody tr:last-child td {
background: var(--danger-soft);
}
+.notice.danger {
+ border-color: var(--danger);
+ color: var(--danger-dark);
+ background: var(--danger-soft);
+}
+
+.form-panel {
+ display: grid;
+ gap: 18px;
+ padding: 22px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: var(--surface);
+}
+
+.detail-section {
+ margin-bottom: 22px;
+}
+
+.detail-grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 1px;
+ margin: 0;
+ overflow: hidden;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: var(--line);
+}
+
+.detail-grid > div {
+ min-width: 0;
+ padding: 12px;
+ background: var(--surface);
+}
+
+.detail-grid dt {
+ color: var(--muted);
+ font-size: 12px;
+ font-weight: 700;
+}
+
+.detail-grid dd {
+ margin: 4px 0 0;
+ overflow-wrap: anywhere;
+ font-weight: 700;
+}
+
.task-form {
display: grid;
gap: 18px;
@@ -1139,6 +1187,10 @@ tbody tr:last-child td {
grid-template-columns: 1fr;
}
+ .detail-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
.authorization-note {
grid-column: auto;
}
@@ -1154,11 +1206,7 @@ tbody tr:last-child td {
padding-inline: 10px;
}
- .brand {
- font-size: 14px;
- }
-
- .brand-mark {
+ .brand span:not(.brand-mark) {
display: none;
}
@@ -1167,13 +1215,13 @@ tbody tr:last-child td {
}
.main-nav a {
- padding-inline: 8px;
- font-size: 13px;
+ padding-inline: 6px;
+ font-size: 12px;
}
.logout-button {
- padding-inline: 8px;
- font-size: 13px;
+ padding-inline: 6px;
+ font-size: 12px;
}
.title-row,
@@ -1186,6 +1234,10 @@ tbody tr:last-child td {
width: 100%;
}
+ .detail-grid {
+ grid-template-columns: 1fr;
+ }
+
.title-actions {
justify-content: flex-start;
flex-wrap: wrap;
diff --git a/backend-api/internal/transport/webui/templates/freight-detail.gohtml b/backend-api/internal/transport/webui/templates/freight-detail.gohtml
new file mode 100644
index 0000000..4de258d
--- /dev/null
+++ b/backend-api/internal/transport/webui/templates/freight-detail.gohtml
@@ -0,0 +1,66 @@
+{{define "freight-detail"}}
+
+
+
+ {{.Page.Title}} - 采购任务管理
+ {{template "document-head" .}}
+
+
+ {{template "site-header" .}}
+
+
+
+
{{if .Detail.Order.SourceCode}}{{.Detail.Order.SourceCode}}{{else}}货运详情{{end}}
+
ERP ID:{{.Detail.Order.ExternalStockID}} · 来源版本 {{.Detail.Order.Revision}}
+
+
返回列表
+
+
+ 来源信息
+
+ - 店铺
- {{if .Detail.Order.ShopName}}{{.Detail.Order.ShopName}}{{else}}未提供{{end}}
+ - ERP 创建时间
- {{displayTime .Detail.Order.SourceCreatedAt}}
+ - 订单状态
- {{if .Detail.Order.OrderStatus}}{{.Detail.Order.OrderStatus}}{{else}}未提供{{end}}
+ - 采购状态
- {{if .Detail.Order.PurchaseStatus}}{{.Detail.Order.PurchaseStatus}}{{else}}未提供{{end}}
+
+
+
+ 商品明细
+ {{if .Detail.Items}}
+
+
+
+ | 商品 |
+ 规格 / SKU |
+ 数量 |
+ 采购状态 |
+ 来源版本 |
+
+
+
+ {{range .Detail.Items}}
+
+ |
+ {{if .Title}}{{.Title}}{{else}}缺少标题{{end}}
+ 明细 ID:{{.ExternalItemID}}
+ {{if .ProductThumbRef}}图片引用:{{.ProductThumbRef}}{{end}}
+ |
+
+ {{if .ProductSpec}}{{.ProductSpec}}{{else}}未提供规格{{end}}
+ SKU:{{if .SKU}}{{.SKU}}{{else}}未提供{{end}}
+ |
+ {{if .Quantity}}{{.Quantity}}{{else}}未提供{{end}} |
+ {{if .PurchaseStatus}}{{.PurchaseStatus}}{{else}}未提供{{end}} |
+ {{.Revision}} |
+
+ {{end}}
+
+
+ {{else}}
+ 该货运单没有商品明细
+ {{end}}
+
+
+
+
+{{end}}
diff --git a/backend-api/internal/transport/webui/templates/freight-import.gohtml b/backend-api/internal/transport/webui/templates/freight-import.gohtml
new file mode 100644
index 0000000..b25485e
--- /dev/null
+++ b/backend-api/internal/transport/webui/templates/freight-import.gohtml
@@ -0,0 +1,46 @@
+{{define "freight-import"}}
+
+
+
+ {{.Page.Title}} - 采购任务管理
+ {{template "document-head" .}}
+
+
+ {{template "site-header" .}}
+
+
+
+
导入 ERP 货运
+
使用 ERP 页面“全部单号”中的完整单号
+
+
返回列表
+
+ {{if .Error}}{{.Error}}
{{end}}
+ {{if .Sync}}
+
+ 同步状态
+
+ - 状态
- {{.Sync.Status}}
+ - 货运单
- {{.Sync.OrderCount}}
+ - 商品明细
- {{.Sync.ItemCount}}
+ - 错误码
- {{if .Sync.ErrorCode}}{{.Sync.ErrorCode}}{{else}}无{{end}}
+
+ {{if or (eq .Sync.Status "PENDING") (eq .Sync.Status "RUNNING")}}
+ 同步仍在后台执行,刷新本页查看结果。
+ {{end}}
+
+ {{end}}
+
+
+
+
+{{end}}
diff --git a/backend-api/internal/transport/webui/templates/freight.gohtml b/backend-api/internal/transport/webui/templates/freight.gohtml
new file mode 100644
index 0000000..7882cc7
--- /dev/null
+++ b/backend-api/internal/transport/webui/templates/freight.gohtml
@@ -0,0 +1,62 @@
+{{define "freight"}}
+
+
+
+ {{.Page.Title}} - 采购任务管理
+ {{template "document-head" .}}
+
+
+ {{template "site-header" .}}
+
+
+
+
ERP 货运
+
核对已同步的货运单和全部商品明细
+
+
导入货运单
+
+
+ 货运单列表
+ {{if .Orders}}
+
+
+
+ | 来源单号 |
+ 店铺 |
+ 状态 |
+ 商品 |
+ 更新时间 |
+ 操作 |
+
+
+
+ {{range .Orders}}
+
+ |
+ {{if .SourceCode}}{{.SourceCode}}{{else}}{{.ExternalStockID}}{{end}}
+ ERP ID:{{.ExternalStockID}}
+ |
+ {{if .ShopName}}{{.ShopName}}{{else}}未提供{{end}} |
+
+ 订单:{{if .OrderStatus}}{{.OrderStatus}}{{else}}未提供{{end}}
+ 采购:{{if .PurchaseStatus}}{{.PurchaseStatus}}{{else}}未提供{{end}}
+ |
+ {{.ItemCount}} 项 |
+ |
+ 查看详情 |
+
+ {{end}}
+
+
+ {{else}}
+
+
尚未导入货运单
+
按完整单号创建第一条 ERP 同步任务。
+
导入货运单
+
+ {{end}}
+
+
+
+
+{{end}}
diff --git a/backend-api/internal/transport/webui/templates/partials.gohtml b/backend-api/internal/transport/webui/templates/partials.gohtml
index 707ecc9..c9ca760 100644
--- a/backend-api/internal/transport/webui/templates/partials.gohtml
+++ b/backend-api/internal/transport/webui/templates/partials.gohtml
@@ -17,6 +17,7 @@
{{if .Page.CSRFToken}}