refactor(t228): remove Python ERP connector
This commit is contained in:
+1
-2
@@ -44,9 +44,8 @@ coverage.out
|
||||
captcha.jpg
|
||||
captcha.png
|
||||
freight_detail.json
|
||||
erp-connector/.env*
|
||||
|
||||
# Python connector outputs
|
||||
# Python local-tool outputs
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
|
||||
@@ -19,46 +19,40 @@ const (
|
||||
ClaimLeaseEnvironment = "CMROUBAO_CLAIM_LEASE"
|
||||
RunningLeaseEnvironment = "CMROUBAO_RUNNING_LEASE"
|
||||
ReadinessTTLEnvironment = "CMROUBAO_READINESS_TTL"
|
||||
ERPConnectorURLEnvironment = "CMROUBAO_ERP_CONNECTOR_URL"
|
||||
ERPConnectorAPIKeyEnvironment = "CMROUBAO_ERP_CONNECTOR_API_KEY"
|
||||
ShunyunbaoURLEnvironment = "CMROUBAO_SHUNYUNBAO_URL"
|
||||
ShunyunbaoUsernameEnvironment = "CMROUBAO_SHUNYUNBAO_USERNAME"
|
||||
ShunyunbaoPasswordEnvironment = "CMROUBAO_SHUNYUNBAO_PASSWORD"
|
||||
|
||||
defaultHTTPAddress = "127.0.0.1:8080"
|
||||
defaultDatabasePath = "var/cmroubao.db"
|
||||
defaultAssetDirectory = "var/assets"
|
||||
defaultClaimLease = 10 * time.Minute
|
||||
defaultRunningLease = 30 * time.Minute
|
||||
defaultReadinessTTL = 2 * time.Minute
|
||||
defaultERPConnectorURL = "http://127.0.0.1:8091"
|
||||
defaultShunyunbaoURL = "https://www.shunyunbaoerp.com"
|
||||
defaultHTTPAddress = "127.0.0.1:8080"
|
||||
defaultDatabasePath = "var/cmroubao.db"
|
||||
defaultAssetDirectory = "var/assets"
|
||||
defaultClaimLease = 10 * time.Minute
|
||||
defaultRunningLease = 30 * time.Minute
|
||||
defaultReadinessTTL = 2 * time.Minute
|
||||
defaultShunyunbaoURL = "https://www.shunyunbaoerp.com"
|
||||
)
|
||||
|
||||
type LookupEnvironment func(string) (string, bool)
|
||||
|
||||
type Config struct {
|
||||
HTTPAddress string
|
||||
DatabasePath string
|
||||
AssetDirectory string
|
||||
TLSCertificate string
|
||||
TLSPrivateKey string
|
||||
ReadHeaderTimeout time.Duration
|
||||
ReadTimeout time.Duration
|
||||
WriteTimeout time.Duration
|
||||
IdleTimeout time.Duration
|
||||
ShutdownTimeout time.Duration
|
||||
MaxHeaderBytes int
|
||||
ClaimLease time.Duration
|
||||
RunningLease time.Duration
|
||||
ReadinessTTL time.Duration
|
||||
ERPConnectorURL string
|
||||
ERPConnectorAPIKey string
|
||||
ERPConnectorTimeout time.Duration
|
||||
ShunyunbaoURL string
|
||||
ShunyunbaoUsername string
|
||||
ShunyunbaoPassword string
|
||||
ShunyunbaoTimeout time.Duration
|
||||
HTTPAddress string
|
||||
DatabasePath string
|
||||
AssetDirectory string
|
||||
TLSCertificate string
|
||||
TLSPrivateKey string
|
||||
ReadHeaderTimeout time.Duration
|
||||
ReadTimeout time.Duration
|
||||
WriteTimeout time.Duration
|
||||
IdleTimeout time.Duration
|
||||
ShutdownTimeout time.Duration
|
||||
MaxHeaderBytes int
|
||||
ClaimLease time.Duration
|
||||
RunningLease time.Duration
|
||||
ReadinessTTL time.Duration
|
||||
ShunyunbaoURL string
|
||||
ShunyunbaoUsername string
|
||||
ShunyunbaoPassword string
|
||||
ShunyunbaoTimeout time.Duration
|
||||
}
|
||||
|
||||
func Load(lookup LookupEnvironment) (Config, error) {
|
||||
@@ -152,27 +146,6 @@ func Load(lookup LookupEnvironment) (Config, error) {
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
erpConnectorURL, err := environmentValue(
|
||||
lookup,
|
||||
ERPConnectorURLEnvironment,
|
||||
defaultERPConnectorURL,
|
||||
)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if err := validateLoopbackURL(erpConnectorURL); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
erpConnectorAPIKey := ""
|
||||
if value, exists := lookup(ERPConnectorAPIKeyEnvironment); exists {
|
||||
erpConnectorAPIKey = strings.TrimSpace(value)
|
||||
if len([]byte(erpConnectorAPIKey)) < 32 {
|
||||
return Config{}, errors.New(
|
||||
ERPConnectorAPIKeyEnvironment +
|
||||
" must contain at least 32 UTF-8 bytes",
|
||||
)
|
||||
}
|
||||
}
|
||||
shunyunbaoURL, err := environmentValue(
|
||||
lookup,
|
||||
ShunyunbaoURLEnvironment,
|
||||
@@ -206,57 +179,27 @@ func Load(lookup LookupEnvironment) (Config, error) {
|
||||
}
|
||||
|
||||
return Config{
|
||||
HTTPAddress: httpAddress,
|
||||
DatabasePath: filepath.Clean(databasePath),
|
||||
AssetDirectory: assetDirectory,
|
||||
TLSCertificate: cleanOptionalPath(tlsCertificate),
|
||||
TLSPrivateKey: cleanOptionalPath(tlsPrivateKey),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
ShutdownTimeout: 10 * time.Second,
|
||||
MaxHeaderBytes: 1 << 20,
|
||||
ClaimLease: claimLease,
|
||||
RunningLease: runningLease,
|
||||
ReadinessTTL: readinessTTL,
|
||||
ERPConnectorURL: strings.TrimRight(erpConnectorURL, "/"),
|
||||
ERPConnectorAPIKey: erpConnectorAPIKey,
|
||||
ERPConnectorTimeout: 90 * time.Second,
|
||||
ShunyunbaoURL: strings.TrimRight(shunyunbaoURL, "/"),
|
||||
ShunyunbaoUsername: shunyunbaoUsername,
|
||||
ShunyunbaoPassword: shunyunbaoPassword,
|
||||
ShunyunbaoTimeout: 30 * time.Second,
|
||||
HTTPAddress: httpAddress,
|
||||
DatabasePath: filepath.Clean(databasePath),
|
||||
AssetDirectory: assetDirectory,
|
||||
TLSCertificate: cleanOptionalPath(tlsCertificate),
|
||||
TLSPrivateKey: cleanOptionalPath(tlsPrivateKey),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
ShutdownTimeout: 10 * time.Second,
|
||||
MaxHeaderBytes: 1 << 20,
|
||||
ClaimLease: claimLease,
|
||||
RunningLease: runningLease,
|
||||
ReadinessTTL: readinessTTL,
|
||||
ShunyunbaoURL: strings.TrimRight(shunyunbaoURL, "/"),
|
||||
ShunyunbaoUsername: shunyunbaoUsername,
|
||||
ShunyunbaoPassword: shunyunbaoPassword,
|
||||
ShunyunbaoTimeout: 30 * time.Second,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateLoopbackURL(value string) error {
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil || parsed.Scheme != "http" || parsed.User != nil ||
|
||||
parsed.RawQuery != "" || parsed.Fragment != "" ||
|
||||
(parsed.Path != "" && parsed.Path != "/") {
|
||||
return errors.New(
|
||||
ERPConnectorURLEnvironment +
|
||||
" must be an http loopback origin without credentials or path",
|
||||
)
|
||||
}
|
||||
host := parsed.Hostname()
|
||||
ip := net.ParseIP(host)
|
||||
if !strings.EqualFold(host, "localhost") &&
|
||||
(ip == nil || !ip.IsLoopback()) {
|
||||
return errors.New(
|
||||
ERPConnectorURLEnvironment + " must use a loopback host",
|
||||
)
|
||||
}
|
||||
port, err := strconv.Atoi(parsed.Port())
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
return errors.New(
|
||||
ERPConnectorURLEnvironment + " must include a valid port",
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateHTTPSOrigin(value, environment string) error {
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Host == "" ||
|
||||
|
||||
@@ -42,16 +42,6 @@ func TestLoadUsesSafeDefaults(t *testing.T) {
|
||||
if cfg.ShutdownTimeout > 30*time.Second {
|
||||
t.Fatalf("ShutdownTimeout = %s", cfg.ShutdownTimeout)
|
||||
}
|
||||
if cfg.ERPConnectorURL != "http://127.0.0.1:8091" ||
|
||||
cfg.ERPConnectorAPIKey != "" ||
|
||||
cfg.ERPConnectorTimeout != 90*time.Second {
|
||||
t.Fatalf(
|
||||
"ERP connector defaults = %q / %q / %s",
|
||||
cfg.ERPConnectorURL,
|
||||
cfg.ERPConnectorAPIKey,
|
||||
cfg.ERPConnectorTimeout,
|
||||
)
|
||||
}
|
||||
if cfg.ShunyunbaoURL != "https://www.shunyunbaoerp.com" ||
|
||||
cfg.ShunyunbaoUsername != "" || cfg.ShunyunbaoPassword != "" ||
|
||||
cfg.ShunyunbaoTimeout != 30*time.Second {
|
||||
@@ -75,8 +65,6 @@ func TestLoadAcceptsExplicitConfiguration(t *testing.T) {
|
||||
ClaimLeaseEnvironment: "15m",
|
||||
RunningLeaseEnvironment: "45m",
|
||||
ReadinessTTLEnvironment: "3m",
|
||||
ERPConnectorURLEnvironment: "http://localhost:18091",
|
||||
ERPConnectorAPIKeyEnvironment: "12345678901234567890123456789012",
|
||||
ShunyunbaoURLEnvironment: "https://erp.example.test:8443",
|
||||
ShunyunbaoUsernameEnvironment: "service-user",
|
||||
ShunyunbaoPasswordEnvironment: " pass with spaces ",
|
||||
@@ -114,14 +102,6 @@ func TestLoadAcceptsExplicitConfiguration(t *testing.T) {
|
||||
cfg.ReadinessTTL,
|
||||
)
|
||||
}
|
||||
if cfg.ERPConnectorURL != values[ERPConnectorURLEnvironment] ||
|
||||
cfg.ERPConnectorAPIKey != values[ERPConnectorAPIKeyEnvironment] {
|
||||
t.Fatalf(
|
||||
"ERP connector = %q / %q",
|
||||
cfg.ERPConnectorURL,
|
||||
cfg.ERPConnectorAPIKey,
|
||||
)
|
||||
}
|
||||
if cfg.ShunyunbaoURL != values[ShunyunbaoURLEnvironment] ||
|
||||
cfg.ShunyunbaoUsername != values[ShunyunbaoUsernameEnvironment] ||
|
||||
cfg.ShunyunbaoPassword != values[ShunyunbaoPasswordEnvironment] {
|
||||
@@ -134,24 +114,6 @@ func TestLoadRejectsUnsafeOrInvalidValues(t *testing.T) {
|
||||
name string
|
||||
values map[string]string
|
||||
}{
|
||||
{
|
||||
name: "non-loopback ERP connector",
|
||||
values: map[string]string{
|
||||
ERPConnectorURLEnvironment: "http://192.0.2.10:8091",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ERP connector path",
|
||||
values: map[string]string{
|
||||
ERPConnectorURLEnvironment: "http://127.0.0.1:8091/private",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "short ERP connector key",
|
||||
values: map[string]string{
|
||||
ERPConnectorAPIKeyEnvironment: "short",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "non HTTPS shunyunbao URL",
|
||||
values: map[string]string{
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
package erpconnector
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
)
|
||||
|
||||
var (
|
||||
// Deprecated aliases keep the temporary Connector behavior compatible while
|
||||
// the source-neutral Go ERP implementation is introduced in T-225 to T-228.
|
||||
ErrNotConfigured = domain.ErrFreightSourceNotConfigured
|
||||
ErrSessionRequired = domain.ErrFreightSourceSessionNeeded
|
||||
ErrNotFound = domain.ErrFreightSourceNotFound
|
||||
ErrUnavailable = domain.ErrFreightSourceUnavailable
|
||||
ErrProtocol = domain.ErrFreightSourceProtocol
|
||||
)
|
||||
|
||||
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) {
|
||||
return client.query(ctx, map[string]string{
|
||||
"mode": domain.FreightSyncOrderNumber,
|
||||
"order_number": orderNumber,
|
||||
}, domain.FreightSyncOrderNumber, "", "")
|
||||
}
|
||||
|
||||
func (client *Client) QueryCreatedRange(
|
||||
ctx context.Context,
|
||||
createdFrom, createdTo string,
|
||||
) (domain.FreightSourceBatch, error) {
|
||||
return client.query(ctx, map[string]string{
|
||||
"mode": domain.FreightSyncCreatedRange,
|
||||
"created_from": createdFrom,
|
||||
"created_to": createdTo,
|
||||
}, domain.FreightSyncCreatedRange, createdFrom, createdTo)
|
||||
}
|
||||
|
||||
func (client *Client) query(
|
||||
ctx context.Context,
|
||||
payload map[string]string,
|
||||
expectedMode, expectedFrom, expectedTo string,
|
||||
) (domain.FreightSourceBatch, error) {
|
||||
if client.apiKey == "" {
|
||||
return domain.FreightSourceBatch{}, ErrNotConfigured
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
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 != expectedMode ||
|
||||
result.Orders == nil {
|
||||
return domain.FreightSourceBatch{}, ErrProtocol
|
||||
}
|
||||
if expectedMode == domain.FreightSyncOrderNumber {
|
||||
if result.Query.CreatedFrom != nil || result.Query.CreatedTo != nil {
|
||||
return domain.FreightSourceBatch{}, ErrProtocol
|
||||
}
|
||||
} else if result.Query.CreatedFrom == nil ||
|
||||
result.Query.CreatedTo == nil ||
|
||||
*result.Query.CreatedFrom != expectedFrom ||
|
||||
*result.Query.CreatedTo != expectedTo {
|
||||
return domain.FreightSourceBatch{}, ErrProtocol
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
package erpconnector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"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 TestQueryCreatedRangeUsesStrictContract(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
) {
|
||||
var body map[string]string
|
||||
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
if body["mode"] != "CREATED_RANGE" ||
|
||||
body["created_from"] != "2026-07-22" ||
|
||||
body["created_to"] != "2026-07-28" ||
|
||||
len(body) != 3 {
|
||||
t.Fatalf("request body = %#v", body)
|
||||
}
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
_, _ = writer.Write([]byte(`{
|
||||
"schema_version":1,
|
||||
"query":{
|
||||
"mode":"CREATED_RANGE",
|
||||
"created_from":"2026-07-22",
|
||||
"created_to":"2026-07-28"
|
||||
},
|
||||
"orders":[]
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
client, _ := New(
|
||||
server.URL,
|
||||
"12345678901234567890123456789012",
|
||||
time.Second,
|
||||
)
|
||||
|
||||
result, err := client.QueryCreatedRange(
|
||||
context.Background(),
|
||||
"2026-07-22",
|
||||
"2026-07-28",
|
||||
)
|
||||
|
||||
if err != nil || result.Query.CreatedFrom == nil ||
|
||||
*result.Query.CreatedFrom != "2026-07-22" {
|
||||
t.Fatalf("QueryCreatedRange() = %+v, %v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryCreatedRangeRejectsMismatchedResponseWindow(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(
|
||||
writer http.ResponseWriter,
|
||||
_ *http.Request,
|
||||
) {
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
_, _ = writer.Write([]byte(`{
|
||||
"schema_version":1,
|
||||
"query":{
|
||||
"mode":"CREATED_RANGE",
|
||||
"created_from":"2026-07-21",
|
||||
"created_to":"2026-07-28"
|
||||
},
|
||||
"orders":[]
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
client, _ := New(
|
||||
server.URL,
|
||||
"12345678901234567890123456789012",
|
||||
time.Second,
|
||||
)
|
||||
|
||||
_, err := client.QueryCreatedRange(
|
||||
context.Background(),
|
||||
"2026-07-22",
|
||||
"2026-07-28",
|
||||
)
|
||||
|
||||
if !errors.Is(err, ErrProtocol) {
|
||||
t.Fatalf("QueryCreatedRange() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryOrderRejectsUnexpectedPIIField(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(
|
||||
writer http.ResponseWriter,
|
||||
_ *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)
|
||||
}
|
||||
}
|
||||
@@ -214,7 +214,7 @@ func TestFreightDateSyncAdvancesWatermarkOnlyOnWholeBatchSuccess(
|
||||
if err := store.FailFreightSync(
|
||||
ctx,
|
||||
failed.ID,
|
||||
"ERP_CONNECTOR_UNAVAILABLE",
|
||||
"ERP_UNAVAILABLE",
|
||||
now.Add(time.Hour+time.Minute),
|
||||
); err != nil {
|
||||
t.Fatalf("FailFreightSync() error = %v", err)
|
||||
|
||||
@@ -62,9 +62,10 @@ T-205 原子领取/租约状态机、T-206 Android 登录/有限离线、T-207
|
||||
结构化人工理由和修订历史也已完成。T-214 商品持久身份和重新定位指纹、T-215
|
||||
Admin 候选确认、不可变待投递授权、T-216 设备命令可靠投递及 T-217 已授权商品
|
||||
重新定位与订单 dry-run、T-218 单次订单提交围栏和订单回读,以及 T-219 Admin
|
||||
待付款提醒与端到端验收均已完成。采购任务来源现扩展为顺运宝 ERP:T-220 至
|
||||
T-224 将依次完成安全字段契约、精确单号 Connector、货运信息存储/Admin、待采购
|
||||
需求生成和日期增量同步;完成后再进入 T-301 P0 UI 完整交互验收。
|
||||
待付款提醒与端到端验收均已完成。采购任务来源已扩展为顺运宝 ERP:T-220 至 T-224
|
||||
完成字段契约、货运信息存储/Admin、采购需求生成和日期增量同步;T-225 至 T-228 已将
|
||||
验证码登录和查询统一迁移到 Go API 进程,删除 Python 服务、loopback 端口和共享 API Key。
|
||||
完成后再进入 T-301 P0 UI 完整交互验收。
|
||||
不得直接把候选链接或列表 ordinal 当成授权。
|
||||
手机从管理后端领取任务并回传结果,VLM、拼多多自动化和人工确认在 App 本地完成。
|
||||
T-206 增加有限离线执行;T-207 已复用 Roubao 端上 OpenAI 兼容适配器并加密本地 Key。
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
| 数据访问 | 标准库 `database/sql` | MVP 已定 | 领域层通过仓储接口访问,避免先引入 ORM 和代码生成复杂度。 |
|
||||
| 数据迁移 | 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 工程,模板和静态资源随服务构建。 |
|
||||
| ERP 直连适配 | Go 标准库 `net/http`、`net/http/cookiejar` | T-225 契约已冻结,尚未切换运行时 | Go 侧固定顺运宝请求、分页、详情批量和最小字段归一化;当前 Python loopback Connector 仅在 T-228 前临时保留。 |
|
||||
| ERP 直连适配 | Go 标准库 `net/http`、`net/http/cookiejar` | T-227 已验证 | Go 侧固定顺运宝验证码、登录、会话校验、分页、详情批量和最小字段归一化;不需要 Python 服务或共享 API Key。 |
|
||||
| 数据库 | SQLite | MVP 已定 | 单服务、单设备验证足够;多实例或并发提升前迁移 PostgreSQL。 |
|
||||
| 图片/截图 | 后端受控本地文件目录 + `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。 |
|
||||
@@ -122,12 +122,10 @@ backend-api/
|
||||
internal/config/ # 环境变量和安全默认
|
||||
internal/platform/database/ # SQLite 打开、pragma 和生命周期
|
||||
internal/platform/migration/ # Goose provider 封装
|
||||
internal/platform/shunyunbao/ # ERP 会话、协议、直连查询与 allowlist
|
||||
internal/transport/httpapi/ # Gin 路由、健康检查和 HTTP Server
|
||||
migrations/ # 嵌入式 Goose SQL migration
|
||||
var/ # 本地运行数据,必须忽略
|
||||
erp-connector/
|
||||
src/shunyunbaoerp/ # 顺运宝登录、查询、规范化与内部 HTTP 适配
|
||||
tests/ # 伪响应和脱敏结构测试,不访问线上 ERP
|
||||
docs/
|
||||
```
|
||||
|
||||
@@ -167,8 +165,7 @@ T-205 已在该分层上增加独立 `LifecycleService` 与 SQLite immediate tra
|
||||
PowerShell 等价环境变量,确保依赖没有暗中要求更高 Go 版本。
|
||||
- handler 不直接写 SQL,repository 不依赖 Gin,domain/usecase 不导入具体数据库驱动。
|
||||
- 密钥和设备令牌通过环境变量或本地忽略配置注入,不进入 Git。
|
||||
- T-225 后 Go `internal/platform/shunyunbao` 固定同一套脱敏协议契约;T-226 以前不装配
|
||||
直接会话,运行时仍使用临时 Connector。T-226 的会话只在进程内,不引入 Redis。
|
||||
- ERP 账号、密码、Cookie、JWT、验证码和 Connector 服务密钥只通过环境变量/受控秘密
|
||||
注入;不得进入 SQLite、浏览器、VLM、fixture 或普通日志。Go 适配器不得记录完整
|
||||
响应或 PII。
|
||||
- Go `internal/platform/shunyunbao` 固定同一套脱敏协议契约,并在 API 进程内装配受锁
|
||||
会话与 `FreightSource`;会话只在进程内,不引入 Redis。
|
||||
- ERP 账号、密码、Cookie、JWT 和验证码只通过后端环境变量或受控人工输入流转;不得进入
|
||||
SQLite、浏览器、VLM、fixture 或普通日志。Go 适配器不得记录完整响应或 PII。
|
||||
|
||||
@@ -161,8 +161,8 @@ T-225 冻结了 `internal/platform/shunyunbao` 的协议常量、请求 header
|
||||
启动环境读取。T-227 将此会话直接作为 `FreightSource`:每次同步先校验会话,再以最多
|
||||
100 条、每页 20 条和每批最多 100 个详情 ID 查询;响应不完整、身份冲突或会话失效均使
|
||||
整批失败。进程重启即失去会话,不用 Redis 或持久化 Cookie。货运用例只识别来源中立的
|
||||
“未配置、会话失效、未找到、协议异常、暂时不可用”错误。T-228 再删除未参与运行的旧
|
||||
Python Connector、loopback 端口和服务密钥。
|
||||
“未配置、会话失效、未找到、协议异常、暂时不可用”错误。T-228 已删除旧 Python
|
||||
Connector、loopback 端口和服务密钥;仓库运行时仅保留 Go API 进程。
|
||||
|
||||
模式在 execution 开始时固定并写入结果;AI 失败后只能由人员明确切换,不能静默降级。
|
||||
App 同时固定 provider ID、model、prompt/schema version 和证据 SHA-256,作为非秘密
|
||||
|
||||
@@ -113,7 +113,8 @@
|
||||
recovery 不得把 Authorization、Cookie、panic 或请求正文写普通日志。
|
||||
- SQLite 数据文件必须位于被忽略目录,启用 foreign keys、有限 busy timeout 和
|
||||
WAL;连接由进程入口显式关闭,migration 使用固定版本和受控 SQL 文件。
|
||||
- ERP adapter 只能把版本化 allowlist 归一化对象交给 `FreightSource`;收件人、电话、
|
||||
- ERP adapter 只能把版本化 allowlist 归一化对象交给 `FreightSource`;其 HTTP 会话只在
|
||||
Go API 进程内。收件人、电话、
|
||||
地址、完整响应、Cookie、JWT、账号、密码和验证码不得进入领域对象、错误、日志、
|
||||
fixture、SQLite、浏览器或 VLM。验证码必须由人员输入,禁止 OCR、猜测或重放。
|
||||
|
||||
|
||||
+1
-2
@@ -276,8 +276,7 @@ T-227 的后台 worker 使用当前 Go 内存会话,按 `listTotal -> list 分
|
||||
查询。每次先校验会话;列表最多 100 条、每页 20 条,详情每批最多 100 个外部 stock ID。
|
||||
未配置、未登录、找不到货运单、响应协议错误和暂时不可用分别落为
|
||||
`ERP_NOT_CONFIGURED`、`ERP_SESSION_REQUIRED`、`ERP_FREIGHT_NOT_FOUND`、
|
||||
`ERP_RESPONSE_INVALID` 和 `ERP_UNAVAILABLE`,不返回 ERP 原始错误 body。旧 Python
|
||||
Connector 仅在仓库中等待 T-228 删除,不再是运行时依赖。
|
||||
`ERP_RESPONSE_INVALID` 和 `ERP_UNAVAILABLE`,不返回 ERP 原始错误 body。
|
||||
|
||||
### `POST /api/v1/freight-items/{item_id}/procurement-request`
|
||||
|
||||
|
||||
+16
-16
@@ -5,7 +5,7 @@
|
||||
## 当前快照
|
||||
|
||||
- 日期:2026-07-29
|
||||
- 阶段:T-227 Go 直连 ERP 查询已完成;T-228 待删除遗留 Python Connector
|
||||
- 阶段:T-228 已完成单 Go 后端 ERP 切换;等待真实凭证下的人工验证码登录 smoke
|
||||
- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-219
|
||||
均按文档提交、实现提交的顺序纳入历史
|
||||
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码
|
||||
@@ -22,7 +22,8 @@
|
||||
已增加受锁保护的 Go 内存 Cookie jar、验证码 ticket、登录和用户校验,以及 ADMIN 的
|
||||
`/erp` 页面/API;账号密码仅由 `CMROUBAO_SHUNYUNBAO_*` 启动环境读取,重启后需人工
|
||||
重新登录。T-227 已将其作为 `FreightSource`,查询先校验会话、再执行有界分页/详情
|
||||
批量并返回 allowlist;未访问真实 ERP。旧 Python Connector 仅待 T-228 删除。
|
||||
批量并返回 allowlist;T-228 已删除旧 Python Connector、loopback 端口和共享 API Key;
|
||||
未访问真实 ERP。
|
||||
- ERP 增量同步:v14 支持 Asia/Shanghai 创建日期闭区间和“同步至现在”,source 单窗
|
||||
最多 7 天,后端对较长水位范围切窗并从成功水位前 10 分钟所在自然日回看。
|
||||
货运落库、同步成功和水位推进同事务完成;失败与较旧范围成功不推进水位。Admin
|
||||
@@ -34,12 +35,12 @@
|
||||
- Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建
|
||||
- 测试:T-219 Android Debug/Release 单元测试与构建和根 `init.ps1` 通过;
|
||||
Debug APK `1.4.16 (21)` 已覆盖安装到 PKG110
|
||||
- 后端测试:T-226 已运行 `go test ./...`、`go test -race ./...`、`go vet ./...` 和三个 Go
|
||||
入口构建;T-227 增加 Go source 的伪 ERP 会话预检、完整单号、日期分页去重、详情
|
||||
allowlist 和稳定错误码覆盖,均未访问真实 ERP;
|
||||
- 后端测试:T-226 至 T-228 已运行 `go test ./...`、`go test -race ./...`、`go vet ./...`
|
||||
和三个 Go 入口构建;T-227 增加 Go source 的伪 ERP 会话预检、完整单号、日期分页去重、
|
||||
详情 allowlist 和稳定错误码覆盖;根 `init.ps1` 的 Android 测试/Debug APK 与 Go 标准
|
||||
验证也通过,均未访问真实 ERP;
|
||||
覆盖 v14 上下迁移、7 天切窗、水位重叠、中途失败、空窗口、重复页、来源 revision、
|
||||
水位事务/不回退、Admin API/SSR 和旧 Connector 严格响应窗口;Python 22 项伪响应
|
||||
测试保留至 T-228 删除前,未访问真实 ERP
|
||||
水位事务/不回退、Admin API/SSR 和 Go source 严格响应窗口;未访问真实 ERP
|
||||
- 原型:4 个管理 Web 页面和 7 个 Android 页面均可离线独立打开;Playwright
|
||||
以 1440×900、390×844、360×800 验证 36 个页面/视口组合,无页面横向溢出、
|
||||
脚本错误或外部请求,Android 可见交互控件均不小于 44px
|
||||
@@ -103,10 +104,9 @@
|
||||
对账和唯一对账成功;成功态展示完整订单号、平台时间、授权 SKU/数量/金额及受控
|
||||
证据,只提醒采购员打开拼多多人工核对付款。Roubao 显示相同终态;Web/App 均没有
|
||||
付款、重复提交或自动支付动作。隔离数据库真实 SSR 已通过三个响应式视口验收。
|
||||
- T-220/T-221 ERP 来源:字段契约已固定 stock/detail/item 外部身份、PII 最小化和
|
||||
缺图边界;Python Connector 只监听 loopback,服务密钥鉴权,受控验证码登录后按
|
||||
完整单号读取全部商品并输出 allowlist schema。16 项伪响应测试和真实 HTTP 健康
|
||||
smoke 通过,未访问线上 ERP。
|
||||
- T-220 至 T-228 ERP 来源:字段契约已固定 stock/detail/item 外部身份、PII 最小化和
|
||||
缺图边界;Go API 以受锁内存会话完成受控验证码登录,并直接按完整单号或日期范围查询
|
||||
allowlist schema。旧 Python Connector、8091 端口和共享服务密钥已删除;未访问线上 ERP。
|
||||
- Workflow:纯 Kotlin runner 已支持步骤 timeout、最多 3 次 retry、安全阻塞、
|
||||
用户停止和单 runner 并发拒绝;T-102 已接入搜索加有界候选采集五步
|
||||
- TaskSource:严格 CLI 已生成并验证真实私有 ProbeTask;默认 APK 不含私有 fixture
|
||||
@@ -182,23 +182,23 @@
|
||||
| `docs/tasks/T-225.md` | DONE | 冻结 Go 直连 ERP 协议与安全边界 |
|
||||
| `docs/tasks/T-226.md` | DONE | Go ERP 会话、验证码登录与 Admin 连接页 |
|
||||
| `docs/tasks/T-227.md` | DONE | Go 直连顺运宝查询接入货运同步 |
|
||||
| `docs/tasks/T-228.md` | DONE | 移除 Python Connector 并完成 Go 切换 |
|
||||
| `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 |
|
||||
| `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 |
|
||||
| `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 |
|
||||
| `android-buyer/task-contract/` | 已有 | Android/CLI 共享 ProbeTask 与 TaskSource |
|
||||
| `android-buyer/tools/shopee-importer/` | 已有 | 开发机私有 fixture 导入 CLI |
|
||||
| `backend-api/` | 已有 | Go-Gin 任务 API、SQLite、图片存储、管理 Web 和 migration |
|
||||
| `erp-connector/` | 已有 | 顺运宝 loopback Python Connector 与无网络伪响应测试 |
|
||||
| `init.ps1` / `init.sh` | 已验证/待跨平台 | Windows 同时验证 Android/Go;Unix 入口待 Linux/WSL 复核 |
|
||||
|
||||
## 任务摘要
|
||||
|
||||
- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-219。
|
||||
- 已完成:另含 T-220 至 T-227 ERP 契约、货运存储、采购需求生成、日期增量同步、Go
|
||||
直连协议、人工验证码会话和直连 `FreightSource`。
|
||||
- 已完成:另含 T-220 至 T-228 ERP 契约、货运存储、采购需求生成、日期增量同步、Go
|
||||
直连协议、人工验证码会话、直连 `FreightSource` 和旧 Connector 清理。
|
||||
- 进行中:无。
|
||||
- 下一步:T-228 删除旧 Python Connector、loopback 端口/服务密钥及其文档;真实 ERP
|
||||
上线前仍需确认开放 API、数据使用权限并由人员完成验证码登录。
|
||||
- 下一步:真实 ERP 上线前确认开放 API、数据使用权限,并由人员在 `/erp` 完成验证码
|
||||
登录后以受控单号执行一次不记录订单内容的 smoke。
|
||||
|
||||
## 当前可运行内容
|
||||
|
||||
|
||||
@@ -23,22 +23,22 @@
|
||||
|
||||
## Go 直连迁移契约(T-225)
|
||||
|
||||
T-225 在 `backend-api/internal/platform/shunyunbao` 用脱敏 fixture 固定以下内容,尚未
|
||||
改变当前 Python Connector 运行时路径:
|
||||
T-225 至 T-227 在 `backend-api/internal/platform/shunyunbao` 用脱敏 fixture 固定以下
|
||||
内容,并由 API 进程内的 Go source 直接执行:
|
||||
|
||||
- 基础请求 header 固定 `Accept`、`Origin`、`Referer`、`User-Agent` 和
|
||||
`X-Requested-With`;base URL 必须是无 userinfo、query、fragment 或路径的 origin。
|
||||
- 单号条件为 `t_stock.allcode`、`op=6`、`type=0`、`optType=1`;日期范围为
|
||||
`t_stock.created`、`op=0`、`type=3`、`optType=0`,单个 ERP 窗口最多 7 个自然日。
|
||||
- `listTotal`、`list` 共享 `history=0`、`length`、`start`、`pageIndex`、`store=false`、
|
||||
已验证 columns 和单一 `queries`;每页最多 500 条。
|
||||
已验证 columns 和单一 `queries`;运行时每页固定 20 条、单次最多 100 条。
|
||||
- `listByStock?hist=0` 每批最多 100 个去重后的正整数 stock id,详情 `id` 必须与列表
|
||||
`stock.id` 一致。
|
||||
- 所有结果必须经 Go allowlist 归一化;fixture 专门含收件信息、Cookie/JWT 标记值,
|
||||
测试断言它们不会出现在输出或错误里。
|
||||
|
||||
Go 直连的验证码、登录和 Cookie jar 属于 T-226。不得用本地 OCR 或 Redis 取代人工
|
||||
验证码流程;真实线上请求不属于自动化测试。
|
||||
Go 直连的验证码、登录、Cookie jar 和查询 source 都在 API 进程内。不得用本地 OCR 或
|
||||
Redis 取代人工验证码流程;真实线上请求不属于自动化测试。
|
||||
|
||||
## 身份和规范字段
|
||||
|
||||
|
||||
+23
-6
@@ -4,7 +4,7 @@ title: 移除 Python ERP Connector 并完成 Go 切换
|
||||
phase: 2
|
||||
deps:
|
||||
- T-227
|
||||
status: TODO
|
||||
status: DONE
|
||||
created: 2026-07-29
|
||||
context_ref: d62a4af
|
||||
work_branch: null
|
||||
@@ -18,11 +18,13 @@ write_paths:
|
||||
- docs/api.md
|
||||
- docs/current-state.md
|
||||
- docs/integrations/**
|
||||
- .gitignore
|
||||
- README.md
|
||||
- backend-api/README.md
|
||||
- backend-api/cmd/api/**
|
||||
- backend-api/internal/config/**
|
||||
- backend-api/internal/platform/erpconnector/**
|
||||
- backend-api/internal/repository/sqlite/**
|
||||
- erp-connector/**
|
||||
- scripts/start-erp-connector.bat
|
||||
- start-backend.bat
|
||||
@@ -55,11 +57,11 @@ Go 直连 source 经 T-227 验证后,保留 Python Connector、8091 loopback
|
||||
|
||||
## 验收要点
|
||||
|
||||
- [ ] 仓库不再包含 Python Connector、8091 监听、Connector API Key 或其启动命令。
|
||||
- [ ] Go 后端在 ERP 凭证缺失、未登录和伪 ERP 成功路径下均返回稳定、可操作状态。
|
||||
- [ ] `rg` 证明已跟踪文件无 Connector 运行时引用、真实凭证、Cookie、JWT 或订单样本。
|
||||
- [ ] `go test ./...`、`go test -race ./...`、`go vet ./...` 和根验证通过。
|
||||
- [ ] 文档只说明单 Go 后端、人工验证码和外部 ERP 风险;真实线上 smoke 结果明确记录。
|
||||
- [x] 仓库不再包含 Python Connector、8091 监听、Connector API Key 或其启动命令。
|
||||
- [x] Go 后端在 ERP 凭证缺失、未登录和伪 ERP 成功路径下均返回稳定、可操作状态。
|
||||
- [x] `rg` 证明已跟踪文件无 Connector 运行时引用、真实凭证、Cookie、JWT 或订单样本。
|
||||
- [x] `go test ./...`、`go test -race ./...`、`go vet ./...` 和根验证通过。
|
||||
- [x] 文档只说明单 Go 后端、人工验证码和外部 ERP 风险;真实线上 smoke 结果明确记录。
|
||||
|
||||
## 边界
|
||||
|
||||
@@ -70,3 +72,18 @@ Go 直连 source 经 T-227 验证后,保留 Python Connector、8091 loopback
|
||||
## 执行记录
|
||||
|
||||
- 2026-07-29:由 T-227 依赖创建,等待 Go 查询链路验收后进行删除。
|
||||
- 2026-07-29:开始实施;T-227 已由 `a822edd` 完成。确认外部原始研究脚本不在本次
|
||||
删除范围内,清理仅作用于仓库内旧 Connector、端口、共享服务密钥及其文档。
|
||||
- 2026-07-29:删除 `erp-connector/`、`internal/platform/erpconnector/` 和
|
||||
`scripts/start-erp-connector.bat`,同时移除 `CMROUBAO_ERP_CONNECTOR_*`、8091 默认值、
|
||||
共享 API Key 兼容和旧错误码。外部
|
||||
`D:\chengma\shunyunbaoerp\shunyunbaoerp_single.py` 未读取、未修改、未删除。
|
||||
- 2026-07-29:`start-backend.bat` 只启动 Go 后端;ERP 未配置时 `/erp` 给出可修复状态,
|
||||
同步 run 使用 `ERP_NOT_CONFIGURED` 或 `ERP_SESSION_REQUIRED`,不再依赖第二进程。
|
||||
- 验证:在 `backend-api/` 执行 `$env:GOTOOLCHAIN='local'; go test ./...; go test -race ./...;`
|
||||
`go vet ./...; go build ./cmd/api; go build ./cmd/migrate; go build ./cmd/authctl`,全部通过。
|
||||
根目录执行 `./init.ps1`,Android 测试/Debug APK 和 Go 标准验证全部通过。
|
||||
- 扫描:排除历史任务执行记录后,`rg` 未找到 Connector 运行时引用、8091、共享 API Key、
|
||||
真实凭证、Cookie、JWT 或订单样本;工作区内 `erp-connector/` 已不存在。
|
||||
- 未运行:未配置真实 ERP 凭证,未执行线上验证码/受控单号 smoke;上线前仍需人员完成
|
||||
`/erp` 人工验证码登录,并确认厂商开放 API、数据使用和个人信息处理授权。
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
# 顺运宝 ERP Connector
|
||||
|
||||
内部只读适配器:保持顺运宝验证码/Cookie 会话,按完整单号或最多 7 个自然日的
|
||||
创建日期闭区间查询货运列表和详情,再输出不含收件人、电话、地址、Cookie、JWT
|
||||
或原始响应的规范化 JSON。
|
||||
|
||||
协议客户端基于同一开发机上已经用本地 HAR 验证的 `shunyunbaoerp 0.1.0` 代码收敛;
|
||||
本目录不包含 HAR、真实响应、账号、密码或订单号。
|
||||
|
||||
## 安装和测试
|
||||
|
||||
```powershell
|
||||
Set-Location erp-connector
|
||||
python -m pip install -e ".[api,dev]"
|
||||
pytest
|
||||
```
|
||||
|
||||
测试只使用伪响应,不访问线上 ERP。
|
||||
|
||||
## 启动
|
||||
|
||||
```powershell
|
||||
$env:SHUNYUNBAO_USERNAME = "your_username"
|
||||
$env:SHUNYUNBAO_PASSWORD = "your_password"
|
||||
$env:SHUNYUNBAO_SERVICE_API_KEY = "at-least-32-utf8-bytes"
|
||||
..\scripts\start-erp-connector.bat
|
||||
```
|
||||
|
||||
服务固定监听 `127.0.0.1:8091` 并关闭访问日志。不要把环境变量写入脚本、Git 或命令行
|
||||
参数;生产前应改用受控秘密注入。
|
||||
|
||||
## 受控会话
|
||||
|
||||
1. `GET /v1/session/captcha`,请求头携带 `X-API-Key`。
|
||||
2. 人员查看验证码后调用 `POST /v1/session/login`:
|
||||
|
||||
```json
|
||||
{"captcha_code":"abcd"}
|
||||
```
|
||||
|
||||
3. 调用 `POST /v1/freight/query`:
|
||||
|
||||
```json
|
||||
{"mode":"ORDER_NUMBER","order_number":"完整单号"}
|
||||
```
|
||||
|
||||
日期查询严格使用 Asia/Shanghai 的 `YYYY-MM-DD`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mode":"CREATED_RANGE",
|
||||
"created_from":"2026-07-22",
|
||||
"created_to":"2026-07-28"
|
||||
}
|
||||
```
|
||||
|
||||
Connector 不自动 OCR 绕过验证码。服务重启或 ERP 会话过期后需重新执行前两步。
|
||||
|
||||
## 输出边界
|
||||
|
||||
响应只包含:
|
||||
|
||||
- `external_stock_id`、受控来源单号、店铺、ERP 时间和原始状态。
|
||||
- 全部商品的 `external_item_id`、标题、规格、SKU、数量、`productThumb` 引用和状态。
|
||||
|
||||
`productThumb` 当前可能是数字引用,不是公开图片 URL。下游必须保持 `NEEDS_IMAGE`,
|
||||
不能拼接 URL 或抓取未知主机。
|
||||
@@ -1,38 +0,0 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "shunyunbaoerp"
|
||||
version = "0.1.0"
|
||||
description = "顺运宝 ERP 货运信息查询客户端"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"requests>=2.31,<3",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
api = [
|
||||
"fastapi>=0.110,<1",
|
||||
"uvicorn[standard]>=0.27,<1",
|
||||
]
|
||||
redis = [
|
||||
"redis>=5,<7",
|
||||
]
|
||||
dev = [
|
||||
"pytest>=8,<9",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
shunyunbaoerp = "shunyunbaoerp.cli:main"
|
||||
|
||||
[tool.setuptools]
|
||||
package-dir = {"" = "src"}
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
addopts = "-q"
|
||||
@@ -1,25 +0,0 @@
|
||||
"""顺运宝 ERP 客户端。"""
|
||||
|
||||
from .client import ERPClient
|
||||
from .errors import (
|
||||
ERPAPIError,
|
||||
ERPAuthenticationError,
|
||||
ERPError,
|
||||
ERPNotFoundError,
|
||||
ERPProtocolError,
|
||||
ERPTransportError,
|
||||
)
|
||||
from .normalizer import normalize_freight_result
|
||||
|
||||
__all__ = [
|
||||
"ERPClient",
|
||||
"ERPError",
|
||||
"ERPTransportError",
|
||||
"ERPProtocolError",
|
||||
"ERPAPIError",
|
||||
"ERPAuthenticationError",
|
||||
"ERPNotFoundError",
|
||||
"normalize_freight_result",
|
||||
]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -1,167 +0,0 @@
|
||||
"""可选 FastAPI 包装层,供其他项目通过 HTTP 调用。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException, Response, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .client import ERPClient
|
||||
from .errors import (
|
||||
ERPAPIError,
|
||||
ERPAuthenticationError,
|
||||
ERPNotFoundError,
|
||||
ERPProtocolError,
|
||||
ERPTransportError,
|
||||
)
|
||||
from .normalizer import normalize_freight_result
|
||||
|
||||
app = FastAPI(
|
||||
title="顺运宝 ERP 货运查询服务",
|
||||
version="0.1.0",
|
||||
description="单租户、受控会话的 ERP 查询适配层",
|
||||
)
|
||||
|
||||
_client: ERPClient | None = None
|
||||
_client_lock = threading.Lock()
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
captcha_code: str = Field(min_length=1, max_length=16)
|
||||
|
||||
|
||||
class FreightQueryRequest(BaseModel):
|
||||
mode: str = Field(default="ORDER_NUMBER", max_length=32)
|
||||
order_number: str | None = Field(default=None, min_length=1, max_length=128)
|
||||
created_from: str | None = Field(default=None, min_length=10, max_length=10)
|
||||
created_to: str | None = Field(default=None, min_length=10, max_length=10)
|
||||
|
||||
|
||||
def get_client() -> ERPClient:
|
||||
global _client
|
||||
with _client_lock:
|
||||
if _client is None:
|
||||
_client = ERPClient(
|
||||
os.getenv(
|
||||
"SHUNYUNBAO_BASE_URL",
|
||||
"https://www.shunyunbaoerp.com",
|
||||
)
|
||||
)
|
||||
return _client
|
||||
|
||||
|
||||
def require_api_key(
|
||||
x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
|
||||
) -> None:
|
||||
expected = os.getenv("SHUNYUNBAO_SERVICE_API_KEY")
|
||||
if not expected or len(expected.encode("utf-8")) < 32:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="CONNECTOR_API_KEY_NOT_CONFIGURED",
|
||||
)
|
||||
if not x_api_key or not secrets.compare_digest(x_api_key, expected):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="CONNECTOR_API_KEY_INVALID",
|
||||
)
|
||||
|
||||
|
||||
APIKeyDependency = Annotated[None, Depends(require_api_key)]
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict[str, object]:
|
||||
client = get_client()
|
||||
expires = client.token_expires_at
|
||||
return {
|
||||
"status": "ok",
|
||||
"loggedIn": client.is_logged_in,
|
||||
"tokenExpiresAt": expires.isoformat() if expires else None,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/v1/session/captcha")
|
||||
def captcha(_: APIKeyDependency) -> Response:
|
||||
try:
|
||||
client = get_client()
|
||||
content = client.fetch_captcha()
|
||||
return Response(
|
||||
content=content,
|
||||
media_type=client.last_captcha_content_type,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
except (ERPTransportError, ERPProtocolError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="ERP_CAPTCHA_UNAVAILABLE",
|
||||
) from exc
|
||||
|
||||
|
||||
@app.post("/v1/session/login")
|
||||
def login(body: LoginRequest, _: APIKeyDependency) -> dict[str, object]:
|
||||
username = os.getenv("SHUNYUNBAO_USERNAME")
|
||||
password = os.getenv("SHUNYUNBAO_PASSWORD")
|
||||
if not username or not password:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="ERP_CREDENTIALS_NOT_CONFIGURED",
|
||||
)
|
||||
try:
|
||||
get_client().login(username, password, body.captcha_code)
|
||||
return {"status": "ok"}
|
||||
except ERPAuthenticationError as exc:
|
||||
raise HTTPException(status_code=401, detail="ERP_LOGIN_FAILED") from exc
|
||||
except (ERPTransportError, ERPProtocolError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="ERP_UPSTREAM_UNAVAILABLE",
|
||||
) from exc
|
||||
|
||||
|
||||
@app.post("/v1/freight/query")
|
||||
def query_freight(
|
||||
body: FreightQueryRequest,
|
||||
_: APIKeyDependency,
|
||||
) -> dict[str, object]:
|
||||
try:
|
||||
if body.mode == "ORDER_NUMBER" and body.order_number:
|
||||
if body.created_from is not None or body.created_to is not None:
|
||||
raise ValueError("ORDER_NUMBER 不能包含日期范围")
|
||||
result = get_client().get_freight_details(body.order_number)
|
||||
elif (
|
||||
body.mode == "CREATED_RANGE"
|
||||
and body.order_number is None
|
||||
and body.created_from
|
||||
and body.created_to
|
||||
):
|
||||
result = get_client().get_freight_details_by_created_range(
|
||||
body.created_from,
|
||||
body.created_to,
|
||||
)
|
||||
else:
|
||||
raise ValueError("查询模式与参数不匹配")
|
||||
return normalize_freight_result(result)
|
||||
except ERPAuthenticationError as exc:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="ERP_SESSION_REQUIRED",
|
||||
) from exc
|
||||
except ERPNotFoundError as exc:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="ERP_FREIGHT_NOT_FOUND",
|
||||
) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="ERP_QUERY_INVALID",
|
||||
) from exc
|
||||
except (ERPTransportError, ERPProtocolError, ERPAPIError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="ERP_UPSTREAM_UNAVAILABLE",
|
||||
) from exc
|
||||
@@ -1,99 +0,0 @@
|
||||
"""命令行入口,用于人工验证码登录和查询验证。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import getpass
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
|
||||
from .client import ERPClient
|
||||
from .errors import ERPError
|
||||
from .normalizer import normalize_freight_result
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="顺运宝 ERP 货运查询")
|
||||
parser.add_argument(
|
||||
"--base-url",
|
||||
default=os.getenv("SHUNYUNBAO_BASE_URL", "https://www.shunyunbaoerp.com"),
|
||||
help="ERP 根地址",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--username",
|
||||
default=os.getenv("SHUNYUNBAO_USERNAME"),
|
||||
help="ERP 用户名;默认读取 SHUNYUNBAO_USERNAME",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--captcha-file",
|
||||
type=Path,
|
||||
default=Path(".local/shunyunbao/captcha.png"),
|
||||
help="验证码图片保存路径",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--open-captcha",
|
||||
action="store_true",
|
||||
help="保存后使用系统默认图片查看器打开验证码",
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
subparsers.add_parser("login", help="验证登录链路")
|
||||
query = subparsers.add_parser("query", help="按单号查询货运详情")
|
||||
query.add_argument("order_number", help="ERP 页面“全部单号”查询框中的单号")
|
||||
query.add_argument(
|
||||
"--compact",
|
||||
action="store_true",
|
||||
help="输出紧凑 JSON",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def _credentials(args: argparse.Namespace) -> tuple[str, str]:
|
||||
username = args.username or input("ERP 用户名: ").strip()
|
||||
password = os.getenv("SHUNYUNBAO_PASSWORD") or getpass.getpass("ERP 密码: ")
|
||||
if not username or not password:
|
||||
raise ValueError("用户名和密码不能为空")
|
||||
return username, password
|
||||
|
||||
|
||||
def _interactive_login(client: ERPClient, args: argparse.Namespace) -> None:
|
||||
image = client.fetch_captcha()
|
||||
args.captcha_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.captcha_file.write_bytes(image)
|
||||
absolute_path = args.captcha_file.resolve()
|
||||
print(f"验证码已保存到: {absolute_path}", file=sys.stderr)
|
||||
if args.open_captcha:
|
||||
webbrowser.open(absolute_path.as_uri())
|
||||
|
||||
username, password = _credentials(args)
|
||||
code = input("验证码: ").strip()
|
||||
client.login(username, password, code)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
try:
|
||||
with ERPClient(args.base_url) as client:
|
||||
_interactive_login(client, args)
|
||||
if args.command == "login":
|
||||
expires = client.token_expires_at
|
||||
suffix = f",JWT 提示过期时间 {expires.isoformat()}" if expires else ""
|
||||
print(f"登录成功{suffix}")
|
||||
return 0
|
||||
|
||||
result = normalize_freight_result(
|
||||
client.get_freight_details(args.order_number)
|
||||
)
|
||||
indent = None if args.compact else 2
|
||||
print(json.dumps(result, ensure_ascii=False, indent=indent, default=str))
|
||||
return 0
|
||||
except (ERPError, ValueError, OSError) as exc:
|
||||
print(f"错误: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,558 +0,0 @@
|
||||
"""顺运宝 ERP 登录与货运详情查询客户端。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Iterable, Mapping
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import requests
|
||||
|
||||
from .constants import (
|
||||
CAPTCHA_PATH,
|
||||
LOGIN_PATH,
|
||||
STOCK_DETAIL_PATH,
|
||||
STOCK_LIST_PATH,
|
||||
STOCK_LIST_TOTAL_PATH,
|
||||
created_range_query,
|
||||
order_number_query,
|
||||
stock_columns,
|
||||
)
|
||||
from .errors import (
|
||||
ERPAPIError,
|
||||
ERPAuthenticationError,
|
||||
ERPNotFoundError,
|
||||
ERPProtocolError,
|
||||
ERPTransportError,
|
||||
)
|
||||
|
||||
|
||||
class ERPClient:
|
||||
"""保持 Cookie 会话的同步客户端。
|
||||
|
||||
一个实例对应一个 ERP 登录会话。requests.Session 不是线程安全的,
|
||||
因此所有会话操作都由实例锁串行化。
|
||||
"""
|
||||
|
||||
RETRYABLE_STATUS_CODES = frozenset({429, 502, 503, 504})
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = "https://www.shunyunbaoerp.com",
|
||||
*,
|
||||
timeout: tuple[float, float] = (5.0, 30.0),
|
||||
max_retries: int = 2,
|
||||
page_size: int = 20,
|
||||
max_matches: int = 100,
|
||||
session: requests.Session | None = None,
|
||||
allow_insecure_http: bool = False,
|
||||
) -> None:
|
||||
self.base_url = base_url.rstrip("/") + "/"
|
||||
parsed = urlparse(self.base_url)
|
||||
is_local = parsed.hostname in {"localhost", "127.0.0.1", "::1"}
|
||||
if parsed.scheme != "https" and not (allow_insecure_http or is_local):
|
||||
raise ValueError("ERP 登录包含口令,非本地地址必须使用 HTTPS")
|
||||
if not parsed.netloc:
|
||||
raise ValueError("base_url 必须是完整 URL")
|
||||
if any(value <= 0 for value in timeout):
|
||||
raise ValueError("timeout 必须为正数")
|
||||
if max_retries < 0:
|
||||
raise ValueError("max_retries 不能小于 0")
|
||||
if not 1 <= page_size <= 500:
|
||||
raise ValueError("page_size 必须在 1..500 之间")
|
||||
if max_matches < 1:
|
||||
raise ValueError("max_matches 必须大于 0")
|
||||
|
||||
self.timeout = timeout
|
||||
self.max_retries = max_retries
|
||||
self.page_size = page_size
|
||||
self.max_matches = max_matches
|
||||
self.session = session or requests.Session()
|
||||
origin = f"{parsed.scheme}://{parsed.netloc}"
|
||||
self.session.headers.update(
|
||||
{
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Origin": origin,
|
||||
"Referer": urljoin(self.base_url, "sys/login"),
|
||||
"User-Agent": "shunyunbaoerp-client/0.1",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
}
|
||||
)
|
||||
|
||||
self._lock = threading.RLock()
|
||||
self._captcha_fetched = False
|
||||
self._token: str | None = None
|
||||
self._token_claims: dict[str, Any] = {}
|
||||
self._user: dict[str, Any] | None = None
|
||||
self.last_captcha_content_type = "image/png"
|
||||
|
||||
@property
|
||||
def user(self) -> dict[str, Any] | None:
|
||||
"""登录用户的副本,不包含口令。"""
|
||||
|
||||
return dict(self._user) if self._user else None
|
||||
|
||||
@property
|
||||
def is_logged_in(self) -> bool:
|
||||
"""本地是否持有登录结果;最终有效性仍由服务端会话决定。"""
|
||||
|
||||
return self._user is not None
|
||||
|
||||
@property
|
||||
def token_expires_at(self) -> datetime | None:
|
||||
"""JWT 中的提示性过期时间;不用于验证签名或替代 Cookie。"""
|
||||
|
||||
exp = self._token_claims.get("exp")
|
||||
if not isinstance(exp, (int, float)):
|
||||
return None
|
||||
try:
|
||||
return datetime.fromtimestamp(exp, tz=timezone.utc)
|
||||
except (OverflowError, OSError, ValueError):
|
||||
return None
|
||||
|
||||
def fetch_captcha(self) -> bytes:
|
||||
"""获取验证码图片,并在当前 Session 中保留服务端 Cookie。"""
|
||||
|
||||
with self._lock:
|
||||
url = self._url(CAPTCHA_PATH)
|
||||
try:
|
||||
response = self.session.get(
|
||||
url,
|
||||
params={"_": int(time.time() * 1000)},
|
||||
timeout=self.timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.RequestException as exc:
|
||||
raise ERPTransportError(f"获取验证码失败: {exc}") from exc
|
||||
|
||||
content_type = response.headers.get("Content-Type", "").split(";", 1)[0].strip()
|
||||
if not content_type.startswith("image/"):
|
||||
raise ERPProtocolError(
|
||||
f"验证码接口返回了非图片内容: {content_type or 'unknown'}"
|
||||
)
|
||||
if not response.content:
|
||||
raise ERPProtocolError("验证码接口返回了空图片")
|
||||
|
||||
self.last_captcha_content_type = content_type
|
||||
self._captcha_fetched = True
|
||||
return response.content
|
||||
|
||||
def login(
|
||||
self,
|
||||
username: str,
|
||||
password: str,
|
||||
captcha_code: str,
|
||||
*,
|
||||
require_fetched_captcha: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""使用同一 Session 中取得的验证码登录。
|
||||
|
||||
登录失败后验证码通常失效,调用方应重新 fetch_captcha。
|
||||
"""
|
||||
|
||||
username = username.strip()
|
||||
captcha_code = captcha_code.strip()
|
||||
if not username:
|
||||
raise ValueError("username 不能为空")
|
||||
if not password:
|
||||
raise ValueError("password 不能为空")
|
||||
if not captcha_code:
|
||||
raise ValueError("captcha_code 不能为空")
|
||||
|
||||
with self._lock:
|
||||
if require_fetched_captcha and not self._captcha_fetched:
|
||||
raise ERPProtocolError("必须先用同一个 ERPClient 实例获取验证码")
|
||||
try:
|
||||
data = self._request_json(
|
||||
"POST",
|
||||
LOGIN_PATH,
|
||||
json_body={
|
||||
"username": username,
|
||||
"password": password,
|
||||
"code": captcha_code,
|
||||
},
|
||||
retryable=False,
|
||||
authentication_request=True,
|
||||
)
|
||||
finally:
|
||||
# 验证码按一次性使用处理,无论成功失败都要求重新获取。
|
||||
self._captcha_fetched = False
|
||||
|
||||
if not isinstance(data, Mapping):
|
||||
raise ERPProtocolError("登录成功响应缺少 data 对象")
|
||||
user = data.get("user")
|
||||
token = data.get("token")
|
||||
if not isinstance(user, Mapping):
|
||||
raise ERPProtocolError("登录成功响应缺少 user 对象")
|
||||
|
||||
self._user = dict(user)
|
||||
self._token = token if isinstance(token, str) else None
|
||||
self._token_claims = self._decode_jwt_claims(self._token)
|
||||
return dict(self._user)
|
||||
|
||||
def query_stock_by_order_number(self, order_number: str) -> list[dict[str, Any]]:
|
||||
"""按 HAR 中的“全部单号”条件查询货运列表。
|
||||
|
||||
注意:HAR 样本的输入值匹配返回字段 code,而不是 orderCode。
|
||||
"""
|
||||
|
||||
normalized = self._validate_order_number(order_number)
|
||||
return self._query_stock(order_number_query(normalized))
|
||||
|
||||
def query_stock_by_created_range(
|
||||
self,
|
||||
created_from: date | str,
|
||||
created_to: date | str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""按 Asia/Shanghai 自然日创建时间闭区间查询货运列表。"""
|
||||
|
||||
start, end = self._validate_created_range(created_from, created_to)
|
||||
return self._query_stock(created_range_query(start.isoformat(), end.isoformat()))
|
||||
|
||||
def _query_stock(self, query: Mapping[str, Any]) -> list[dict[str, Any]]:
|
||||
with self._lock:
|
||||
first_payload = self._stock_payload(query, start=0, page_index=1)
|
||||
total_raw = self._request_json(
|
||||
"POST",
|
||||
STOCK_LIST_TOTAL_PATH,
|
||||
json_body=first_payload,
|
||||
retryable=True,
|
||||
)
|
||||
try:
|
||||
total = int(total_raw or 0)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ERPProtocolError("listTotal 返回值不是整数") from exc
|
||||
if total < 0:
|
||||
raise ERPProtocolError("listTotal 返回了负数")
|
||||
if total == 0:
|
||||
return []
|
||||
if total > self.max_matches:
|
||||
raise ERPProtocolError(
|
||||
f"单号查询返回 {total} 条,超过安全上限 {self.max_matches};"
|
||||
"请确认查询条件或显式调高 max_matches"
|
||||
)
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
for start in range(0, total, self.page_size):
|
||||
page_index = start // self.page_size + 1
|
||||
payload = self._stock_payload(
|
||||
query,
|
||||
start=start,
|
||||
page_index=page_index,
|
||||
)
|
||||
page_data = self._request_json(
|
||||
"POST",
|
||||
STOCK_LIST_PATH,
|
||||
json_body=payload,
|
||||
retryable=True,
|
||||
)
|
||||
page_rows = self._extract_list(page_data, endpoint=STOCK_LIST_PATH)
|
||||
rows.extend(page_rows)
|
||||
if not page_rows:
|
||||
break
|
||||
|
||||
# 服务端分页变化时避免重复记录,并将数量严格限制在已报告总数内。
|
||||
unique: list[dict[str, Any]] = []
|
||||
seen_ids: set[object] = set()
|
||||
for row in rows:
|
||||
row_id = row.get("id")
|
||||
key = row_id if row_id is not None else json.dumps(row, sort_keys=True, default=str)
|
||||
if key not in seen_ids:
|
||||
seen_ids.add(key)
|
||||
unique.append(row)
|
||||
return unique[:total]
|
||||
|
||||
def get_stock_details(self, stock_ids: Iterable[int]) -> list[dict[str, Any]]:
|
||||
"""批量查询货运详情;HAR 证明详情 id 与列表 id 一致。"""
|
||||
|
||||
normalized_ids: list[int] = []
|
||||
seen: set[int] = set()
|
||||
for value in stock_ids:
|
||||
if isinstance(value, bool):
|
||||
raise ValueError("stock id 必须是正整数")
|
||||
try:
|
||||
stock_id = int(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("stock id 必须是正整数") from exc
|
||||
if stock_id <= 0:
|
||||
raise ValueError("stock id 必须是正整数")
|
||||
if stock_id not in seen:
|
||||
seen.add(stock_id)
|
||||
normalized_ids.append(stock_id)
|
||||
|
||||
if not normalized_ids:
|
||||
return []
|
||||
|
||||
details: list[dict[str, Any]] = []
|
||||
with self._lock:
|
||||
for offset in range(0, len(normalized_ids), 100):
|
||||
chunk = normalized_ids[offset : offset + 100]
|
||||
data = self._request_json(
|
||||
"POST",
|
||||
STOCK_DETAIL_PATH,
|
||||
params={"hist": 0},
|
||||
json_body={"ids": chunk},
|
||||
retryable=True,
|
||||
)
|
||||
details.extend(self._extract_list(data, endpoint=STOCK_DETAIL_PATH))
|
||||
return details
|
||||
|
||||
def get_freight_details(self, order_number: str) -> dict[str, Any]:
|
||||
"""查询单号并合并列表概要和详情,返回可直接 JSON 序列化的对象。"""
|
||||
|
||||
normalized = self._validate_order_number(order_number)
|
||||
with self._lock:
|
||||
stocks = self.query_stock_by_order_number(normalized)
|
||||
if not stocks:
|
||||
raise ERPNotFoundError("未找到对应货运记录")
|
||||
return self._join_freight_details(
|
||||
stocks,
|
||||
{
|
||||
"mode": "ORDER_NUMBER",
|
||||
"orderNumber": normalized,
|
||||
"matchField": "allcode",
|
||||
},
|
||||
)
|
||||
|
||||
def get_freight_details_by_created_range(
|
||||
self,
|
||||
created_from: date | str,
|
||||
created_to: date | str,
|
||||
) -> dict[str, Any]:
|
||||
"""查询一个最多七天的创建日期窗口并合并详情。"""
|
||||
|
||||
start, end = self._validate_created_range(created_from, created_to)
|
||||
with self._lock:
|
||||
stocks = self.query_stock_by_created_range(start, end)
|
||||
return self._join_freight_details(
|
||||
stocks,
|
||||
{
|
||||
"mode": "CREATED_RANGE",
|
||||
"createdFrom": start.isoformat(),
|
||||
"createdTo": end.isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
def _join_freight_details(
|
||||
self,
|
||||
stocks: list[dict[str, Any]],
|
||||
query: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
ids = [row.get("id") for row in stocks if row.get("id") is not None]
|
||||
if len(ids) != len(stocks):
|
||||
raise ERPProtocolError("货运列表存在缺少 id 的记录")
|
||||
details = self.get_stock_details(ids)
|
||||
details_by_id: dict[object, dict[str, Any]] = {}
|
||||
for item in details:
|
||||
item_id = item.get("id")
|
||||
if item_id is None:
|
||||
raise ERPProtocolError("货运详情存在缺少 id 的记录")
|
||||
existing = details_by_id.get(item_id)
|
||||
if existing is not None and existing != item:
|
||||
raise ERPProtocolError("货运详情存在冲突的重复 id")
|
||||
details_by_id[item_id] = item
|
||||
|
||||
records = [
|
||||
{
|
||||
"stock": stock,
|
||||
"detail": details_by_id.get(stock["id"]),
|
||||
}
|
||||
for stock in stocks
|
||||
]
|
||||
return {
|
||||
"query": dict(query),
|
||||
"count": len(records),
|
||||
"records": records,
|
||||
}
|
||||
|
||||
def close(self) -> None:
|
||||
self.session.close()
|
||||
|
||||
def __enter__(self) -> "ERPClient":
|
||||
return self
|
||||
|
||||
def __exit__(self, *_: object) -> None:
|
||||
self.close()
|
||||
|
||||
def _stock_payload(
|
||||
self,
|
||||
query: Mapping[str, Any],
|
||||
*,
|
||||
start: int,
|
||||
page_index: int,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"history": 0,
|
||||
"length": self.page_size,
|
||||
"start": start,
|
||||
"pageTotal": 0,
|
||||
"pageIndex": page_index,
|
||||
"store": False,
|
||||
"columns": stock_columns(),
|
||||
"queries": [dict(query)],
|
||||
}
|
||||
|
||||
def _request_json(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
params: Mapping[str, Any] | None = None,
|
||||
json_body: Mapping[str, Any] | None = None,
|
||||
retryable: bool,
|
||||
authentication_request: bool = False,
|
||||
) -> Any:
|
||||
url = self._url(path)
|
||||
attempts = self.max_retries + 1 if retryable else 1
|
||||
response: requests.Response | None = None
|
||||
|
||||
for attempt in range(attempts):
|
||||
try:
|
||||
response = self.session.request(
|
||||
method,
|
||||
url,
|
||||
params=params,
|
||||
json=json_body,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
if attempt + 1 >= attempts:
|
||||
raise ERPTransportError(f"ERP 请求失败: {exc}") from exc
|
||||
self._backoff(attempt, retry_after=None)
|
||||
continue
|
||||
|
||||
if response.status_code in self.RETRYABLE_STATUS_CODES and attempt + 1 < attempts:
|
||||
self._backoff(attempt, retry_after=response.headers.get("Retry-After"))
|
||||
continue
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except requests.RequestException as exc:
|
||||
raise ERPTransportError(
|
||||
f"ERP 返回 HTTP {response.status_code}: {path}"
|
||||
) from exc
|
||||
break
|
||||
|
||||
if response is None:
|
||||
raise ERPTransportError("ERP 请求未产生响应")
|
||||
|
||||
refreshed_token = response.headers.get("X-Requested-With")
|
||||
if refreshed_token and refreshed_token.count(".") == 2:
|
||||
self._token = refreshed_token
|
||||
self._token_claims = self._decode_jwt_claims(refreshed_token)
|
||||
|
||||
try:
|
||||
envelope = response.json()
|
||||
except (requests.JSONDecodeError, ValueError) as exc:
|
||||
raise ERPProtocolError(f"ERP 返回了非 JSON 内容: {path}") from exc
|
||||
if not isinstance(envelope, Mapping):
|
||||
raise ERPProtocolError(f"ERP JSON 顶层不是对象: {path}")
|
||||
if envelope.get("status") is not True:
|
||||
code = envelope.get("code")
|
||||
message = str(envelope.get("msg") or "ERP 请求失败")
|
||||
is_auth_error = (
|
||||
authentication_request
|
||||
or str(code) == "-2"
|
||||
or "未登录" in message
|
||||
or "登录过期" in message
|
||||
)
|
||||
error_type = ERPAuthenticationError if is_auth_error else ERPAPIError
|
||||
if not authentication_request and is_auth_error:
|
||||
self._user = None
|
||||
self._token = None
|
||||
self._token_claims = {}
|
||||
raise error_type(message, code=code)
|
||||
if "data" not in envelope:
|
||||
raise ERPProtocolError(f"ERP 成功响应缺少 data: {path}")
|
||||
return envelope["data"]
|
||||
|
||||
@staticmethod
|
||||
def _extract_list(data: Any, *, endpoint: str) -> list[dict[str, Any]]:
|
||||
if not isinstance(data, Mapping):
|
||||
raise ERPProtocolError(f"{endpoint} 的 data 不是对象")
|
||||
values = data.get("list")
|
||||
if not isinstance(values, list):
|
||||
raise ERPProtocolError(f"{endpoint} 的 data.list 不是数组")
|
||||
if not all(isinstance(value, Mapping) for value in values):
|
||||
raise ERPProtocolError(f"{endpoint} 的 data.list 含非对象元素")
|
||||
return [dict(value) for value in values]
|
||||
|
||||
def _url(self, path: str) -> str:
|
||||
return urljoin(self.base_url, path.lstrip("/"))
|
||||
|
||||
@staticmethod
|
||||
def _validate_order_number(value: str) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("order_number 必须是字符串")
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
raise ValueError("order_number 不能为空")
|
||||
if len(normalized) > 128:
|
||||
raise ValueError("order_number 不能超过 128 个字符")
|
||||
if any(ord(char) < 32 or ord(char) == 127 for char in normalized):
|
||||
raise ValueError("order_number 不能包含控制字符")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _validate_created_range(
|
||||
created_from: date | str,
|
||||
created_to: date | str,
|
||||
) -> tuple[date, date]:
|
||||
def parse(value: date | str, field: str) -> date:
|
||||
if isinstance(value, datetime):
|
||||
raise ValueError(f"{field} 必须是 YYYY-MM-DD 日期")
|
||||
if isinstance(value, date):
|
||||
return value
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"{field} 必须是 YYYY-MM-DD 日期")
|
||||
try:
|
||||
parsed = date.fromisoformat(value)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"{field} 必须是 YYYY-MM-DD 日期") from exc
|
||||
if parsed.isoformat() != value:
|
||||
raise ValueError(f"{field} 必须是 YYYY-MM-DD 日期")
|
||||
return parsed
|
||||
|
||||
start = parse(created_from, "created_from")
|
||||
end = parse(created_to, "created_to")
|
||||
if end < start:
|
||||
raise ValueError("created_to 不能早于 created_from")
|
||||
if end - start > timedelta(days=6):
|
||||
raise ValueError("创建日期闭区间不能超过 7 天")
|
||||
return start, end
|
||||
|
||||
@staticmethod
|
||||
def _decode_jwt_claims(token: str | None) -> dict[str, Any]:
|
||||
"""仅解码 JWT payload 供过期时间展示,不验证其真实性。"""
|
||||
|
||||
if not token:
|
||||
return {}
|
||||
parts = token.split(".")
|
||||
if len(parts) != 3:
|
||||
return {}
|
||||
payload = parts[1] + "=" * (-len(parts[1]) % 4)
|
||||
try:
|
||||
decoded = base64.urlsafe_b64decode(payload.encode("ascii"))
|
||||
claims = json.loads(decoded.decode("utf-8"))
|
||||
except (ValueError, UnicodeDecodeError, json.JSONDecodeError):
|
||||
return {}
|
||||
return claims if isinstance(claims, dict) else {}
|
||||
|
||||
@staticmethod
|
||||
def _backoff(attempt: int, *, retry_after: str | None) -> None:
|
||||
delay: float
|
||||
if retry_after:
|
||||
try:
|
||||
delay = min(max(float(retry_after), 0.0), 10.0)
|
||||
except ValueError:
|
||||
delay = 0.0
|
||||
else:
|
||||
delay = 0.0
|
||||
if delay == 0.0:
|
||||
delay = min(0.5 * (2**attempt) + random.uniform(0.0, 0.25), 5.0)
|
||||
time.sleep(delay)
|
||||
@@ -1,131 +0,0 @@
|
||||
"""从 HAR 提取的稳定接口常量和列表列定义。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
CAPTCHA_PATH = "/api/p/code1"
|
||||
LOGIN_PATH = "/am/auth/login"
|
||||
STOCK_LIST_TOTAL_PATH = "/am/stock/listTotal"
|
||||
STOCK_LIST_PATH = "/am/stock/list"
|
||||
STOCK_DETAIL_PATH = "/am/stock/detail/listByStock"
|
||||
|
||||
# (tableName, colName, fieldName, hasAlias, tableAlias)
|
||||
_STOCK_COLUMN_SPECS = (
|
||||
("t_stock", "created", "created", 0, "t"),
|
||||
("t_stock", "order_code", "orderCode", 0, "t"),
|
||||
("t_stock", "printer", "printer", 0, "t"),
|
||||
("t_stock", "weight_time", "weightTime", 0, "t"),
|
||||
("t_stock", "weight_inputer", "weightInputer", 0, "t"),
|
||||
("t_stock", "pkg_time", "pkgTime", 0, "t"),
|
||||
("t_stock", "code", "code", 0, "t"),
|
||||
("t_stock", "status", "status", 0, "t"),
|
||||
("t_stock", "order_status", "orderStatus", 0, "t"),
|
||||
("t_stock", "purchase_status", "purchaseStatus", 0, "t"),
|
||||
("t_stock", "exp_code", "expCode", 0, "t"),
|
||||
("t_stock", "exp_page_code", "expPageCode", 0, "t"),
|
||||
("t_stock", "exp_allow_print", "expAllowPrint", 0, "t"),
|
||||
("t_stock", "exp_page_status", "expPageStatus", 0, "t"),
|
||||
("t_stock", "page_id", "pageId", 0, "t"),
|
||||
("t_stock", "upload_time", "uploadTime", 0, "t"),
|
||||
("t_stock", "pay_time", "payTime", 0, "t"),
|
||||
("t_stock", "shop_day_to_ship", "shopDayToShip", 0, "t"),
|
||||
("t_stock", "remark9", "tsremark9", 1, "t"),
|
||||
("t_stock", "remark9", "remark9", 0, "t"),
|
||||
("t_stock", "detail_qty", "detailQty", 0, "t"),
|
||||
("t_stock", "order_qty", "orderQty", 0, "t"),
|
||||
("t_stock_detail", "inner_exp_code", "innerExpCode", 0, "t7"),
|
||||
("t_stock_detail", "shelf_code", "shelfCode", 0, "t7"),
|
||||
("t_stock", "shelf_code", "tsshelfCode", 1, "t"),
|
||||
("t_stock", "store_type", "storeType", 0, "t"),
|
||||
("t_stock", "order_bag_code", "orderBagCode", 0, "t"),
|
||||
("t_stock", "weight_cust_pkg", "weightCustPkg", 0, "t"),
|
||||
("t_stock", "weight_consign", "weightConsign", 0, "t"),
|
||||
("t_stock", "amt_order", "amtOrder", 0, "t"),
|
||||
("t_stock_append", "offline_amount", "offlineAmount", 0, "t8"),
|
||||
("t_stock_append", "escrow_amount", "escrowAmount", 0, "t8"),
|
||||
("t_stock", "exp_cod", "expCod", 0, "t"),
|
||||
("t_stock", "exp_company", "expCompany", 0, "t"),
|
||||
("t_stock", "transport", "transport", 0, "t"),
|
||||
("t_stock", "order_origin", "orderOrigin", 0, "t"),
|
||||
("t_stock", "exp_out_type", "expOutType", 0, "t"),
|
||||
("t_stock", "order_platform", "orderPlatform", 0, "t"),
|
||||
("t_stock", "exp_pkg_type", "expPkgType", 0, "t"),
|
||||
("t_stock", "exp_pkg_code", "expPkgCode", 0, "t"),
|
||||
("t_stock", "exp_batch", "expBatch", 0, "t"),
|
||||
("t_stock", "exp_ti_huo", "expTiHuo", 0, "t"),
|
||||
("t_stock", "print_time", "printTime", 0, "t"),
|
||||
("t_stock", "receiver", "receiver", 0, "t"),
|
||||
("t_stock", "receiver_tel", "receiverTel", 0, "t"),
|
||||
("t_stock", "receiver_addr", "receiverAddr", 0, "t"),
|
||||
("t_stock", "receiver_shop_name", "receiverShopName", 0, "t"),
|
||||
("t_stock", "receiver_shop_code", "receiverShopCode", 0, "t"),
|
||||
("t_stock", "product_name", "productName", 0, "t"),
|
||||
("t_stock", "is_cancel", "isCancel", 0, "t"),
|
||||
("t_stock", "err_msg", "errMsg", 0, "t"),
|
||||
("t_stock", "remark2", "remark2", 0, "t"),
|
||||
("t_stock", "remark1", "remark1", 0, "t"),
|
||||
("t_stock", "note", "note", 0, "t"),
|
||||
("t_store", "name", "name", 0, "t1"),
|
||||
("sys_user", "fullname", "sufullname", 1, "t3"),
|
||||
("sys_user", "dept_label_path", "deptLabelPath", 0, "t3"),
|
||||
("t_stock", "shop_name", "shopName", 0, "t"),
|
||||
("t_shop", "shop_id", "shopId", 0, "t6"),
|
||||
("t_stock", "remark4", "remark4", 0, "t"),
|
||||
("t_stock", "remark7", "remark7", 0, "t"),
|
||||
("t_stock", "package_time", "packageTime", 0, "t"),
|
||||
("t_stock", "packer", "packer", 0, "t"),
|
||||
("t_stock", "pack_type", "packType", 0, "t"),
|
||||
("t_stock", "track_status", "trackStatus", 0, "t"),
|
||||
("t_stock", "track_desc", "trackDesc", 0, "t"),
|
||||
("t_stock", "err_status", "errStatus", 0, "t"),
|
||||
("t_stock", "err_time", "errTime", 0, "t"),
|
||||
("t_stock", "err_msg", "tserrMsg", 1, "t"),
|
||||
("t_stock", "remark2", "tsremark2", 1, "t"),
|
||||
("t_stock", "remark1", "tsremark1", 1, "t"),
|
||||
("t_stock", "product_volume_str", "productVolumeStr", 0, "t"),
|
||||
)
|
||||
|
||||
|
||||
def stock_columns() -> list[dict[str, Any]]:
|
||||
"""返回新的列定义列表,避免调用方修改全局模板。"""
|
||||
|
||||
return [
|
||||
{
|
||||
"tableName": table_name,
|
||||
"colName": column_name,
|
||||
"fieldName": field_name,
|
||||
"hasAlias": has_alias,
|
||||
"tableAlias": table_alias,
|
||||
}
|
||||
for table_name, column_name, field_name, has_alias, table_alias in _STOCK_COLUMN_SPECS
|
||||
]
|
||||
|
||||
|
||||
def order_number_query(order_number: str) -> dict[str, Any]:
|
||||
"""生成 HAR 中“全部单号”精确查询条件。"""
|
||||
|
||||
return {
|
||||
"dvalue": order_number,
|
||||
"tableName": "t_stock",
|
||||
"colName": "allcode",
|
||||
"op": 6,
|
||||
"type": 0,
|
||||
"tableAlias": "t",
|
||||
"optType": 1,
|
||||
}
|
||||
|
||||
|
||||
def created_range_query(created_from: str, created_to: str) -> dict[str, Any]:
|
||||
"""生成 HAR 中“创建时间”闭区间查询条件。"""
|
||||
|
||||
return {
|
||||
"dvalue": f"{created_from},{created_to}",
|
||||
"tableName": "t_stock",
|
||||
"colName": "created",
|
||||
"op": 0,
|
||||
"type": 3,
|
||||
"tableAlias": "t",
|
||||
"optType": 0,
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
"""客户端异常定义。"""
|
||||
|
||||
|
||||
class ERPError(RuntimeError):
|
||||
"""所有顺运宝客户端异常的基类。"""
|
||||
|
||||
|
||||
class ERPTransportError(ERPError):
|
||||
"""网络、超时或 HTTP 状态异常。"""
|
||||
|
||||
|
||||
class ERPProtocolError(ERPError):
|
||||
"""ERP 响应不符合预期协议。"""
|
||||
|
||||
|
||||
class ERPAPIError(ERPError):
|
||||
"""ERP 返回 status=false。"""
|
||||
|
||||
def __init__(self, message: str, *, code: object = None) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
class ERPAuthenticationError(ERPAPIError):
|
||||
"""登录失败、验证码错误或会话过期。"""
|
||||
|
||||
|
||||
class ERPNotFoundError(ERPError):
|
||||
"""未查到对应货运记录。"""
|
||||
@@ -1,179 +0,0 @@
|
||||
"""把 ERP 原始对象缩减为下游允许保存的货运字段。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from .errors import ERPProtocolError
|
||||
|
||||
|
||||
def normalize_freight_result(result: Mapping[str, Any]) -> dict[str, Any]:
|
||||
normalized_query = _normalize_query(result.get("query"))
|
||||
records = result.get("records")
|
||||
if not isinstance(records, list):
|
||||
raise ERPProtocolError("货运查询结果缺少 records 数组")
|
||||
|
||||
orders: list[dict[str, Any]] = []
|
||||
seen_orders: dict[str, dict[str, Any]] = {}
|
||||
for record in records:
|
||||
if not isinstance(record, Mapping):
|
||||
raise ERPProtocolError("货运查询结果包含非对象记录")
|
||||
stock = record.get("stock")
|
||||
detail = record.get("detail")
|
||||
if not isinstance(stock, Mapping) or not isinstance(detail, Mapping):
|
||||
raise ERPProtocolError("货运记录缺少 stock 或 detail 对象")
|
||||
|
||||
stock_id = _external_id(stock.get("id"), "货运单")
|
||||
detail_id = _external_id(detail.get("id"), "货运详情")
|
||||
if stock_id != detail_id:
|
||||
raise ERPProtocolError("货运列表和详情身份不一致")
|
||||
|
||||
raw_items = detail.get("details")
|
||||
if not isinstance(raw_items, list):
|
||||
raise ERPProtocolError("货运详情缺少 details 数组")
|
||||
item_by_id: dict[str, dict[str, Any]] = {}
|
||||
for raw_item in raw_items:
|
||||
if not isinstance(raw_item, Mapping):
|
||||
raise ERPProtocolError("货运商品明细包含非对象元素")
|
||||
item = _normalize_item(raw_item)
|
||||
existing = item_by_id.get(item["external_item_id"])
|
||||
if existing is not None and existing != item:
|
||||
raise ERPProtocolError("货运商品明细存在冲突的重复身份")
|
||||
item_by_id[item["external_item_id"]] = item
|
||||
|
||||
order = {
|
||||
"external_stock_id": stock_id,
|
||||
"source_code": _text(stock.get("code")),
|
||||
"platform_order_no": _optional_text(stock.get("orderCode")),
|
||||
"shop_name": _optional_text(
|
||||
detail.get("shopName")
|
||||
if _text(detail.get("shopName"))
|
||||
else stock.get("shopName")
|
||||
),
|
||||
"source_created_at": _optional_text(
|
||||
detail.get("created")
|
||||
if _text(detail.get("created"))
|
||||
else stock.get("created")
|
||||
),
|
||||
"order_status": _optional_scalar(
|
||||
stock.get("orderStatus")
|
||||
if stock.get("orderStatus") is not None
|
||||
else detail.get("status")
|
||||
),
|
||||
"purchase_status": _optional_scalar(stock.get("purchaseStatus")),
|
||||
"is_canceled": _optional_bool(stock.get("isCancel")),
|
||||
"items": sorted(
|
||||
item_by_id.values(),
|
||||
key=lambda value: _identity_sort_key(
|
||||
value["external_item_id"]
|
||||
),
|
||||
),
|
||||
}
|
||||
existing_order = seen_orders.get(stock_id)
|
||||
if existing_order is not None and existing_order != order:
|
||||
raise ERPProtocolError("货运查询结果存在冲突的重复货运单")
|
||||
seen_orders[stock_id] = order
|
||||
|
||||
orders.extend(
|
||||
sorted(
|
||||
seen_orders.values(),
|
||||
key=lambda value: _identity_sort_key(
|
||||
value["external_stock_id"]
|
||||
),
|
||||
)
|
||||
)
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"query": normalized_query,
|
||||
"orders": orders,
|
||||
}
|
||||
|
||||
|
||||
def _normalize_query(value: Any) -> dict[str, Any]:
|
||||
if not isinstance(value, Mapping):
|
||||
raise ERPProtocolError("货运查询结果缺少 query 对象")
|
||||
mode = value.get("mode", "ORDER_NUMBER")
|
||||
if mode == "ORDER_NUMBER":
|
||||
return {"mode": "ORDER_NUMBER"}
|
||||
if mode != "CREATED_RANGE":
|
||||
raise ERPProtocolError("货运查询模式无效")
|
||||
created_from = _text(value.get("createdFrom"))
|
||||
created_to = _text(value.get("createdTo"))
|
||||
if len(created_from) != 10 or len(created_to) != 10:
|
||||
raise ERPProtocolError("货运日期范围无效")
|
||||
return {
|
||||
"mode": "CREATED_RANGE",
|
||||
"created_from": created_from,
|
||||
"created_to": created_to,
|
||||
}
|
||||
|
||||
|
||||
def _normalize_item(item: Mapping[str, Any]) -> dict[str, Any]:
|
||||
title = _text(item.get("productTitle"))
|
||||
if not title:
|
||||
title = _text(item.get("detailProductName"))
|
||||
sku = _text(item.get("sku"))
|
||||
if not sku:
|
||||
sku = _text(item.get("variationSku"))
|
||||
return {
|
||||
"external_item_id": _external_id(item.get("id"), "货运商品"),
|
||||
"title": title,
|
||||
"product_spec": _text(item.get("productSpec")),
|
||||
"sku": sku,
|
||||
"quantity": _positive_int_or_none(item.get("productQty")),
|
||||
"product_thumb_ref": _optional_scalar(item.get("productThumb")),
|
||||
"purchase_status": _optional_scalar(item.get("purchaseStatus")),
|
||||
}
|
||||
|
||||
|
||||
def _external_id(value: Any, label: str) -> str:
|
||||
if isinstance(value, bool):
|
||||
raise ERPProtocolError(f"{label}外部身份无效")
|
||||
try:
|
||||
normalized = int(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ERPProtocolError(f"{label}外部身份无效") from exc
|
||||
if normalized <= 0:
|
||||
raise ERPProtocolError(f"{label}外部身份无效")
|
||||
return str(normalized)
|
||||
|
||||
|
||||
def _positive_int_or_none(value: Any) -> int | None:
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
normalized = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return normalized if normalized > 0 else None
|
||||
|
||||
|
||||
def _text(value: Any) -> str:
|
||||
return value.strip() if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def _optional_text(value: Any) -> str | None:
|
||||
normalized = _text(value)
|
||||
return normalized or None
|
||||
|
||||
|
||||
def _optional_scalar(value: Any) -> str | None:
|
||||
if value is None or isinstance(value, (dict, list, tuple, set)):
|
||||
return None
|
||||
normalized = str(value).strip()
|
||||
return normalized or None
|
||||
|
||||
|
||||
def _optional_bool(value: Any) -> bool | None:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value in (0, "0"):
|
||||
return False
|
||||
if value in (1, "1"):
|
||||
return True
|
||||
return None
|
||||
|
||||
|
||||
def _identity_sort_key(value: str) -> tuple[int, str]:
|
||||
return (int(value), value)
|
||||
@@ -1,111 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from shunyunbaoerp import api
|
||||
|
||||
from test_normalizer import raw_result
|
||||
|
||||
|
||||
VALID_KEY = "k" * 32
|
||||
|
||||
|
||||
class FakeClient:
|
||||
is_logged_in = True
|
||||
token_expires_at = None
|
||||
|
||||
def get_freight_details(self, order_number: str) -> dict:
|
||||
assert order_number == "SOURCE-12"
|
||||
return raw_result()
|
||||
|
||||
def get_freight_details_by_created_range(
|
||||
self,
|
||||
created_from: str,
|
||||
created_to: str,
|
||||
) -> dict:
|
||||
assert (created_from, created_to) == ("2026-07-22", "2026-07-28")
|
||||
result = raw_result()
|
||||
result["query"] = {
|
||||
"mode": "CREATED_RANGE",
|
||||
"createdFrom": created_from,
|
||||
"createdTo": created_to,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def test_query_requires_configured_service_key(monkeypatch) -> None:
|
||||
monkeypatch.delenv("SHUNYUNBAO_SERVICE_API_KEY", raising=False)
|
||||
|
||||
with pytest.raises(HTTPException) as raised:
|
||||
api.require_api_key(VALID_KEY)
|
||||
|
||||
assert raised.value.status_code == 503
|
||||
assert raised.value.detail == "CONNECTOR_API_KEY_NOT_CONFIGURED"
|
||||
|
||||
|
||||
def test_query_rejects_wrong_service_key(monkeypatch) -> None:
|
||||
monkeypatch.setenv("SHUNYUNBAO_SERVICE_API_KEY", VALID_KEY)
|
||||
|
||||
with pytest.raises(HTTPException) as raised:
|
||||
api.require_api_key("x" * 32)
|
||||
|
||||
assert raised.value.status_code == 401
|
||||
assert raised.value.detail == "CONNECTOR_API_KEY_INVALID"
|
||||
|
||||
|
||||
def test_query_returns_only_normalized_fields(monkeypatch) -> None:
|
||||
monkeypatch.setenv("SHUNYUNBAO_SERVICE_API_KEY", VALID_KEY)
|
||||
monkeypatch.setattr(api, "get_client", lambda: FakeClient())
|
||||
api.require_api_key(VALID_KEY)
|
||||
response = api.query_freight(
|
||||
api.FreightQueryRequest(order_number="SOURCE-12"),
|
||||
None,
|
||||
)
|
||||
|
||||
assert len(response["orders"][0]["items"]) == 2
|
||||
encoded = json.dumps(response, ensure_ascii=False)
|
||||
assert "receiverTel" not in encoded
|
||||
assert "receiverAddr" not in encoded
|
||||
assert "PRIVATE-QUERY" not in encoded
|
||||
|
||||
|
||||
def test_created_range_query_returns_allowlisted_range(monkeypatch) -> None:
|
||||
monkeypatch.setenv("SHUNYUNBAO_SERVICE_API_KEY", VALID_KEY)
|
||||
monkeypatch.setattr(api, "get_client", lambda: FakeClient())
|
||||
|
||||
response = api.query_freight(
|
||||
api.FreightQueryRequest(
|
||||
mode="CREATED_RANGE",
|
||||
created_from="2026-07-22",
|
||||
created_to="2026-07-28",
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
assert response["query"] == {
|
||||
"mode": "CREATED_RANGE",
|
||||
"created_from": "2026-07-22",
|
||||
"created_to": "2026-07-28",
|
||||
}
|
||||
|
||||
|
||||
def test_created_range_rejects_mixed_mode_parameters(monkeypatch) -> None:
|
||||
monkeypatch.setenv("SHUNYUNBAO_SERVICE_API_KEY", VALID_KEY)
|
||||
monkeypatch.setattr(api, "get_client", lambda: FakeClient())
|
||||
|
||||
with pytest.raises(HTTPException) as raised:
|
||||
api.query_freight(
|
||||
api.FreightQueryRequest(
|
||||
mode="CREATED_RANGE",
|
||||
order_number="SOURCE-12",
|
||||
created_from="2026-07-22",
|
||||
created_to="2026-07-28",
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
assert raised.value.status_code == 422
|
||||
assert raised.value.detail == "ERP_QUERY_INVALID"
|
||||
@@ -1,242 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from datetime import date
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from shunyunbaoerp import (
|
||||
ERPAuthenticationError,
|
||||
ERPClient,
|
||||
ERPNotFoundError,
|
||||
ERPProtocolError,
|
||||
)
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(
|
||||
self,
|
||||
payload=None,
|
||||
*,
|
||||
status_code: int = 200,
|
||||
content: bytes = b"",
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
self._payload = payload
|
||||
self.status_code = status_code
|
||||
self.content = content
|
||||
self.headers = headers or {"Content-Type": "application/json"}
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
if self.status_code >= 400:
|
||||
raise requests.HTTPError(str(self.status_code))
|
||||
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self, responses: list[FakeResponse]) -> None:
|
||||
self.responses = list(responses)
|
||||
self.headers: dict[str, str] = {}
|
||||
self.calls: list[dict[str, object]] = []
|
||||
self.closed = False
|
||||
|
||||
def get(self, url: str, **kwargs):
|
||||
self.calls.append({"method": "GET", "url": url, **kwargs})
|
||||
return self.responses.pop(0)
|
||||
|
||||
def request(self, method: str, url: str, **kwargs):
|
||||
self.calls.append({"method": method, "url": url, **kwargs})
|
||||
return self.responses.pop(0)
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
def envelope(data, *, status=True, msg="成功", code=None):
|
||||
return {"status": status, "msg": msg, "data": data, "code": code}
|
||||
|
||||
|
||||
def fake_jwt(exp: int = 2_000_000_000) -> str:
|
||||
encode = lambda value: base64.urlsafe_b64encode(
|
||||
json.dumps(value, separators=(",", ":")).encode()
|
||||
).decode().rstrip("=")
|
||||
return f"{encode({'alg': 'none'})}.{encode({'exp': exp, 'username': 'demo'})}.signature"
|
||||
|
||||
|
||||
def test_login_keeps_captcha_and_login_in_same_session() -> None:
|
||||
session = FakeSession(
|
||||
[
|
||||
FakeResponse(
|
||||
content=b"image",
|
||||
headers={"Content-Type": "image/png", "Set-Cookie": "omitted"},
|
||||
),
|
||||
FakeResponse(
|
||||
envelope(
|
||||
{
|
||||
"user": {"id": 1, "username": "demo"},
|
||||
"token": fake_jwt(),
|
||||
}
|
||||
)
|
||||
),
|
||||
]
|
||||
)
|
||||
client = ERPClient(session=session)
|
||||
|
||||
assert client.fetch_captcha() == b"image"
|
||||
user = client.login("demo", "secret", "abcd")
|
||||
|
||||
assert user["id"] == 1
|
||||
assert client.is_logged_in is True
|
||||
assert client.token_expires_at is not None
|
||||
assert urlparse(session.calls[0]["url"]).path == "/api/p/code1"
|
||||
assert urlparse(session.calls[1]["url"]).path == "/am/auth/login"
|
||||
assert session.calls[1]["json"] == {
|
||||
"username": "demo",
|
||||
"password": "secret",
|
||||
"code": "abcd",
|
||||
}
|
||||
|
||||
|
||||
def test_login_requires_captcha_from_same_client() -> None:
|
||||
client = ERPClient(session=FakeSession([]))
|
||||
|
||||
with pytest.raises(ERPProtocolError):
|
||||
client.login("demo", "secret", "abcd")
|
||||
|
||||
|
||||
def test_query_and_detail_reproduce_har_contract() -> None:
|
||||
stock = {
|
||||
"id": 99001122,
|
||||
"code": "FREIGHT-001",
|
||||
"orderCode": "PLATFORM-001",
|
||||
}
|
||||
detail = {
|
||||
"id": 99001122,
|
||||
"code": "FREIGHT-001",
|
||||
"details": [{"id": 2, "productQty": 1}],
|
||||
}
|
||||
session = FakeSession(
|
||||
[
|
||||
FakeResponse(envelope(1)),
|
||||
FakeResponse(envelope({"total": 1, "list": [stock]})),
|
||||
FakeResponse(envelope({"total": 1, "list": [detail]})),
|
||||
]
|
||||
)
|
||||
client = ERPClient(session=session)
|
||||
|
||||
result = client.get_freight_details("FREIGHT-001")
|
||||
|
||||
assert result["count"] == 1
|
||||
assert result["records"][0] == {"stock": stock, "detail": detail}
|
||||
assert [urlparse(call["url"]).path for call in session.calls] == [
|
||||
"/am/stock/listTotal",
|
||||
"/am/stock/list",
|
||||
"/am/stock/detail/listByStock",
|
||||
]
|
||||
|
||||
query = session.calls[0]["json"]["queries"][0]
|
||||
assert query == {
|
||||
"dvalue": "FREIGHT-001",
|
||||
"tableName": "t_stock",
|
||||
"colName": "allcode",
|
||||
"op": 6,
|
||||
"type": 0,
|
||||
"tableAlias": "t",
|
||||
"optType": 1,
|
||||
}
|
||||
assert len(session.calls[0]["json"]["columns"]) == 72
|
||||
assert session.calls[2]["params"] == {"hist": 0}
|
||||
assert session.calls[2]["json"] == {"ids": [99001122]}
|
||||
|
||||
|
||||
def test_created_range_query_reproduces_har_contract_and_deduplicates() -> None:
|
||||
stock = {"id": 99001122, "code": "FREIGHT-001"}
|
||||
detail = {"id": 99001122, "details": []}
|
||||
session = FakeSession(
|
||||
[
|
||||
FakeResponse(envelope(2)),
|
||||
FakeResponse(envelope({"total": 2, "list": [stock, stock]})),
|
||||
FakeResponse(envelope({"total": 1, "list": [detail]})),
|
||||
]
|
||||
)
|
||||
client = ERPClient(session=session)
|
||||
|
||||
result = client.get_freight_details_by_created_range(
|
||||
date(2026, 7, 22),
|
||||
date(2026, 7, 28),
|
||||
)
|
||||
|
||||
assert result["count"] == 1
|
||||
assert result["query"] == {
|
||||
"mode": "CREATED_RANGE",
|
||||
"createdFrom": "2026-07-22",
|
||||
"createdTo": "2026-07-28",
|
||||
}
|
||||
assert session.calls[0]["json"]["queries"] == [
|
||||
{
|
||||
"dvalue": "2026-07-22,2026-07-28",
|
||||
"tableName": "t_stock",
|
||||
"colName": "created",
|
||||
"op": 0,
|
||||
"type": 3,
|
||||
"tableAlias": "t",
|
||||
"optType": 0,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_created_range_rejects_more_than_seven_inclusive_days() -> None:
|
||||
client = ERPClient(session=FakeSession([]))
|
||||
|
||||
with pytest.raises(ValueError, match="7 天"):
|
||||
client.query_stock_by_created_range("2026-07-21", "2026-07-28")
|
||||
|
||||
|
||||
def test_empty_created_range_does_not_request_details() -> None:
|
||||
session = FakeSession([FakeResponse(envelope(0))])
|
||||
client = ERPClient(session=session)
|
||||
|
||||
result = client.get_freight_details_by_created_range(
|
||||
"2026-07-28",
|
||||
"2026-07-28",
|
||||
)
|
||||
|
||||
assert result["records"] == []
|
||||
assert len(session.calls) == 1
|
||||
|
||||
|
||||
def test_not_found_stops_before_detail_request() -> None:
|
||||
session = FakeSession([FakeResponse(envelope(0))])
|
||||
client = ERPClient(session=session)
|
||||
|
||||
with pytest.raises(ERPNotFoundError):
|
||||
client.get_freight_details("missing")
|
||||
|
||||
assert len(session.calls) == 1
|
||||
|
||||
|
||||
def test_expired_session_becomes_authentication_error() -> None:
|
||||
session = FakeSession(
|
||||
[
|
||||
FakeResponse(
|
||||
envelope(None, status=False, msg="未登录或登录过期", code="-2")
|
||||
)
|
||||
]
|
||||
)
|
||||
client = ERPClient(session=session)
|
||||
|
||||
with pytest.raises(ERPAuthenticationError):
|
||||
client.query_stock_by_order_number("FREIGHT-001")
|
||||
|
||||
|
||||
def test_rejects_unexpected_bulk_match() -> None:
|
||||
session = FakeSession([FakeResponse(envelope(101))])
|
||||
client = ERPClient(session=session, max_matches=100)
|
||||
|
||||
with pytest.raises(ERPProtocolError, match="安全上限"):
|
||||
client.query_stock_by_order_number("too-broad")
|
||||
@@ -1,160 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from shunyunbaoerp import ERPProtocolError, normalize_freight_result
|
||||
|
||||
|
||||
def raw_result() -> dict:
|
||||
return {
|
||||
"count": 1,
|
||||
"query": {
|
||||
"orderNumber": "PRIVATE-QUERY",
|
||||
"matchField": "allcode",
|
||||
},
|
||||
"records": [
|
||||
{
|
||||
"stock": {
|
||||
"id": 12,
|
||||
"code": "SOURCE-12",
|
||||
"orderCode": "PLATFORM-12",
|
||||
"shopName": "来源店铺",
|
||||
"created": "2026-07-28 10:00:00",
|
||||
"orderStatus": 2,
|
||||
"purchaseStatus": 0,
|
||||
"isCancel": 0,
|
||||
"receiver": "不得返回",
|
||||
"receiverTel": "不得返回",
|
||||
"receiverAddr": "不得返回",
|
||||
},
|
||||
"detail": {
|
||||
"id": 12,
|
||||
"shopName": "来源店铺",
|
||||
"created": "2026-07-28 10:00:00",
|
||||
"details": [
|
||||
{
|
||||
"id": 102,
|
||||
"productTitle": "第二件商品",
|
||||
"productSpec": "蓝色,L",
|
||||
"sku": "",
|
||||
"variationSku": "BLUE-L",
|
||||
"productQty": 2,
|
||||
"productThumb": 190000002,
|
||||
"purchaseStatus": 0,
|
||||
"cost": "不得返回",
|
||||
},
|
||||
{
|
||||
"id": 101,
|
||||
"productTitle": "第一件商品",
|
||||
"productSpec": "灰色,2XL",
|
||||
"sku": "GRAY-2XL",
|
||||
"variationSku": "IGNORED",
|
||||
"productQty": 1,
|
||||
"productThumb": 190000001,
|
||||
"purchaseStatus": 0,
|
||||
},
|
||||
],
|
||||
"receiver": "不得返回",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_normalizes_all_items_and_excludes_private_fields() -> None:
|
||||
normalized = normalize_freight_result(raw_result())
|
||||
|
||||
assert normalized["schema_version"] == 1
|
||||
assert normalized["query"] == {"mode": "ORDER_NUMBER"}
|
||||
assert len(normalized["orders"]) == 1
|
||||
order = normalized["orders"][0]
|
||||
assert order["external_stock_id"] == "12"
|
||||
assert [item["external_item_id"] for item in order["items"]] == [
|
||||
"101",
|
||||
"102",
|
||||
]
|
||||
assert order["items"][1]["sku"] == "BLUE-L"
|
||||
assert order["items"][1]["quantity"] == 2
|
||||
assert order["items"][0]["product_thumb_ref"] == "190000001"
|
||||
|
||||
encoded = json.dumps(normalized, ensure_ascii=False)
|
||||
for forbidden in (
|
||||
"PRIVATE-QUERY",
|
||||
"receiver",
|
||||
"receiverTel",
|
||||
"receiverAddr",
|
||||
"cost",
|
||||
"不得返回",
|
||||
):
|
||||
assert forbidden not in encoded
|
||||
|
||||
|
||||
def test_invalid_procurement_fields_remain_reviewable() -> None:
|
||||
source = raw_result()
|
||||
item = source["records"][0]["detail"]["details"][0]
|
||||
item["productTitle"] = None
|
||||
item["detailProductName"] = None
|
||||
item["sku"] = ""
|
||||
item["variationSku"] = ""
|
||||
item["productQty"] = 0
|
||||
item["productThumb"] = None
|
||||
|
||||
normalized = normalize_freight_result(source)
|
||||
result = normalized["orders"][0]["items"][1]
|
||||
|
||||
assert result["title"] == ""
|
||||
assert result["sku"] == ""
|
||||
assert result["quantity"] is None
|
||||
assert result["product_thumb_ref"] is None
|
||||
|
||||
|
||||
def test_normalizes_created_range_without_exposing_raw_query() -> None:
|
||||
source = raw_result()
|
||||
source["query"] = {
|
||||
"mode": "CREATED_RANGE",
|
||||
"createdFrom": "2026-07-22",
|
||||
"createdTo": "2026-07-28",
|
||||
"private": "must-not-leak",
|
||||
}
|
||||
|
||||
normalized = normalize_freight_result(source)
|
||||
|
||||
assert normalized["query"] == {
|
||||
"mode": "CREATED_RANGE",
|
||||
"created_from": "2026-07-22",
|
||||
"created_to": "2026-07-28",
|
||||
}
|
||||
assert "must-not-leak" not in json.dumps(normalized)
|
||||
|
||||
|
||||
def test_conflicting_duplicate_item_is_rejected() -> None:
|
||||
source = raw_result()
|
||||
duplicate = dict(source["records"][0]["detail"]["details"][0])
|
||||
duplicate["productTitle"] = "冲突内容"
|
||||
source["records"][0]["detail"]["details"].append(duplicate)
|
||||
|
||||
with pytest.raises(ERPProtocolError, match="重复身份"):
|
||||
normalize_freight_result(source)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mutation",
|
||||
[
|
||||
lambda source: source["records"][0].update(detail=None),
|
||||
lambda source: source["records"][0]["detail"].update(id=99),
|
||||
lambda source: source["records"][0]["detail"].update(details=None),
|
||||
lambda source: source["records"][0]["detail"]["details"][0].update(
|
||||
id=None
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_identity_or_detail_shape_failure_rejects_whole_batch(
|
||||
mutation,
|
||||
) -> None:
|
||||
source = raw_result()
|
||||
mutation(source)
|
||||
|
||||
with pytest.raises(ERPProtocolError):
|
||||
normalize_freight_result(source)
|
||||
@@ -1,26 +0,0 @@
|
||||
@echo off
|
||||
setlocal
|
||||
|
||||
if "%SHUNYUNBAO_USERNAME%"=="" (
|
||||
echo Missing SHUNYUNBAO_USERNAME.
|
||||
exit /b 1
|
||||
)
|
||||
if "%SHUNYUNBAO_PASSWORD%"=="" (
|
||||
echo Missing SHUNYUNBAO_PASSWORD.
|
||||
exit /b 1
|
||||
)
|
||||
if "%SHUNYUNBAO_SERVICE_API_KEY%"=="" (
|
||||
echo Missing SHUNYUNBAO_SERVICE_API_KEY.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
set "CONNECTOR_ROOT=%~dp0..\erp-connector"
|
||||
pushd "%CONNECTOR_ROOT%" || exit /b 1
|
||||
python -m uvicorn shunyunbaoerp.api:app ^
|
||||
--app-dir src ^
|
||||
--host 127.0.0.1 ^
|
||||
--port 8091 ^
|
||||
--no-access-log
|
||||
set "EXIT_CODE=%ERRORLEVEL%"
|
||||
popd
|
||||
exit /b %EXIT_CODE%
|
||||
@@ -7,12 +7,6 @@ set "LOCAL_TLS_DIR=%PROJECT_ROOT%.local\cmroubao-tls"
|
||||
set "LOCAL_TLS_CERT=%LOCAL_TLS_DIR%\server.crt"
|
||||
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 exist "%LOCAL_TLS_CERT%" if exist "%LOCAL_TLS_KEY%" (
|
||||
set "CMROUBAO_HTTP_ADDR=0.0.0.0:8080"
|
||||
|
||||
Reference in New Issue
Block a user