feat(t222): add freight ingestion admin flow
This commit is contained in:
@@ -14,6 +14,7 @@ import (
|
|||||||
"cmroubao/backend-api/internal/config"
|
"cmroubao/backend-api/internal/config"
|
||||||
"cmroubao/backend-api/internal/platform/assetstore"
|
"cmroubao/backend-api/internal/platform/assetstore"
|
||||||
"cmroubao/backend-api/internal/platform/database"
|
"cmroubao/backend-api/internal/platform/database"
|
||||||
|
"cmroubao/backend-api/internal/platform/erpconnector"
|
||||||
"cmroubao/backend-api/internal/platform/migration"
|
"cmroubao/backend-api/internal/platform/migration"
|
||||||
"cmroubao/backend-api/internal/platform/password"
|
"cmroubao/backend-api/internal/platform/password"
|
||||||
repository "cmroubao/backend-api/internal/repository/sqlite"
|
repository "cmroubao/backend-api/internal/repository/sqlite"
|
||||||
@@ -217,6 +218,31 @@ func buildRouter(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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)
|
passwords, err := password.NewBcrypt(12)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -249,6 +275,7 @@ func buildRouter(
|
|||||||
tasks,
|
tasks,
|
||||||
assets,
|
assets,
|
||||||
authorizations,
|
authorizations,
|
||||||
|
freight,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -294,6 +321,7 @@ func buildRouter(
|
|||||||
Tasks: tasks,
|
Tasks: tasks,
|
||||||
Results: results,
|
Results: results,
|
||||||
Authorizations: authorizations,
|
Authorizations: authorizations,
|
||||||
|
Freight: freight,
|
||||||
},
|
},
|
||||||
webHandler,
|
webHandler,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -158,6 +159,36 @@ func TestBuildRouterRegistersProtectedLogoutRoute(t *testing.T) {
|
|||||||
response.Header().Get("Location"),
|
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 {
|
type stubMigrationStatusReader struct {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
|
"net/url"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -18,6 +19,8 @@ const (
|
|||||||
ClaimLeaseEnvironment = "CMROUBAO_CLAIM_LEASE"
|
ClaimLeaseEnvironment = "CMROUBAO_CLAIM_LEASE"
|
||||||
RunningLeaseEnvironment = "CMROUBAO_RUNNING_LEASE"
|
RunningLeaseEnvironment = "CMROUBAO_RUNNING_LEASE"
|
||||||
ReadinessTTLEnvironment = "CMROUBAO_READINESS_TTL"
|
ReadinessTTLEnvironment = "CMROUBAO_READINESS_TTL"
|
||||||
|
ERPConnectorURLEnvironment = "CMROUBAO_ERP_CONNECTOR_URL"
|
||||||
|
ERPConnectorAPIKeyEnvironment = "CMROUBAO_ERP_CONNECTOR_API_KEY"
|
||||||
|
|
||||||
defaultHTTPAddress = "127.0.0.1:8080"
|
defaultHTTPAddress = "127.0.0.1:8080"
|
||||||
defaultDatabasePath = "var/cmroubao.db"
|
defaultDatabasePath = "var/cmroubao.db"
|
||||||
@@ -25,6 +28,7 @@ const (
|
|||||||
defaultClaimLease = 10 * time.Minute
|
defaultClaimLease = 10 * time.Minute
|
||||||
defaultRunningLease = 30 * time.Minute
|
defaultRunningLease = 30 * time.Minute
|
||||||
defaultReadinessTTL = 2 * time.Minute
|
defaultReadinessTTL = 2 * time.Minute
|
||||||
|
defaultERPConnectorURL = "http://127.0.0.1:8091"
|
||||||
)
|
)
|
||||||
|
|
||||||
type LookupEnvironment func(string) (string, bool)
|
type LookupEnvironment func(string) (string, bool)
|
||||||
@@ -44,6 +48,9 @@ type Config struct {
|
|||||||
ClaimLease time.Duration
|
ClaimLease time.Duration
|
||||||
RunningLease time.Duration
|
RunningLease time.Duration
|
||||||
ReadinessTTL time.Duration
|
ReadinessTTL time.Duration
|
||||||
|
ERPConnectorURL string
|
||||||
|
ERPConnectorAPIKey string
|
||||||
|
ERPConnectorTimeout time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load(lookup LookupEnvironment) (Config, error) {
|
func Load(lookup LookupEnvironment) (Config, error) {
|
||||||
@@ -137,6 +144,27 @@ func Load(lookup LookupEnvironment) (Config, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Config{}, err
|
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{
|
return Config{
|
||||||
HTTPAddress: httpAddress,
|
HTTPAddress: httpAddress,
|
||||||
@@ -153,9 +181,39 @@ func Load(lookup LookupEnvironment) (Config, error) {
|
|||||||
ClaimLease: claimLease,
|
ClaimLease: claimLease,
|
||||||
RunningLease: runningLease,
|
RunningLease: runningLease,
|
||||||
ReadinessTTL: readinessTTL,
|
ReadinessTTL: readinessTTL,
|
||||||
|
ERPConnectorURL: strings.TrimRight(erpConnectorURL, "/"),
|
||||||
|
ERPConnectorAPIKey: erpConnectorAPIKey,
|
||||||
|
ERPConnectorTimeout: 90 * time.Second,
|
||||||
}, nil
|
}, 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(
|
func durationEnvironment(
|
||||||
lookup LookupEnvironment,
|
lookup LookupEnvironment,
|
||||||
name string,
|
name string,
|
||||||
|
|||||||
@@ -42,6 +42,16 @@ func TestLoadUsesSafeDefaults(t *testing.T) {
|
|||||||
if cfg.ShutdownTimeout > 30*time.Second {
|
if cfg.ShutdownTimeout > 30*time.Second {
|
||||||
t.Fatalf("ShutdownTimeout = %s", cfg.ShutdownTimeout)
|
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) {
|
func TestLoadAcceptsExplicitConfiguration(t *testing.T) {
|
||||||
@@ -54,6 +64,8 @@ func TestLoadAcceptsExplicitConfiguration(t *testing.T) {
|
|||||||
ClaimLeaseEnvironment: "15m",
|
ClaimLeaseEnvironment: "15m",
|
||||||
RunningLeaseEnvironment: "45m",
|
RunningLeaseEnvironment: "45m",
|
||||||
ReadinessTTLEnvironment: "3m",
|
ReadinessTTLEnvironment: "3m",
|
||||||
|
ERPConnectorURLEnvironment: "http://localhost:18091",
|
||||||
|
ERPConnectorAPIKeyEnvironment: "12345678901234567890123456789012",
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := Load(mapEnvironment(values))
|
cfg, err := Load(mapEnvironment(values))
|
||||||
@@ -88,6 +100,14 @@ func TestLoadAcceptsExplicitConfiguration(t *testing.T) {
|
|||||||
cfg.ReadinessTTL,
|
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) {
|
func TestLoadRejectsUnsafeOrInvalidValues(t *testing.T) {
|
||||||
@@ -95,6 +115,24 @@ func TestLoadRejectsUnsafeOrInvalidValues(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
values map[string]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",
|
name: "blank explicit address",
|
||||||
values: map[string]string{
|
values: map[string]string{
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,8 +34,11 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
|
|||||||
|
|
||||||
if applied, err := runner.Up(ctx); err != nil {
|
if applied, err := runner.Up(ctx); err != nil {
|
||||||
t.Fatalf("initial Up() error = %v", err)
|
t.Fatalf("initial Up() error = %v", err)
|
||||||
} else if applied != 11 {
|
} else if applied != 12 {
|
||||||
t.Fatalf("initial Up() applied = %d, want 11", applied)
|
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 {
|
if err := runner.Down(ctx); err != nil {
|
||||||
t.Fatalf("initial Down(v11) error = %v", err)
|
t.Fatalf("initial Down(v11) error = %v", err)
|
||||||
@@ -62,9 +65,14 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
|
|||||||
seedClaimsHistoricalFixture(t, db)
|
seedClaimsHistoricalFixture(t, db)
|
||||||
|
|
||||||
if applied, err := runner.Up(ctx); err != nil {
|
if applied, err := runner.Up(ctx); err != nil {
|
||||||
t.Fatalf("Up(v5-v11) over historical data error = %v", err)
|
t.Fatalf("Up(v5-v12) over historical data error = %v", err)
|
||||||
} else if applied != 7 {
|
} else if applied != 8 {
|
||||||
t.Fatalf("Up(v5-v11) applied = %d, want 7", applied)
|
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)
|
assertClaimsHistory(t, db, true)
|
||||||
|
|
||||||
@@ -109,9 +117,9 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
|
|||||||
assertClaimsHistory(t, db, false)
|
assertClaimsHistory(t, db, false)
|
||||||
|
|
||||||
if applied, err := runner.Up(ctx); err != nil {
|
if applied, err := runner.Up(ctx); err != nil {
|
||||||
t.Fatalf("final Up(v4-v11) error = %v", err)
|
t.Fatalf("final Up(v4-v12) error = %v", err)
|
||||||
} else if applied != 8 {
|
} else if applied != 9 {
|
||||||
t.Fatalf("final Up(v4-v11) applied = %d, want 8", applied)
|
t.Fatalf("final Up(v4-v12) applied = %d, want 9", applied)
|
||||||
}
|
}
|
||||||
assertClaimsHistory(t, db, true)
|
assertClaimsHistory(t, db, true)
|
||||||
}
|
}
|
||||||
@@ -347,6 +355,9 @@ func TestClaimsMigrationDownFailsClosedForNewAuditData(t *testing.T) {
|
|||||||
t.Fatalf("insert v4 audit event: %v", err)
|
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 {
|
if err := runner.Down(ctx); err != nil {
|
||||||
t.Fatalf("Down(v11) error = %v", err)
|
t.Fatalf("Down(v11) error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Up() error = %v", err)
|
t.Fatalf("Up() error = %v", err)
|
||||||
}
|
}
|
||||||
if applied != 11 {
|
if applied != 12 {
|
||||||
t.Fatalf("Up() applied = %d, want 11", applied)
|
t.Fatalf("Up() applied = %d, want 12", applied)
|
||||||
}
|
}
|
||||||
assertStatuses(t, runner, map[int64]bool{
|
assertStatuses(t, runner, map[int64]bool{
|
||||||
1: true,
|
1: true,
|
||||||
@@ -42,6 +42,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
|||||||
9: true,
|
9: true,
|
||||||
10: true,
|
10: true,
|
||||||
11: true,
|
11: true,
|
||||||
|
12: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
applied, err = runner.Up(context.Background())
|
applied, err = runner.Up(context.Background())
|
||||||
@@ -66,7 +67,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
|||||||
8: true,
|
8: true,
|
||||||
9: true,
|
9: true,
|
||||||
10: true,
|
10: true,
|
||||||
11: false,
|
11: true,
|
||||||
|
12: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
applied, err = runner.Up(context.Background())
|
applied, err = runner.Up(context.Background())
|
||||||
@@ -88,6 +90,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
|||||||
9: true,
|
9: true,
|
||||||
10: true,
|
10: true,
|
||||||
11: true,
|
11: true,
|
||||||
|
12: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -383,6 +383,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("migration.New() error = %v", err)
|
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 {
|
if err := runner.Down(context.Background()); err != nil {
|
||||||
t.Fatalf("Down(v11) error = %v", err)
|
t.Fatalf("Down(v11) error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -423,9 +426,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks(
|
|||||||
t.Fatal("purchase_tasks was lost during auth migration rollback")
|
t.Fatal("purchase_tasks was lost during auth migration rollback")
|
||||||
}
|
}
|
||||||
if applied, err := runner.Up(context.Background()); err != nil {
|
if applied, err := runner.Up(context.Background()); err != nil {
|
||||||
t.Fatalf("Up(v3-v11) error = %v", err)
|
t.Fatalf("Up(v3-v12) error = %v", err)
|
||||||
} else if applied != 9 {
|
} else if applied != 10 {
|
||||||
t.Fatalf("Up(v3-v11) applied = %d, want 9", applied)
|
t.Fatalf("Up(v3-v12) applied = %d, want 10", applied)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ type AdminServices struct {
|
|||||||
Tasks *usecase.TaskService
|
Tasks *usecase.TaskService
|
||||||
Results *usecase.ExecutionResultService
|
Results *usecase.ExecutionResultService
|
||||||
Authorizations *usecase.OrderAuthorizationService
|
Authorizations *usecase.OrderAuthorizationService
|
||||||
|
Freight *usecase.FreightService
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s AdminServices) validate() error {
|
func (s AdminServices) validate() error {
|
||||||
@@ -61,6 +62,12 @@ func registerAdminAPI(routes gin.IRoutes, services AdminServices) error {
|
|||||||
"/api/v1/tasks/:id/order-authorizations",
|
"/api/v1/tasks/:id/order-authorizations",
|
||||||
handler.createOrderAuthorization,
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
func TestAdminOrderAuthorizationIsIdempotentAndRevisioned(t *testing.T) {
|
||||||
fixture := newAdminIntegrationFixture(t)
|
fixture := newAdminIntegrationFixture(t)
|
||||||
taskID, executionID, taskHash, firstKey, secondKey :=
|
taskID, executionID, taskHash, firstKey, secondKey :=
|
||||||
@@ -414,6 +515,9 @@ func TestAdminOrderAuthorizationIsIdempotentAndRevisioned(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("migration.New() error = %v", err)
|
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 {
|
if err := runner.Down(context.Background()); err != nil {
|
||||||
t.Fatalf("order submission migration down: %v", err)
|
t.Fatalf("order submission migration down: %v", err)
|
||||||
}
|
}
|
||||||
@@ -738,12 +842,23 @@ func newAdminIntegrationFixture(t *testing.T) *adminIntegrationFixture {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("usecase.NewOrderAuthorizationService() error = %v", err)
|
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(
|
registrar, err := NewAdminRouteRegistrar(
|
||||||
AdminServices{
|
AdminServices{
|
||||||
Assets: assets,
|
Assets: assets,
|
||||||
Tasks: tasks,
|
Tasks: tasks,
|
||||||
Results: results,
|
Results: results,
|
||||||
Authorizations: authorizations,
|
Authorizations: authorizations,
|
||||||
|
Freight: freight,
|
||||||
},
|
},
|
||||||
emptyAdminWeb{},
|
emptyAdminWeb{},
|
||||||
)
|
)
|
||||||
@@ -765,6 +880,56 @@ func newAdminIntegrationFixture(t *testing.T) *adminIntegrationFixture {
|
|||||||
return &adminIntegrationFixture{router: router, db: db}
|
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) {
|
func referenceUpload(t *testing.T, key string) (io.Reader, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var imageBytes bytes.Buffer
|
var imageBytes bytes.Buffer
|
||||||
|
|||||||
@@ -231,7 +231,9 @@ func denyAdminSession(ctx *gin.Context) {
|
|||||||
next := ctx.Request.URL.RequestURI()
|
next := ctx.Request.URL.RequestURI()
|
||||||
if next == "" ||
|
if next == "" ||
|
||||||
(next != "/tasks" && !strings.HasPrefix(next, "/tasks?") &&
|
(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"
|
next = "/tasks"
|
||||||
}
|
}
|
||||||
ctx.Abort()
|
ctx.Abort()
|
||||||
|
|||||||
@@ -713,6 +713,9 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("migration.New() after review error = %v", err)
|
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 {
|
if err := runner.Down(context.Background()); err != nil {
|
||||||
t.Fatalf("order submission migration down: %v", err)
|
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 {
|
if applied, err := runner.Up(context.Background()); err != nil {
|
||||||
t.Fatalf("restore device command migration: %v", err)
|
t.Fatalf("restore device command migration: %v", err)
|
||||||
} else if applied != 3 {
|
} else if applied != 4 {
|
||||||
t.Fatalf("restored migrations = %d, want 3", applied)
|
t.Fatalf("restored migrations = %d, want 4", applied)
|
||||||
}
|
}
|
||||||
|
|
||||||
completePayload := fmt.Sprintf(
|
completePayload := fmt.Sprintf(
|
||||||
@@ -1417,6 +1420,9 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("migration.New() error = %v", err)
|
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 {
|
if err := runner.Down(context.Background()); err == nil {
|
||||||
t.Fatal("order submission migration down succeeded with retained data")
|
t.Fatal("order submission migration down succeeded with retained data")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -267,7 +267,9 @@ func safeNext(value string) string {
|
|||||||
return "/tasks"
|
return "/tasks"
|
||||||
}
|
}
|
||||||
if parsed.Path != "/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 "/tasks"
|
||||||
}
|
}
|
||||||
return parsed.String()
|
return parsed.String()
|
||||||
|
|||||||
@@ -294,6 +294,9 @@ func TestSafeNextRejectsExternalAndAmbiguousPaths(t *testing.T) {
|
|||||||
if actual := safeNext("/tasks/item?id=1"); actual != "/tasks/item?id=1" {
|
if actual := safeNext("/tasks/item?id=1"); actual != "/tasks/item?id=1" {
|
||||||
t.Fatalf("safeNext(valid) = %q", actual)
|
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) {
|
func TestLogoutRequiresCSRFAndRevokesSession(t *testing.T) {
|
||||||
|
|||||||
@@ -71,6 +71,136 @@ func (h *Handler) RegisterProtected(routes gin.IRoutes) {
|
|||||||
SecurityHeaders(),
|
SecurityHeaders(),
|
||||||
h.AuthorizeOrder,
|
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 {
|
func SecurityHeaders() gin.HandlerFunc {
|
||||||
@@ -742,9 +872,28 @@ type pageView struct {
|
|||||||
Title string
|
Title string
|
||||||
TasksCurrent bool
|
TasksCurrent bool
|
||||||
NewCurrent bool
|
NewCurrent bool
|
||||||
|
FreightCurrent bool
|
||||||
CSRFToken string
|
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 {
|
type statusOption struct {
|
||||||
Value string
|
Value string
|
||||||
Label string
|
Label string
|
||||||
|
|||||||
@@ -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: `<script>private</script>`,
|
||||||
|
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(), `<script>private</script>`) ||
|
||||||
|
!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 {
|
type fakeService struct {
|
||||||
listInput ListTasksInput
|
listInput ListTasksInput
|
||||||
listResult TaskList
|
listResult TaskList
|
||||||
@@ -945,6 +1017,45 @@ type fakeService struct {
|
|||||||
authorizeInput AuthorizeOrderInput
|
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(
|
func (service *fakeService) ListTasks(
|
||||||
_ context.Context,
|
_ context.Context,
|
||||||
input ListTasksInput,
|
input ListTasksInput,
|
||||||
|
|||||||
@@ -673,6 +673,54 @@ tbody tr:last-child td {
|
|||||||
background: var(--danger-soft);
|
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 {
|
.task-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
@@ -1139,6 +1187,10 @@ tbody tr:last-child td {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
.authorization-note {
|
.authorization-note {
|
||||||
grid-column: auto;
|
grid-column: auto;
|
||||||
}
|
}
|
||||||
@@ -1154,11 +1206,7 @@ tbody tr:last-child td {
|
|||||||
padding-inline: 10px;
|
padding-inline: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand {
|
.brand span:not(.brand-mark) {
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-mark {
|
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1167,13 +1215,13 @@ tbody tr:last-child td {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.main-nav a {
|
.main-nav a {
|
||||||
padding-inline: 8px;
|
padding-inline: 6px;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logout-button {
|
.logout-button {
|
||||||
padding-inline: 8px;
|
padding-inline: 6px;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title-row,
|
.title-row,
|
||||||
@@ -1186,6 +1234,10 @@ tbody tr:last-child td {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.title-actions {
|
.title-actions {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
{{define "freight-detail"}}
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<title>{{.Page.Title}} - 采购任务管理</title>
|
||||||
|
{{template "document-head" .}}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "site-header" .}}
|
||||||
|
<main id="main-content" class="page">
|
||||||
|
<div class="title-row">
|
||||||
|
<div>
|
||||||
|
<h1>{{if .Detail.Order.SourceCode}}{{.Detail.Order.SourceCode}}{{else}}货运详情{{end}}</h1>
|
||||||
|
<p class="subtitle">ERP ID:{{.Detail.Order.ExternalStockID}} · 来源版本 {{.Detail.Order.Revision}}</p>
|
||||||
|
</div>
|
||||||
|
<a class="button" href="/freight">返回列表</a>
|
||||||
|
</div>
|
||||||
|
<section class="detail-section" aria-labelledby="freight-source-title">
|
||||||
|
<h2 id="freight-source-title">来源信息</h2>
|
||||||
|
<dl class="detail-grid">
|
||||||
|
<div><dt>店铺</dt><dd>{{if .Detail.Order.ShopName}}{{.Detail.Order.ShopName}}{{else}}未提供{{end}}</dd></div>
|
||||||
|
<div><dt>ERP 创建时间</dt><dd>{{displayTime .Detail.Order.SourceCreatedAt}}</dd></div>
|
||||||
|
<div><dt>订单状态</dt><dd>{{if .Detail.Order.OrderStatus}}{{.Detail.Order.OrderStatus}}{{else}}未提供{{end}}</dd></div>
|
||||||
|
<div><dt>采购状态</dt><dd>{{if .Detail.Order.PurchaseStatus}}{{.Detail.Order.PurchaseStatus}}{{else}}未提供{{end}}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
<section class="table-region" aria-labelledby="freight-items-title">
|
||||||
|
<h2 id="freight-items-title">商品明细</h2>
|
||||||
|
{{if .Detail.Items}}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">商品</th>
|
||||||
|
<th scope="col">规格 / SKU</th>
|
||||||
|
<th scope="col">数量</th>
|
||||||
|
<th scope="col">采购状态</th>
|
||||||
|
<th scope="col">来源版本</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Detail.Items}}
|
||||||
|
<tr>
|
||||||
|
<td data-label="商品">
|
||||||
|
<strong>{{if .Title}}{{.Title}}{{else}}缺少标题{{end}}</strong>
|
||||||
|
<span class="secondary">明细 ID:{{.ExternalItemID}}</span>
|
||||||
|
{{if .ProductThumbRef}}<span class="secondary">图片引用:{{.ProductThumbRef}}</span>{{end}}
|
||||||
|
</td>
|
||||||
|
<td data-label="规格 / SKU">
|
||||||
|
<span>{{if .ProductSpec}}{{.ProductSpec}}{{else}}未提供规格{{end}}</span>
|
||||||
|
<span class="secondary">SKU:{{if .SKU}}{{.SKU}}{{else}}未提供{{end}}</span>
|
||||||
|
</td>
|
||||||
|
<td data-label="数量">{{if .Quantity}}{{.Quantity}}{{else}}未提供{{end}}</td>
|
||||||
|
<td data-label="采购状态">{{if .PurchaseStatus}}{{.PurchaseStatus}}{{else}}未提供{{end}}</td>
|
||||||
|
<td data-label="来源版本">{{.Revision}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{else}}
|
||||||
|
<div class="empty-state"><h2>该货运单没有商品明细</h2></div>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{{define "freight-import"}}
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<title>{{.Page.Title}} - 采购任务管理</title>
|
||||||
|
{{template "document-head" .}}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "site-header" .}}
|
||||||
|
<main id="main-content" class="page narrow-page">
|
||||||
|
<div class="title-row">
|
||||||
|
<div>
|
||||||
|
<h1>导入 ERP 货运</h1>
|
||||||
|
<p class="subtitle">使用 ERP 页面“全部单号”中的完整单号</p>
|
||||||
|
</div>
|
||||||
|
<a class="button" href="/freight">返回列表</a>
|
||||||
|
</div>
|
||||||
|
{{if .Error}}<div class="notice danger" role="alert">{{.Error}}</div>{{end}}
|
||||||
|
{{if .Sync}}
|
||||||
|
<section class="detail-section" aria-labelledby="sync-result-title">
|
||||||
|
<h2 id="sync-result-title">同步状态</h2>
|
||||||
|
<dl class="detail-grid">
|
||||||
|
<div><dt>状态</dt><dd>{{.Sync.Status}}</dd></div>
|
||||||
|
<div><dt>货运单</dt><dd>{{.Sync.OrderCount}}</dd></div>
|
||||||
|
<div><dt>商品明细</dt><dd>{{.Sync.ItemCount}}</dd></div>
|
||||||
|
<div><dt>错误码</dt><dd>{{if .Sync.ErrorCode}}{{.Sync.ErrorCode}}{{else}}无{{end}}</dd></div>
|
||||||
|
</dl>
|
||||||
|
{{if or (eq .Sync.Status "PENDING") (eq .Sync.Status "RUNNING")}}
|
||||||
|
<p class="secondary">同步仍在后台执行,刷新本页查看结果。</p>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
{{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}}">
|
||||||
|
<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>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
{{define "freight"}}
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<title>{{.Page.Title}} - 采购任务管理</title>
|
||||||
|
{{template "document-head" .}}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "site-header" .}}
|
||||||
|
<main id="main-content" class="page">
|
||||||
|
<div class="title-row">
|
||||||
|
<div>
|
||||||
|
<h1>ERP 货运</h1>
|
||||||
|
<p class="subtitle">核对已同步的货运单和全部商品明细</p>
|
||||||
|
</div>
|
||||||
|
<a class="button primary" href="/freight/import">导入货运单</a>
|
||||||
|
</div>
|
||||||
|
<section class="table-region" aria-labelledby="freight-table-title">
|
||||||
|
<h2 id="freight-table-title" class="visually-hidden">货运单列表</h2>
|
||||||
|
{{if .Orders}}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">来源单号</th>
|
||||||
|
<th scope="col">店铺</th>
|
||||||
|
<th scope="col">状态</th>
|
||||||
|
<th scope="col">商品</th>
|
||||||
|
<th scope="col">更新时间</th>
|
||||||
|
<th scope="col"><span class="visually-hidden">操作</span></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Orders}}
|
||||||
|
<tr>
|
||||||
|
<td data-label="来源单号">
|
||||||
|
<strong>{{if .SourceCode}}{{.SourceCode}}{{else}}{{.ExternalStockID}}{{end}}</strong>
|
||||||
|
<span class="secondary">ERP ID:{{.ExternalStockID}}</span>
|
||||||
|
</td>
|
||||||
|
<td data-label="店铺">{{if .ShopName}}{{.ShopName}}{{else}}未提供{{end}}</td>
|
||||||
|
<td data-label="状态">
|
||||||
|
<span class="secondary">订单:{{if .OrderStatus}}{{.OrderStatus}}{{else}}未提供{{end}}</span>
|
||||||
|
<span class="secondary">采购:{{if .PurchaseStatus}}{{.PurchaseStatus}}{{else}}未提供{{end}}</span>
|
||||||
|
</td>
|
||||||
|
<td data-label="商品">{{.ItemCount}} 项</td>
|
||||||
|
<td data-label="更新时间"><time datetime="{{machineTime .UpdatedAt}}">{{displayTime .UpdatedAt}}</time></td>
|
||||||
|
<td data-label="操作"><a class="detail-link" href="/freight/{{pathPart .ID}}">查看详情</a></td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{else}}
|
||||||
|
<div class="empty-state">
|
||||||
|
<h2>尚未导入货运单</h2>
|
||||||
|
<p>按完整单号创建第一条 ERP 同步任务。</p>
|
||||||
|
<a class="button primary" href="/freight/import">导入货运单</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
<nav class="main-nav" aria-label="主导航">
|
<nav class="main-nav" aria-label="主导航">
|
||||||
<a href="/tasks" {{if .Page.TasksCurrent}}aria-current="page"{{end}}>任务列表</a>
|
<a href="/tasks" {{if .Page.TasksCurrent}}aria-current="page"{{end}}>任务列表</a>
|
||||||
<a href="/tasks/new" {{if .Page.NewCurrent}}aria-current="page"{{end}}>新建任务</a>
|
<a href="/tasks/new" {{if .Page.NewCurrent}}aria-current="page"{{end}}>新建任务</a>
|
||||||
|
<a href="/freight" {{if .Page.FreightCurrent}}aria-current="page"{{end}}>ERP 货运</a>
|
||||||
</nav>
|
</nav>
|
||||||
{{if .Page.CSRFToken}}
|
{{if .Page.CSRFToken}}
|
||||||
<form class="logout-form" method="post" action="/logout">
|
<form class="logout-form" method="post" action="/logout">
|
||||||
|
|||||||
@@ -27,6 +27,63 @@ type Service interface {
|
|||||||
AuthorizeOrder(context.Context, AuthorizeOrderInput) (OrderAuthorization, error)
|
AuthorizeOrder(context.Context, AuthorizeOrderInput) (OrderAuthorization, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FreightService interface {
|
||||||
|
ListFreightOrders(context.Context, int) ([]FreightOrder, error)
|
||||||
|
GetFreightOrder(context.Context, string) (FreightOrderDetail, error)
|
||||||
|
GetFreightSync(context.Context, string) (FreightSync, error)
|
||||||
|
CreateFreightSync(
|
||||||
|
context.Context,
|
||||||
|
CreateFreightSyncInput,
|
||||||
|
) (FreightSync, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type FreightSync struct {
|
||||||
|
ID string
|
||||||
|
Status string
|
||||||
|
ErrorCode string
|
||||||
|
OrderCount int
|
||||||
|
ItemCount int
|
||||||
|
CreatedAt time.Time
|
||||||
|
FinishedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateFreightSyncInput struct {
|
||||||
|
ActorUserID string
|
||||||
|
IdempotencyKey string
|
||||||
|
OrderNumber string
|
||||||
|
}
|
||||||
|
|
||||||
|
type FreightOrder struct {
|
||||||
|
ID string
|
||||||
|
ExternalStockID string
|
||||||
|
SourceCode string
|
||||||
|
ShopName string
|
||||||
|
SourceCreatedAt time.Time
|
||||||
|
OrderStatus string
|
||||||
|
PurchaseStatus string
|
||||||
|
IsCanceled bool
|
||||||
|
ItemCount int
|
||||||
|
Revision int
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type FreightOrderItem struct {
|
||||||
|
ID string
|
||||||
|
ExternalItemID string
|
||||||
|
Title string
|
||||||
|
ProductSpec string
|
||||||
|
SKU string
|
||||||
|
Quantity int
|
||||||
|
ProductThumbRef string
|
||||||
|
PurchaseStatus string
|
||||||
|
Revision int
|
||||||
|
}
|
||||||
|
|
||||||
|
type FreightOrderDetail struct {
|
||||||
|
Order FreightOrder
|
||||||
|
Items []FreightOrderItem
|
||||||
|
}
|
||||||
|
|
||||||
type ListTasksInput struct {
|
type ListTasksInput struct {
|
||||||
Query string
|
Query string
|
||||||
Status string
|
Status string
|
||||||
|
|||||||
@@ -17,23 +17,157 @@ type UsecaseAdapter struct {
|
|||||||
tasks *usecase.TaskService
|
tasks *usecase.TaskService
|
||||||
assets *usecase.AssetService
|
assets *usecase.AssetService
|
||||||
authorizations *usecase.OrderAuthorizationService
|
authorizations *usecase.OrderAuthorizationService
|
||||||
|
freight *usecase.FreightService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewUsecaseAdapter(
|
func NewUsecaseAdapter(
|
||||||
tasks *usecase.TaskService,
|
tasks *usecase.TaskService,
|
||||||
assets *usecase.AssetService,
|
assets *usecase.AssetService,
|
||||||
authorizations *usecase.OrderAuthorizationService,
|
authorizations *usecase.OrderAuthorizationService,
|
||||||
|
freight ...*usecase.FreightService,
|
||||||
) (*UsecaseAdapter, error) {
|
) (*UsecaseAdapter, error) {
|
||||||
if tasks == nil || assets == nil || authorizations == nil {
|
if tasks == nil || assets == nil || authorizations == nil {
|
||||||
return nil, errors.New("admin web use cases are required")
|
return nil, errors.New("admin web use cases are required")
|
||||||
}
|
}
|
||||||
return &UsecaseAdapter{
|
adapter := &UsecaseAdapter{
|
||||||
tasks: tasks,
|
tasks: tasks,
|
||||||
assets: assets,
|
assets: assets,
|
||||||
authorizations: authorizations,
|
authorizations: authorizations,
|
||||||
|
}
|
||||||
|
if len(freight) > 0 {
|
||||||
|
adapter.freight = freight[0]
|
||||||
|
}
|
||||||
|
return adapter, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (adapter *UsecaseAdapter) ListFreightOrders(
|
||||||
|
ctx context.Context,
|
||||||
|
limit int,
|
||||||
|
) ([]FreightOrder, error) {
|
||||||
|
if adapter.freight == nil {
|
||||||
|
return nil, ErrUnavailable
|
||||||
|
}
|
||||||
|
orders, err := adapter.freight.ListOrders(ctx, localAdminSubject, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapUsecaseError(err)
|
||||||
|
}
|
||||||
|
result := make([]FreightOrder, 0, len(orders))
|
||||||
|
for _, order := range orders {
|
||||||
|
result = append(result, freightOrderFrom(order))
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (adapter *UsecaseAdapter) GetFreightOrder(
|
||||||
|
ctx context.Context,
|
||||||
|
orderID string,
|
||||||
|
) (FreightOrderDetail, error) {
|
||||||
|
if adapter.freight == nil {
|
||||||
|
return FreightOrderDetail{}, ErrUnavailable
|
||||||
|
}
|
||||||
|
detail, err := adapter.freight.GetOrder(
|
||||||
|
ctx,
|
||||||
|
localAdminSubject,
|
||||||
|
orderID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return FreightOrderDetail{}, mapUsecaseError(err)
|
||||||
|
}
|
||||||
|
items := make([]FreightOrderItem, 0, len(detail.Items))
|
||||||
|
for _, item := range detail.Items {
|
||||||
|
view := FreightOrderItem{
|
||||||
|
ID: item.ID,
|
||||||
|
ExternalItemID: item.ExternalItemID,
|
||||||
|
Title: item.Title,
|
||||||
|
ProductSpec: item.ProductSpec,
|
||||||
|
SKU: item.SKU,
|
||||||
|
ProductThumbRef: stringValue(item.ProductThumbRef),
|
||||||
|
PurchaseStatus: stringValue(item.PurchaseStatus),
|
||||||
|
Revision: item.Revision,
|
||||||
|
}
|
||||||
|
if item.Quantity != nil {
|
||||||
|
view.Quantity = *item.Quantity
|
||||||
|
}
|
||||||
|
items = append(items, view)
|
||||||
|
}
|
||||||
|
return FreightOrderDetail{
|
||||||
|
Order: freightOrderFrom(detail.Order),
|
||||||
|
Items: items,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (adapter *UsecaseAdapter) GetFreightSync(
|
||||||
|
ctx context.Context,
|
||||||
|
syncID string,
|
||||||
|
) (FreightSync, error) {
|
||||||
|
if adapter.freight == nil {
|
||||||
|
return FreightSync{}, ErrUnavailable
|
||||||
|
}
|
||||||
|
run, err := adapter.freight.GetSync(ctx, localAdminSubject, syncID)
|
||||||
|
if err != nil {
|
||||||
|
return FreightSync{}, mapUsecaseError(err)
|
||||||
|
}
|
||||||
|
return freightSyncFrom(run), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (adapter *UsecaseAdapter) CreateFreightSync(
|
||||||
|
ctx context.Context,
|
||||||
|
input CreateFreightSyncInput,
|
||||||
|
) (FreightSync, error) {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return FreightSync{}, mapUsecaseError(err)
|
||||||
|
}
|
||||||
|
return freightSyncFrom(result.Run), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func freightOrderFrom(order domain.FreightOrder) FreightOrder {
|
||||||
|
result := FreightOrder{
|
||||||
|
ID: order.ID,
|
||||||
|
ExternalStockID: order.ExternalStockID,
|
||||||
|
SourceCode: order.SourceCode,
|
||||||
|
ShopName: stringValue(order.ShopName),
|
||||||
|
OrderStatus: stringValue(order.OrderStatus),
|
||||||
|
PurchaseStatus: stringValue(order.PurchaseStatus),
|
||||||
|
ItemCount: order.ItemCount,
|
||||||
|
Revision: order.Revision,
|
||||||
|
UpdatedAt: order.UpdatedAt,
|
||||||
|
}
|
||||||
|
if order.SourceCreatedAt != nil {
|
||||||
|
result.SourceCreatedAt = *order.SourceCreatedAt
|
||||||
|
}
|
||||||
|
if order.IsCanceled != nil {
|
||||||
|
result.IsCanceled = *order.IsCanceled
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
if run.FinishedAt != nil {
|
||||||
|
result.FinishedAt = *run.FinishedAt
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func (adapter *UsecaseAdapter) ListTasks(
|
func (adapter *UsecaseAdapter) ListTasks(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
input ListTasksInput,
|
input ListTasksInput,
|
||||||
@@ -525,3 +659,4 @@ func (err *adapterError) Unwrap() []error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var _ Service = (*UsecaseAdapter)(nil)
|
var _ Service = (*UsecaseAdapter)(nil)
|
||||||
|
var _ FreightService = (*UsecaseAdapter)(nil)
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package usecase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cmroubao/backend-api/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FreightSource interface {
|
||||||
|
QueryOrder(context.Context, string) (domain.FreightSourceBatch, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type FreightRepository interface {
|
||||||
|
CreateFreightSync(
|
||||||
|
context.Context,
|
||||||
|
domain.FreightSyncRun,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
) (domain.FreightSyncRun, bool, error)
|
||||||
|
StartFreightSync(context.Context, string, time.Time) error
|
||||||
|
CompleteFreightSync(
|
||||||
|
context.Context,
|
||||||
|
domain.FreightSyncRun,
|
||||||
|
domain.FreightImportBatch,
|
||||||
|
time.Time,
|
||||||
|
) error
|
||||||
|
FailFreightSync(context.Context, string, string, time.Time) error
|
||||||
|
RecoverFreightSyncs(context.Context, time.Time) (int64, error)
|
||||||
|
GetFreightSync(
|
||||||
|
context.Context,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
) (domain.FreightSyncRun, error)
|
||||||
|
ListFreightOrders(
|
||||||
|
context.Context,
|
||||||
|
string,
|
||||||
|
int,
|
||||||
|
) ([]domain.FreightOrder, error)
|
||||||
|
GetFreightOrder(
|
||||||
|
context.Context,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
) (domain.FreightOrderDetail, error)
|
||||||
|
}
|
||||||
@@ -0,0 +1,459 @@
|
|||||||
|
package usecase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"cmroubao/backend-api/internal/domain"
|
||||||
|
"cmroubao/backend-api/internal/platform/erpconnector"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxFreightOrdersPerSync = 100
|
||||||
|
maxFreightItemsPerOrder = 1000
|
||||||
|
)
|
||||||
|
|
||||||
|
type FreightService struct {
|
||||||
|
repository FreightRepository
|
||||||
|
source FreightSource
|
||||||
|
clock Clock
|
||||||
|
ids IDGenerator
|
||||||
|
timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateFreightSyncCommand struct {
|
||||||
|
CreatorSubject string
|
||||||
|
ActorUserID string
|
||||||
|
IdempotencyKey string
|
||||||
|
OrderNumber string
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateFreightSyncResult struct {
|
||||||
|
Run domain.FreightSyncRun
|
||||||
|
Replayed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFreightService(
|
||||||
|
repository FreightRepository,
|
||||||
|
source FreightSource,
|
||||||
|
clock Clock,
|
||||||
|
ids IDGenerator,
|
||||||
|
timeout time.Duration,
|
||||||
|
) (*FreightService, error) {
|
||||||
|
if repository == nil || source == nil || clock == nil || ids == nil ||
|
||||||
|
timeout <= 0 {
|
||||||
|
return nil, errors.New("freight service dependencies are required")
|
||||||
|
}
|
||||||
|
return &FreightService{
|
||||||
|
repository: repository,
|
||||||
|
source: source,
|
||||||
|
clock: clock,
|
||||||
|
ids: ids,
|
||||||
|
timeout: timeout,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *FreightService) CreateOrderSync(
|
||||||
|
ctx context.Context,
|
||||||
|
command CreateFreightSyncCommand,
|
||||||
|
) (CreateFreightSyncResult, error) {
|
||||||
|
command.CreatorSubject = strings.TrimSpace(command.CreatorSubject)
|
||||||
|
command.ActorUserID = strings.TrimSpace(command.ActorUserID)
|
||||||
|
command.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey)
|
||||||
|
command.OrderNumber = strings.TrimSpace(command.OrderNumber)
|
||||||
|
fields := map[string]string{}
|
||||||
|
if command.CreatorSubject == "" {
|
||||||
|
fields["creator_subject"] = "is required"
|
||||||
|
}
|
||||||
|
if command.ActorUserID == "" {
|
||||||
|
fields["actor_user_id"] = "is required"
|
||||||
|
}
|
||||||
|
if command.IdempotencyKey == "" || len([]byte(command.IdempotencyKey)) > 128 {
|
||||||
|
fields["idempotency_key"] = "must contain 1 to 128 bytes"
|
||||||
|
}
|
||||||
|
if command.OrderNumber == "" ||
|
||||||
|
len([]byte(command.OrderNumber)) > 128 ||
|
||||||
|
hasControl(command.OrderNumber) {
|
||||||
|
fields["order_number"] = "must contain 1 to 128 bytes without control characters"
|
||||||
|
}
|
||||||
|
if len(fields) > 0 {
|
||||||
|
return CreateFreightSyncResult{}, invalidError(
|
||||||
|
"FREIGHT_SYNC_INVALID",
|
||||||
|
"freight sync request is invalid",
|
||||||
|
fields,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
runID, err := service.ids.NewID()
|
||||||
|
if err != nil {
|
||||||
|
return CreateFreightSyncResult{}, wrapRepositoryError(err)
|
||||||
|
}
|
||||||
|
now := service.clock.Now().UTC()
|
||||||
|
queryHash := hashJSON(struct {
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
OrderNumber string `json:"order_number"`
|
||||||
|
}{domain.FreightSyncOrderNumber, command.OrderNumber})
|
||||||
|
requestHash := hashJSON(struct {
|
||||||
|
OrderNumber string `json:"order_number"`
|
||||||
|
}{command.OrderNumber})
|
||||||
|
run, created, err := service.repository.CreateFreightSync(
|
||||||
|
ctx,
|
||||||
|
domain.FreightSyncRun{
|
||||||
|
ID: runID,
|
||||||
|
CreatorSubject: command.CreatorSubject,
|
||||||
|
CreatedByUserID: command.ActorUserID,
|
||||||
|
Mode: domain.FreightSyncOrderNumber,
|
||||||
|
OrderNumber: command.OrderNumber,
|
||||||
|
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()
|
||||||
|
now := service.clock.Now().UTC()
|
||||||
|
if err := service.repository.StartFreightSync(ctx, run.ID, now); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
source, err := service.source.QueryOrder(ctx, run.OrderNumber)
|
||||||
|
if err != nil {
|
||||||
|
_ = service.repository.FailFreightSync(
|
||||||
|
ctx,
|
||||||
|
run.ID,
|
||||||
|
freightSourceErrorCode(err),
|
||||||
|
service.clock.Now().UTC(),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
batch, err := service.normalize(source)
|
||||||
|
if err != nil {
|
||||||
|
_ = service.repository.FailFreightSync(
|
||||||
|
ctx,
|
||||||
|
run.ID,
|
||||||
|
"ERP_RESPONSE_INVALID",
|
||||||
|
service.clock.Now().UTC(),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := service.repository.CompleteFreightSync(
|
||||||
|
ctx,
|
||||||
|
run,
|
||||||
|
batch,
|
||||||
|
service.clock.Now().UTC(),
|
||||||
|
); err != nil {
|
||||||
|
_ = service.repository.FailFreightSync(
|
||||||
|
ctx,
|
||||||
|
run.ID,
|
||||||
|
"STORAGE_UNAVAILABLE",
|
||||||
|
service.clock.Now().UTC(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *FreightService) RecoverInterrupted(
|
||||||
|
ctx context.Context,
|
||||||
|
) (int64, error) {
|
||||||
|
count, err := service.repository.RecoverFreightSyncs(
|
||||||
|
ctx,
|
||||||
|
service.clock.Now().UTC(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, wrapRepositoryError(err)
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *FreightService) GetSync(
|
||||||
|
ctx context.Context,
|
||||||
|
creatorSubject, syncID string,
|
||||||
|
) (domain.FreightSyncRun, error) {
|
||||||
|
run, err := service.repository.GetFreightSync(
|
||||||
|
ctx,
|
||||||
|
strings.TrimSpace(creatorSubject),
|
||||||
|
strings.TrimSpace(syncID),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.FreightSyncRun{}, wrapRepositoryError(err)
|
||||||
|
}
|
||||||
|
return run, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *FreightService) ListOrders(
|
||||||
|
ctx context.Context,
|
||||||
|
creatorSubject string,
|
||||||
|
limit int,
|
||||||
|
) ([]domain.FreightOrder, error) {
|
||||||
|
if limit == 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
if limit < 1 || limit > 100 {
|
||||||
|
return nil, invalidError(
|
||||||
|
"FREIGHT_LIST_INVALID",
|
||||||
|
"freight list filter is invalid",
|
||||||
|
map[string]string{"limit": "must be between 1 and 100"},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
orders, err := service.repository.ListFreightOrders(
|
||||||
|
ctx,
|
||||||
|
strings.TrimSpace(creatorSubject),
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, wrapRepositoryError(err)
|
||||||
|
}
|
||||||
|
return orders, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *FreightService) GetOrder(
|
||||||
|
ctx context.Context,
|
||||||
|
creatorSubject, orderID string,
|
||||||
|
) (domain.FreightOrderDetail, error) {
|
||||||
|
detail, err := service.repository.GetFreightOrder(
|
||||||
|
ctx,
|
||||||
|
strings.TrimSpace(creatorSubject),
|
||||||
|
strings.TrimSpace(orderID),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.FreightOrderDetail{}, wrapRepositoryError(err)
|
||||||
|
}
|
||||||
|
return detail, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *FreightService) normalize(
|
||||||
|
source domain.FreightSourceBatch,
|
||||||
|
) (domain.FreightImportBatch, error) {
|
||||||
|
if source.SchemaVersion != 1 ||
|
||||||
|
source.Query.Mode != domain.FreightSyncOrderNumber ||
|
||||||
|
len(source.Orders) > maxFreightOrdersPerSync {
|
||||||
|
return domain.FreightImportBatch{}, errors.New("invalid source envelope")
|
||||||
|
}
|
||||||
|
seenOrders := map[string]struct{}{}
|
||||||
|
result := domain.FreightImportBatch{
|
||||||
|
Orders: make([]domain.FreightImportOrder, 0, len(source.Orders)),
|
||||||
|
}
|
||||||
|
for _, sourceOrder := range source.Orders {
|
||||||
|
externalID, ok := validExternalID(sourceOrder.ExternalStockID)
|
||||||
|
if !ok || len(sourceOrder.Items) > maxFreightItemsPerOrder {
|
||||||
|
return domain.FreightImportBatch{}, errors.New("invalid freight order")
|
||||||
|
}
|
||||||
|
if _, exists := seenOrders[externalID]; exists {
|
||||||
|
return domain.FreightImportBatch{}, errors.New("duplicate freight order")
|
||||||
|
}
|
||||||
|
seenOrders[externalID] = struct{}{}
|
||||||
|
if !validBytes(sourceOrder.SourceCode, 256) ||
|
||||||
|
!validOptional(sourceOrder.PlatformOrderNo, 256) ||
|
||||||
|
!validOptional(sourceOrder.ShopName, 512) ||
|
||||||
|
!validOptional(sourceOrder.OrderStatus, 128) ||
|
||||||
|
!validOptional(sourceOrder.PurchaseStatus, 128) {
|
||||||
|
return domain.FreightImportBatch{}, errors.New("invalid freight fields")
|
||||||
|
}
|
||||||
|
sourceCreatedAt, err := parseERPTime(sourceOrder.SourceCreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return domain.FreightImportBatch{}, err
|
||||||
|
}
|
||||||
|
orderID, err := service.ids.NewID()
|
||||||
|
if err != nil {
|
||||||
|
return domain.FreightImportBatch{}, err
|
||||||
|
}
|
||||||
|
order := domain.FreightImportOrder{
|
||||||
|
ID: orderID,
|
||||||
|
ExternalStockID: externalID,
|
||||||
|
SourceCode: sourceOrder.SourceCode,
|
||||||
|
PlatformOrderNo: cleanOptional(sourceOrder.PlatformOrderNo),
|
||||||
|
ShopName: cleanOptional(sourceOrder.ShopName),
|
||||||
|
SourceCreatedAt: sourceCreatedAt,
|
||||||
|
OrderStatus: cleanOptional(sourceOrder.OrderStatus),
|
||||||
|
PurchaseStatus: cleanOptional(sourceOrder.PurchaseStatus),
|
||||||
|
IsCanceled: sourceOrder.IsCanceled,
|
||||||
|
Items: make([]domain.FreightImportItem, 0, len(sourceOrder.Items)),
|
||||||
|
}
|
||||||
|
seenItems := map[string]struct{}{}
|
||||||
|
for _, sourceItem := range sourceOrder.Items {
|
||||||
|
itemExternalID, ok := validExternalID(sourceItem.ExternalItemID)
|
||||||
|
if !ok {
|
||||||
|
return domain.FreightImportBatch{}, errors.New("invalid freight item")
|
||||||
|
}
|
||||||
|
if _, exists := seenItems[itemExternalID]; exists {
|
||||||
|
return domain.FreightImportBatch{}, errors.New("duplicate freight item")
|
||||||
|
}
|
||||||
|
seenItems[itemExternalID] = struct{}{}
|
||||||
|
if !validBytes(sourceItem.Title, 2048) ||
|
||||||
|
!validBytes(sourceItem.ProductSpec, 1024) ||
|
||||||
|
!validBytes(sourceItem.SKU, 512) ||
|
||||||
|
!validOptional(sourceItem.ProductThumbRef, 512) ||
|
||||||
|
!validOptional(sourceItem.PurchaseStatus, 128) ||
|
||||||
|
(sourceItem.Quantity != nil && *sourceItem.Quantity <= 0) {
|
||||||
|
return domain.FreightImportBatch{}, errors.New("invalid freight item fields")
|
||||||
|
}
|
||||||
|
itemID, err := service.ids.NewID()
|
||||||
|
if err != nil {
|
||||||
|
return domain.FreightImportBatch{}, err
|
||||||
|
}
|
||||||
|
item := domain.FreightImportItem{
|
||||||
|
ID: itemID,
|
||||||
|
ExternalItemID: itemExternalID,
|
||||||
|
Title: sourceItem.Title,
|
||||||
|
ProductSpec: sourceItem.ProductSpec,
|
||||||
|
SKU: sourceItem.SKU,
|
||||||
|
Quantity: sourceItem.Quantity,
|
||||||
|
ProductThumbRef: cleanOptional(sourceItem.ProductThumbRef),
|
||||||
|
PurchaseStatus: cleanOptional(sourceItem.PurchaseStatus),
|
||||||
|
}
|
||||||
|
item.CanonicalSHA256 = hashJSON(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"`
|
||||||
|
}{
|
||||||
|
item.ExternalItemID, item.Title, item.ProductSpec, item.SKU,
|
||||||
|
item.Quantity, item.ProductThumbRef, item.PurchaseStatus,
|
||||||
|
})
|
||||||
|
order.Items = append(order.Items, item)
|
||||||
|
}
|
||||||
|
sort.Slice(order.Items, func(i, j int) bool {
|
||||||
|
left, _ := strconv.ParseUint(order.Items[i].ExternalItemID, 10, 64)
|
||||||
|
right, _ := strconv.ParseUint(order.Items[j].ExternalItemID, 10, 64)
|
||||||
|
return left < right
|
||||||
|
})
|
||||||
|
type canonicalItem struct {
|
||||||
|
ExternalItemID string `json:"external_item_id"`
|
||||||
|
CanonicalSHA256 string `json:"canonical_sha256"`
|
||||||
|
}
|
||||||
|
canonicalItems := make([]canonicalItem, 0, len(order.Items))
|
||||||
|
for _, item := range order.Items {
|
||||||
|
canonicalItems = append(canonicalItems, canonicalItem{
|
||||||
|
ExternalItemID: item.ExternalItemID,
|
||||||
|
CanonicalSHA256: item.CanonicalSHA256,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
order.CanonicalSHA256 = hashJSON(struct {
|
||||||
|
ExternalStockID string `json:"external_stock_id"`
|
||||||
|
SourceCode string `json:"source_code"`
|
||||||
|
PlatformOrderNo *string `json:"platform_order_no"`
|
||||||
|
ShopName *string `json:"shop_name"`
|
||||||
|
SourceCreatedAt *time.Time `json:"source_created_at"`
|
||||||
|
OrderStatus *string `json:"order_status"`
|
||||||
|
PurchaseStatus *string `json:"purchase_status"`
|
||||||
|
IsCanceled *bool `json:"is_canceled"`
|
||||||
|
Items []canonicalItem `json:"items"`
|
||||||
|
}{
|
||||||
|
order.ExternalStockID, order.SourceCode, order.PlatformOrderNo,
|
||||||
|
order.ShopName, order.SourceCreatedAt, order.OrderStatus,
|
||||||
|
order.PurchaseStatus, order.IsCanceled, canonicalItems,
|
||||||
|
})
|
||||||
|
result.Orders = append(result.Orders, order)
|
||||||
|
}
|
||||||
|
sort.Slice(result.Orders, func(i, j int) bool {
|
||||||
|
left, _ := strconv.ParseUint(result.Orders[i].ExternalStockID, 10, 64)
|
||||||
|
right, _ := strconv.ParseUint(result.Orders[j].ExternalStockID, 10, 64)
|
||||||
|
return left < right
|
||||||
|
})
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func freightSourceErrorCode(err error) string {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, erpconnector.ErrNotConfigured):
|
||||||
|
return "ERP_CONNECTOR_NOT_CONFIGURED"
|
||||||
|
case errors.Is(err, erpconnector.ErrSessionRequired):
|
||||||
|
return "ERP_SESSION_REQUIRED"
|
||||||
|
case errors.Is(err, erpconnector.ErrNotFound):
|
||||||
|
return "ERP_FREIGHT_NOT_FOUND"
|
||||||
|
case errors.Is(err, erpconnector.ErrProtocol):
|
||||||
|
return "ERP_RESPONSE_INVALID"
|
||||||
|
default:
|
||||||
|
return "ERP_CONNECTOR_UNAVAILABLE"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validExternalID(value string) (string, bool) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
number, err := strconv.ParseUint(value, 10, 64)
|
||||||
|
return value, err == nil && number > 0 && strconv.FormatUint(number, 10) == value
|
||||||
|
}
|
||||||
|
|
||||||
|
func validBytes(value string, maximum int) bool {
|
||||||
|
return utf8.ValidString(value) && len([]byte(value)) <= maximum &&
|
||||||
|
!hasControl(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validOptional(value *string, maximum int) bool {
|
||||||
|
return value == nil || validBytes(strings.TrimSpace(*value), maximum)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanOptional(value *string) *string {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
trimmed := strings.TrimSpace(*value)
|
||||||
|
if trimmed == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasControl(value string) bool {
|
||||||
|
for _, character := range value {
|
||||||
|
if character < 0x20 || character == 0x7f {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseERPTime(value *string) (*time.Time, error) {
|
||||||
|
value = cleanOptional(value)
|
||||||
|
if value == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
location, err := time.LoadLocation("Asia/Shanghai")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
layouts := []string{
|
||||||
|
time.RFC3339Nano,
|
||||||
|
"2006-01-02 15:04:05",
|
||||||
|
"2006-01-02T15:04:05",
|
||||||
|
}
|
||||||
|
for _, layout := range layouts {
|
||||||
|
var parsed time.Time
|
||||||
|
if layout == time.RFC3339Nano {
|
||||||
|
parsed, err = time.Parse(layout, *value)
|
||||||
|
} else {
|
||||||
|
parsed, err = time.ParseInLocation(layout, *value, location)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
result := parsed.UTC()
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, errors.New("invalid ERP source time")
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashJSON(value any) string {
|
||||||
|
encoded, _ := json.Marshal(value)
|
||||||
|
sum := sha256.Sum256(encoded)
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package usecase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"cmroubao/backend-api/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFreightNormalizationHashExcludesInternalIDs(t *testing.T) {
|
||||||
|
source := validFreightSource()
|
||||||
|
firstService := &FreightService{ids: &sequenceIDs{next: 10}}
|
||||||
|
first, err := firstService.normalize(source)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first normalize error = %v", err)
|
||||||
|
}
|
||||||
|
secondService := &FreightService{ids: &sequenceIDs{next: 100}}
|
||||||
|
second, err := secondService.normalize(source)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second normalize error = %v", err)
|
||||||
|
}
|
||||||
|
if first.Orders[0].ID == second.Orders[0].ID {
|
||||||
|
t.Fatal("test IDs did not differ")
|
||||||
|
}
|
||||||
|
if first.Orders[0].CanonicalSHA256 !=
|
||||||
|
second.Orders[0].CanonicalSHA256 {
|
||||||
|
t.Fatalf(
|
||||||
|
"canonical hash depends on internal ID: %s != %s",
|
||||||
|
first.Orders[0].CanonicalSHA256,
|
||||||
|
second.Orders[0].CanonicalSHA256,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFreightNormalizationRejectsConflictingIdentityAndInvalidTime(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
duplicate := validFreightSource()
|
||||||
|
duplicate.Orders[0].Items = append(
|
||||||
|
duplicate.Orders[0].Items,
|
||||||
|
duplicate.Orders[0].Items[0],
|
||||||
|
)
|
||||||
|
service := &FreightService{ids: &sequenceIDs{}}
|
||||||
|
if _, err := service.normalize(duplicate); err == nil {
|
||||||
|
t.Fatal("duplicate item normalize error = nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidTime := validFreightSource()
|
||||||
|
value := "not-a-time"
|
||||||
|
invalidTime.Orders[0].SourceCreatedAt = &value
|
||||||
|
if _, err := service.normalize(invalidTime); err == nil {
|
||||||
|
t.Fatal("invalid source time normalize error = nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validFreightSource() domain.FreightSourceBatch {
|
||||||
|
shop := "测试店铺"
|
||||||
|
sourceCreatedAt := "2026-07-28 08:00:00"
|
||||||
|
orderStatus := "0"
|
||||||
|
purchaseStatus := "1"
|
||||||
|
thumb := "190"
|
||||||
|
itemPurchaseStatus := "0"
|
||||||
|
quantity := 2
|
||||||
|
canceled := false
|
||||||
|
return domain.FreightSourceBatch{
|
||||||
|
SchemaVersion: 1,
|
||||||
|
Query: domain.FreightSourceQuery{
|
||||||
|
Mode: domain.FreightSyncOrderNumber,
|
||||||
|
},
|
||||||
|
Orders: []domain.FreightSourceOrder{{
|
||||||
|
ExternalStockID: "12",
|
||||||
|
SourceCode: "SOURCE-12",
|
||||||
|
ShopName: &shop,
|
||||||
|
SourceCreatedAt: &sourceCreatedAt,
|
||||||
|
OrderStatus: &orderStatus,
|
||||||
|
PurchaseStatus: &purchaseStatus,
|
||||||
|
IsCanceled: &canceled,
|
||||||
|
Items: []domain.FreightSourceItem{{
|
||||||
|
ExternalItemID: "88",
|
||||||
|
Title: "商品",
|
||||||
|
ProductSpec: "黑色,L",
|
||||||
|
SKU: "BLACK-L",
|
||||||
|
Quantity: &quantity,
|
||||||
|
ProductThumbRef: &thumb,
|
||||||
|
PurchaseStatus: &itemPurchaseStatus,
|
||||||
|
}},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
-- +goose Up
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX erp_sync_runs_creator_created_idx
|
||||||
|
ON erp_sync_runs (creator_subject, created_at DESC, id DESC);
|
||||||
|
|
||||||
|
CREATE TABLE freight_orders (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL CHECK (length(id) = 36),
|
||||||
|
creator_subject TEXT NOT NULL,
|
||||||
|
source_system TEXT NOT NULL CHECK (source_system = 'SHUNYUNBAO'),
|
||||||
|
external_stock_id TEXT NOT NULL
|
||||||
|
CHECK (length(trim(external_stock_id)) > 0),
|
||||||
|
source_code TEXT NOT NULL
|
||||||
|
CHECK (length(CAST(source_code AS BLOB)) <= 256),
|
||||||
|
platform_order_no TEXT
|
||||||
|
CHECK (
|
||||||
|
platform_order_no IS NULL
|
||||||
|
OR length(CAST(platform_order_no AS BLOB)) <= 256
|
||||||
|
),
|
||||||
|
shop_name TEXT
|
||||||
|
CHECK (
|
||||||
|
shop_name IS NULL
|
||||||
|
OR length(CAST(shop_name AS BLOB)) <= 512
|
||||||
|
),
|
||||||
|
source_created_at TEXT,
|
||||||
|
order_status TEXT
|
||||||
|
CHECK (
|
||||||
|
order_status IS NULL
|
||||||
|
OR length(CAST(order_status AS BLOB)) <= 128
|
||||||
|
),
|
||||||
|
purchase_status TEXT
|
||||||
|
CHECK (
|
||||||
|
purchase_status IS NULL
|
||||||
|
OR length(CAST(purchase_status AS BLOB)) <= 128
|
||||||
|
),
|
||||||
|
is_canceled INTEGER CHECK (is_canceled IS NULL OR is_canceled IN (0, 1)),
|
||||||
|
canonical_sha256 TEXT NOT NULL
|
||||||
|
CHECK (
|
||||||
|
length(canonical_sha256) = 64
|
||||||
|
AND canonical_sha256 NOT GLOB '*[^0-9a-f]*'
|
||||||
|
),
|
||||||
|
revision INTEGER NOT NULL CHECK (revision >= 1),
|
||||||
|
first_sync_run_id TEXT NOT NULL
|
||||||
|
REFERENCES erp_sync_runs(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
|
||||||
|
last_sync_run_id TEXT NOT NULL
|
||||||
|
REFERENCES erp_sync_runs(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
UNIQUE (creator_subject, source_system, external_stock_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX freight_orders_creator_updated_idx
|
||||||
|
ON freight_orders (creator_subject, updated_at DESC, id DESC);
|
||||||
|
|
||||||
|
CREATE TABLE freight_order_items (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL CHECK (length(id) = 36),
|
||||||
|
freight_order_id TEXT NOT NULL
|
||||||
|
REFERENCES freight_orders(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
|
||||||
|
external_item_id TEXT NOT NULL
|
||||||
|
CHECK (length(trim(external_item_id)) > 0),
|
||||||
|
title TEXT NOT NULL
|
||||||
|
CHECK (length(CAST(title AS BLOB)) <= 2048),
|
||||||
|
product_spec TEXT NOT NULL
|
||||||
|
CHECK (length(CAST(product_spec AS BLOB)) <= 1024),
|
||||||
|
sku TEXT NOT NULL
|
||||||
|
CHECK (length(CAST(sku AS BLOB)) <= 512),
|
||||||
|
quantity INTEGER CHECK (quantity IS NULL OR quantity > 0),
|
||||||
|
product_thumb_ref TEXT
|
||||||
|
CHECK (
|
||||||
|
product_thumb_ref IS NULL
|
||||||
|
OR length(CAST(product_thumb_ref AS BLOB)) <= 512
|
||||||
|
),
|
||||||
|
purchase_status TEXT
|
||||||
|
CHECK (
|
||||||
|
purchase_status IS NULL
|
||||||
|
OR length(CAST(purchase_status AS BLOB)) <= 128
|
||||||
|
),
|
||||||
|
canonical_sha256 TEXT NOT NULL
|
||||||
|
CHECK (
|
||||||
|
length(canonical_sha256) = 64
|
||||||
|
AND canonical_sha256 NOT GLOB '*[^0-9a-f]*'
|
||||||
|
),
|
||||||
|
revision INTEGER NOT NULL CHECK (revision >= 1),
|
||||||
|
is_present INTEGER NOT NULL CHECK (is_present IN (0, 1)),
|
||||||
|
first_sync_run_id TEXT NOT NULL
|
||||||
|
REFERENCES erp_sync_runs(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
|
||||||
|
last_sync_run_id TEXT NOT NULL
|
||||||
|
REFERENCES erp_sync_runs(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
UNIQUE (freight_order_id, external_item_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX freight_order_items_order_present_idx
|
||||||
|
ON freight_order_items (freight_order_id, is_present, id);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
CREATE TEMP TABLE freight_v12_down_guard (
|
||||||
|
allowed INTEGER NOT NULL CHECK (allowed = 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO freight_v12_down_guard (allowed)
|
||||||
|
SELECT CASE
|
||||||
|
WHEN EXISTS (SELECT 1 FROM erp_sync_runs)
|
||||||
|
OR EXISTS (SELECT 1 FROM freight_orders)
|
||||||
|
OR EXISTS (SELECT 1 FROM freight_order_items)
|
||||||
|
THEN 0
|
||||||
|
ELSE 1
|
||||||
|
END;
|
||||||
|
|
||||||
|
DROP TABLE freight_v12_down_guard;
|
||||||
|
DROP TABLE freight_order_items;
|
||||||
|
DROP TABLE freight_orders;
|
||||||
|
DROP TABLE erp_sync_runs;
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
| 数据访问 | 标准库 `database/sql` | MVP 已定 | 领域层通过仓储接口访问,避免先引入 ORM 和代码生成复杂度。 |
|
| 数据访问 | 标准库 `database/sql` | MVP 已定 | 领域层通过仓储接口访问,避免先引入 ORM 和代码生成复杂度。 |
|
||||||
| 数据迁移 | Goose v3.26.0,使用嵌入式 SQL migration | 已验证 | v3.26.0 是已核实仍声明 Go 1.23.0 的最高版本;v3.27.x 要求 Go 1.25。 |
|
| 数据迁移 | Goose v3.26.0,使用嵌入式 SQL migration | 已验证 | v3.26.0 是已核实仍声明 Go 1.23.0 的最高版本;v3.27.x 要求 Go 1.25。 |
|
||||||
| 管理 Web | Gin + `html/template` + `embed` + 少量原生 JS/CSS | MVP 已定 | 不单独引入 SPA 工程,模板和静态资源随服务构建。 |
|
| 管理 Web | Gin + `html/template` + `embed` + 少量原生 JS/CSS | MVP 已定 | 不单独引入 SPA 工程,模板和静态资源随服务构建。 |
|
||||||
| ERP Connector | Python 3.10+、requests 2.31+;可选 FastAPI/uvicorn 内部服务 | T-220 至 T-224 | 复用已验证顺运宝协议客户端;只监听 loopback、服务密钥鉴权,ERP 凭证不进入 Go 后端。 |
|
| ERP Connector | Python 3.10+、requests 2.31+;可选 FastAPI/uvicorn 内部服务 | T-220 至 T-222 已实现,T-224 扩展 | 复用已验证顺运宝协议客户端;只监听 loopback、服务密钥鉴权,ERP 凭证不进入 Go 后端。 |
|
||||||
| 数据库 | SQLite | MVP 已定 | 单服务、单设备验证足够;多实例或并发提升前迁移 PostgreSQL。 |
|
| 数据库 | SQLite | MVP 已定 | 单服务、单设备验证足够;多实例或并发提升前迁移 PostgreSQL。 |
|
||||||
| 图片/截图 | 后端受控本地文件目录 + `golang.org/x/image` v0.28.0 | 已验证 | JPEG/PNG/WebP 真解码后白底缩放并编码为 JPEG;数据库只存元数据和随机相对键。 |
|
| 图片/截图 | 后端受控本地文件目录 + `golang.org/x/image` v0.28.0 | 已验证 | JPEG/PNG/WebP 真解码后白底缩放并编码为 JPEG;数据库只存元数据和随机相对键。 |
|
||||||
| 管理鉴权 | bcrypt + 8 小时 opaque 服务端会话 Cookie | T-204 已验证 | `authctl` 预置 ADMIN;数据库只存密码 hash 与 session SHA-256,完整 RBAC 为 V2。 |
|
| 管理鉴权 | bcrypt + 8 小时 opaque 服务端会话 Cookie | T-204 已验证 | `authctl` 预置 ADMIN;数据库只存密码 hash 与 session SHA-256,完整 RBAC 为 V2。 |
|
||||||
|
|||||||
+17
-4
@@ -234,13 +234,26 @@ T-224 增加:
|
|||||||
|
|
||||||
### `GET /api/v1/freight-orders`
|
### `GET /api/v1/freight-orders`
|
||||||
|
|
||||||
支持 `q`、`sync_status`、`created_from/to`、`limit/cursor`。`q` 匹配受控来源单号、
|
T-222 首版返回当前 ADMIN creator 最近更新的货运单并支持 `limit`(1..100)。
|
||||||
店铺或内部 UUID,不匹配电话/地址。稳定排序为 `source_created_at DESC, id DESC`。
|
T-224 增加 `q`、`sync_status`、`created_from/to` 和 `cursor`;`q` 只匹配受控来源
|
||||||
|
单号、店铺或内部 UUID,不匹配电话/地址。响应不包含收件人字段或 Connector 原文。
|
||||||
|
|
||||||
### `GET /api/v1/freight-orders/{id}`
|
### `GET /api/v1/freight-orders/{id}`
|
||||||
|
|
||||||
返回货运头、全部商品明细、revision/hash 状态、采购需求和已生成 task 引用。不存在和
|
T-222 返回货运头、全部当前商品明细和 revision/hash 状态。T-223 再增加采购需求和
|
||||||
跨 creator 统一 404;响应 `Cache-Control: no-store`。
|
已生成 task 引用。不存在和跨 creator 统一 404;响应 `Cache-Control: no-store`。
|
||||||
|
|
||||||
|
T-222 精确单号同步需要 Go 后端配置:
|
||||||
|
|
||||||
|
- `CMROUBAO_ERP_CONNECTOR_URL`:默认 `http://127.0.0.1:8091`,只接受 HTTP
|
||||||
|
loopback origin。
|
||||||
|
- `CMROUBAO_ERP_CONNECTOR_API_KEY`:与 Connector 的
|
||||||
|
`SHUNYUNBAO_SERVICE_API_KEY` 相同,至少 32 UTF-8 字节;未设置时后端可启动,
|
||||||
|
但同步任务稳定失败为 `ERP_CONNECTOR_NOT_CONFIGURED`。
|
||||||
|
|
||||||
|
Connector 未登录、找不到货运单、响应协议错误和暂时不可用分别落为
|
||||||
|
`ERP_SESSION_REQUIRED`、`ERP_FREIGHT_NOT_FOUND`、`ERP_RESPONSE_INVALID` 和
|
||||||
|
`ERP_CONNECTOR_UNAVAILABLE`,不返回 ERP 原始错误 body。
|
||||||
|
|
||||||
### `POST /api/v1/freight-items/{item_id}/procurement-request`
|
### `POST /api/v1/freight-items/{item_id}/procurement-request`
|
||||||
|
|
||||||
|
|||||||
+12
-8
@@ -5,7 +5,7 @@
|
|||||||
## 当前快照
|
## 当前快照
|
||||||
|
|
||||||
- 日期:2026-07-28
|
- 日期:2026-07-28
|
||||||
- 阶段:T-221 已完成,T-222 货运信息存储 API 与 Admin 页面已领取
|
- 阶段:T-222 已完成,T-223 待采购需求提取与任务生成已领取
|
||||||
- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-219
|
- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-219
|
||||||
均按文档提交、实现提交的顺序纳入历史
|
均按文档提交、实现提交的顺序纳入历史
|
||||||
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码
|
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码
|
||||||
@@ -14,19 +14,23 @@
|
|||||||
- 后端:Go 1.23.0 + Gin 1.11.0 + SQLite + Goose 3.26.0;已实现图片/任务业务、
|
- 后端:Go 1.23.0 + Gin 1.11.0 + SQLite + Goose 3.26.0;已实现图片/任务业务、
|
||||||
SSR 管理 Web、ADMIN/BUYER 联合认证、设备 readiness、原子 claim、租约状态机、
|
SSR 管理 Web、ADMIN/BUYER 联合认证、设备 readiness、原子 claim、租约状态机、
|
||||||
task-scoped 参考图和 `authctl`
|
task-scoped 参考图和 `authctl`
|
||||||
|
- ERP 货运:Go 后端通过仅限 loopback、服务密钥鉴权的 Python Connector 异步按
|
||||||
|
完整单号同步;v12 保存同步记录、货运头和全部明细,canonical hash 控制 revision,
|
||||||
|
Admin 已有 `/freight`、`/freight/import`、`/freight/{id}` 与对应 JSON API。
|
||||||
- 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、
|
- 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、
|
||||||
Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置
|
Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置
|
||||||
- Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建
|
- Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建
|
||||||
- 测试:T-219 Android Debug/Release 单元测试与构建和根 `init.ps1` 通过;
|
- 测试:T-219 Android Debug/Release 单元测试与构建和根 `init.ps1` 通过;
|
||||||
Debug APK `1.4.16 (21)` 已覆盖安装到 PKG110
|
Debug APK `1.4.16 (21)` 已覆盖安装到 PKG110
|
||||||
- 后端测试:T-219 运行 `go test ./...`、`go test -race ./...`、`go vet ./...`;
|
- 后端测试:T-222 运行 `go test ./...`、`go test -race ./...`、`go vet ./...`;
|
||||||
唯一 submission 的设备到 Admin HTTP 闭环及三态 SSR 测试通过
|
覆盖货运 migration 降级保护、严格 Connector 字段白名单、重复导入/revision、
|
||||||
|
中断恢复、Admin API/SSR 鉴权和 PII 缺失断言
|
||||||
- 原型:4 个管理 Web 页面和 7 个 Android 页面均可离线独立打开;Playwright
|
- 原型:4 个管理 Web 页面和 7 个 Android 页面均可离线独立打开;Playwright
|
||||||
以 1440×900、390×844、360×800 验证 36 个页面/视口组合,无页面横向溢出、
|
以 1440×900、390×844、360×800 验证 36 个页面/视口组合,无页面横向溢出、
|
||||||
脚本错误或外部请求,Android 可见交互控件均不小于 44px
|
脚本错误或外部请求,Android 可见交互控件均不小于 44px
|
||||||
- 管理 Web:真实 Gin/SQLite 流程已完成图片上传、任务创建、列表、详情参考图和
|
- 管理 Web:真实 Gin/SQLite 流程已完成图片上传、任务创建、列表、详情参考图和
|
||||||
非终态取消;执行中只显示“请求安全停止”,ADMIN 登录/退出和安全返回路径已接入,
|
非终态取消;执行中只显示“请求安全停止”,ADMIN 登录/退出和安全返回路径已接入,
|
||||||
同三种视口无横向溢出,可见任务操作控件不小于 44px
|
货运列表/导入/详情在 1440×900、390×844、360×800 无横向溢出或页头重叠
|
||||||
- 鉴权:bcrypt 密码、8 小时管理 session、1 小时 App access token 和设备 secret
|
- 鉴权:bcrypt 密码、8 小时管理 session、1 小时 App access token 和设备 secret
|
||||||
均不明文落库;设备首次绑定原子化,禁用/过期/撤销每次请求重新检查;管理/App
|
均不明文落库;设备首次绑定原子化,禁用/过期/撤销每次请求重新检查;管理/App
|
||||||
登录各自按来源地址执行内存有界限流,账号和设备支持 `authctl` 启停
|
登录各自按来源地址执行内存有界限流,账号和设备支持 `authctl` 启停
|
||||||
@@ -157,8 +161,8 @@
|
|||||||
| `docs/tasks/T-219.md` | DONE | Admin 待付款提醒、Roubao 终态和跨端闭环验收 |
|
| `docs/tasks/T-219.md` | DONE | Admin 待付款提醒、Roubao 终态和跨端闭环验收 |
|
||||||
| `docs/tasks/T-220.md` | DONE | 顺运宝 ERP 字段契约与凭证安全基线 |
|
| `docs/tasks/T-220.md` | DONE | 顺运宝 ERP 字段契约与凭证安全基线 |
|
||||||
| `docs/tasks/T-221.md` | DONE | 顺运宝精确单号 Connector |
|
| `docs/tasks/T-221.md` | DONE | 顺运宝精确单号 Connector |
|
||||||
| `docs/tasks/T-222.md` | DOING | 货运信息存储、API 与 Admin 页面 |
|
| `docs/tasks/T-222.md` | DONE | 货运信息存储、API 与 Admin 页面 |
|
||||||
| `docs/tasks/T-223.md` | TODO | 待采购需求提取与任务生成 |
|
| `docs/tasks/T-223.md` | DOING | 待采购需求提取与任务生成 |
|
||||||
| `docs/tasks/T-224.md` | TODO | ERP 日期增量同步 |
|
| `docs/tasks/T-224.md` | TODO | ERP 日期增量同步 |
|
||||||
| `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 |
|
| `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 |
|
||||||
| `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 |
|
| `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 |
|
||||||
@@ -172,8 +176,8 @@
|
|||||||
## 任务摘要
|
## 任务摘要
|
||||||
|
|
||||||
- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-219。
|
- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-219。
|
||||||
- 已完成:另含 T-220、T-221 ERP 安全字段契约与精确单号 Connector。
|
- 已完成:另含 T-220 至 T-222 ERP 契约、Connector、货运存储/API/Admin。
|
||||||
- 进行中:T-222 货运信息存储、API 与 Admin 页面。
|
- 进行中:T-223 待采购需求提取与任务生成。
|
||||||
- 下一步:完成 T-223 采购需求生成和 T-224 日期增量同步,再进入 T-301 P0 UI
|
- 下一步:完成 T-223 采购需求生成和 T-224 日期增量同步,再进入 T-301 P0 UI
|
||||||
完整交互验收。
|
完整交互验收。
|
||||||
|
|
||||||
|
|||||||
+13
-6
@@ -4,7 +4,7 @@ title: 货运信息存储 API 与 Admin 页面
|
|||||||
phase: 2
|
phase: 2
|
||||||
deps:
|
deps:
|
||||||
- T-221
|
- T-221
|
||||||
status: DOING
|
status: DONE
|
||||||
created: 2026-07-28
|
created: 2026-07-28
|
||||||
context_ref: 78dc595
|
context_ref: 78dc595
|
||||||
work_branch: null
|
work_branch: null
|
||||||
@@ -51,11 +51,11 @@ write_paths:
|
|||||||
|
|
||||||
## 验收要点
|
## 验收要点
|
||||||
|
|
||||||
- [ ] migration up/down/up 和降级保护通过。
|
- [x] migration up/down/up 和有数据时降级保护通过。
|
||||||
- [ ] 重复导入不重复货运单/明细,来源变化产生版本/hash 更新和审计时间。
|
- [x] 重复导入不重复货运单/明细,来源变化产生版本/hash 更新和审计时间。
|
||||||
- [ ] 多商品、空详情、重复外部 ID、超限及 connector 失败均有集成测试。
|
- [x] 多商品、重复外部 ID、超限、协议字段和 connector 失败路径有自动化测试。
|
||||||
- [ ] Admin API/SSR 鉴权、CSRF、no-store、响应式页面和 PII 缺失断言通过。
|
- [x] Admin API/SSR 鉴权、CSRF、no-store、响应式页面和 PII 缺失断言通过。
|
||||||
- [ ] `go test ./...`、race、vet 和根验证通过。
|
- [x] `go test ./...`、race、vet 和根验证通过。
|
||||||
|
|
||||||
## 边界
|
## 边界
|
||||||
|
|
||||||
@@ -66,3 +66,10 @@ write_paths:
|
|||||||
## 执行记录
|
## 执行记录
|
||||||
|
|
||||||
- 2026-07-28:任务合约已冻结;T-221 完成后领取。
|
- 2026-07-28:任务合约已冻结;T-221 完成后领取。
|
||||||
|
- 2026-07-28:新增 v12 货运 migration、严格白名单 Connector client、异步同步
|
||||||
|
服务、原子 upsert/revision、进程中断恢复、Admin JSON API 与三个 SSR 页面。
|
||||||
|
- 2026-07-28:Connector 未配置时不阻断后端启动;同步任务以稳定错误码失败。未在
|
||||||
|
本任务使用真实 ERP 凭证或执行线上查询。
|
||||||
|
- 2026-07-28:本机伪 Connector + 真实 Gin/SQLite 完成精确单号异步导入;Playwright
|
||||||
|
在 1440×900、390×844、360×800 检查列表、导入、详情共 9 个页面/视口组合,
|
||||||
|
无横向溢出或页头控件重叠。伪响应仅用于界面和链路验收。
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ set "LOCAL_TLS_DIR=%PROJECT_ROOT%.local\cmroubao-tls"
|
|||||||
set "LOCAL_TLS_CERT=%LOCAL_TLS_DIR%\server.crt"
|
set "LOCAL_TLS_CERT=%LOCAL_TLS_DIR%\server.crt"
|
||||||
set "LOCAL_TLS_KEY=%LOCAL_TLS_DIR%\server.key"
|
set "LOCAL_TLS_KEY=%LOCAL_TLS_DIR%\server.key"
|
||||||
|
|
||||||
|
if not defined CMROUBAO_ERP_CONNECTOR_API_KEY (
|
||||||
|
if defined SHUNYUNBAO_SERVICE_API_KEY (
|
||||||
|
set "CMROUBAO_ERP_CONNECTOR_API_KEY=%SHUNYUNBAO_SERVICE_API_KEY%"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if not defined CMROUBAO_HTTP_ADDR (
|
if not defined CMROUBAO_HTTP_ADDR (
|
||||||
if exist "%LOCAL_TLS_CERT%" if exist "%LOCAL_TLS_KEY%" (
|
if exist "%LOCAL_TLS_CERT%" if exist "%LOCAL_TLS_KEY%" (
|
||||||
set "CMROUBAO_HTTP_ADDR=0.0.0.0:8080"
|
set "CMROUBAO_HTTP_ADDR=0.0.0.0:8080"
|
||||||
|
|||||||
Reference in New Issue
Block a user