feat(t222): add freight ingestion admin flow
This commit is contained in:
@@ -28,6 +28,7 @@ type AdminServices struct {
|
||||
Tasks *usecase.TaskService
|
||||
Results *usecase.ExecutionResultService
|
||||
Authorizations *usecase.OrderAuthorizationService
|
||||
Freight *usecase.FreightService
|
||||
}
|
||||
|
||||
func (s AdminServices) validate() error {
|
||||
@@ -61,6 +62,12 @@ func registerAdminAPI(routes gin.IRoutes, services AdminServices) error {
|
||||
"/api/v1/tasks/:id/order-authorizations",
|
||||
handler.createOrderAuthorization,
|
||||
)
|
||||
if services.Freight != nil {
|
||||
routes.POST("/api/v1/freight-syncs", handler.createFreightSync)
|
||||
routes.GET("/api/v1/freight-syncs/:id", handler.freightSyncDetail)
|
||||
routes.GET("/api/v1/freight-orders", handler.listFreightOrders)
|
||||
routes.GET("/api/v1/freight-orders/:id", handler.freightOrderDetail)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -294,6 +294,107 @@ func TestAdminAPIAssetAndTaskLifecycle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminFreightAPIImportsAllItemsWithoutPII(t *testing.T) {
|
||||
router := newAdminIntegrationRouter(t)
|
||||
create := performAdminRequest(
|
||||
t,
|
||||
router,
|
||||
http.MethodPost,
|
||||
"/api/v1/freight-syncs",
|
||||
"application/json",
|
||||
strings.NewReader(
|
||||
`{"mode":"ORDER_NUMBER","order_number":"SOURCE-12"}`,
|
||||
),
|
||||
"freight-sync-1",
|
||||
)
|
||||
if create.Code != http.StatusAccepted {
|
||||
t.Fatalf("create status/body = %d / %s", create.Code, create.Body)
|
||||
}
|
||||
var createBody struct {
|
||||
Sync struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"sync"`
|
||||
}
|
||||
decodeResponse(t, create, &createBody)
|
||||
if createBody.Sync.ID == "" ||
|
||||
strings.Contains(create.Body.String(), "SOURCE-12") {
|
||||
t.Fatalf("create response exposes query or lacks ID: %s", create.Body)
|
||||
}
|
||||
var sync *httptest.ResponseRecorder
|
||||
for attempt := 0; attempt < 50; attempt++ {
|
||||
sync = performAdminRequest(
|
||||
t,
|
||||
router,
|
||||
http.MethodGet,
|
||||
"/api/v1/freight-syncs/"+createBody.Sync.ID,
|
||||
"",
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
if strings.Contains(sync.Body.String(), `"status":"SUCCEEDED"`) {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if sync == nil || sync.Code != http.StatusOK ||
|
||||
!strings.Contains(sync.Body.String(), `"item_count":2`) {
|
||||
t.Fatalf("sync status/body = %d / %s", sync.Code, sync.Body)
|
||||
}
|
||||
list := performAdminRequest(
|
||||
t,
|
||||
router,
|
||||
http.MethodGet,
|
||||
"/api/v1/freight-orders",
|
||||
"",
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
if list.Code != http.StatusOK ||
|
||||
!strings.Contains(list.Body.String(), `"item_count":2`) ||
|
||||
responseContainsKey(mustDecodeAny(t, list), "receiver") ||
|
||||
responseContainsKey(mustDecodeAny(t, list), "receiverTel") ||
|
||||
responseContainsKey(mustDecodeAny(t, list), "receiverAddr") {
|
||||
t.Fatalf("freight list status/body = %d / %s", list.Code, list.Body)
|
||||
}
|
||||
var listBody struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"items"`
|
||||
}
|
||||
decodeResponse(t, list, &listBody)
|
||||
detail := performAdminRequest(
|
||||
t,
|
||||
router,
|
||||
http.MethodGet,
|
||||
"/api/v1/freight-orders/"+listBody.Items[0].ID,
|
||||
"",
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
if detail.Code != http.StatusOK ||
|
||||
!strings.Contains(detail.Body.String(), `"sku":"BLACK-L"`) ||
|
||||
!strings.Contains(detail.Body.String(), `"sku":"WHITE-M"`) ||
|
||||
detail.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatalf("freight detail status/body = %d / %s", detail.Code, detail.Body)
|
||||
}
|
||||
replay := performAdminRequest(
|
||||
t,
|
||||
router,
|
||||
http.MethodPost,
|
||||
"/api/v1/freight-syncs",
|
||||
"application/json",
|
||||
strings.NewReader(
|
||||
`{"mode":"ORDER_NUMBER","order_number":"SOURCE-12"}`,
|
||||
),
|
||||
"freight-sync-1",
|
||||
)
|
||||
if replay.Code != http.StatusAccepted ||
|
||||
!strings.Contains(replay.Body.String(), `"replayed":true`) ||
|
||||
!strings.Contains(replay.Body.String(), createBody.Sync.ID) {
|
||||
t.Fatalf("replay status/body = %d / %s", replay.Code, replay.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminOrderAuthorizationIsIdempotentAndRevisioned(t *testing.T) {
|
||||
fixture := newAdminIntegrationFixture(t)
|
||||
taskID, executionID, taskHash, firstKey, secondKey :=
|
||||
@@ -414,6 +515,9 @@ func TestAdminOrderAuthorizationIsIdempotentAndRevisioned(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("migration.New() error = %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err != nil {
|
||||
t.Fatalf("freight migration down: %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err != nil {
|
||||
t.Fatalf("order submission migration down: %v", err)
|
||||
}
|
||||
@@ -738,12 +842,23 @@ func newAdminIntegrationFixture(t *testing.T) *adminIntegrationFixture {
|
||||
if err != nil {
|
||||
t.Fatalf("usecase.NewOrderAuthorizationService() error = %v", err)
|
||||
}
|
||||
freight, err := usecase.NewFreightService(
|
||||
repositories,
|
||||
staticFreightSource{},
|
||||
clock,
|
||||
ids,
|
||||
time.Second,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("usecase.NewFreightService() error = %v", err)
|
||||
}
|
||||
registrar, err := NewAdminRouteRegistrar(
|
||||
AdminServices{
|
||||
Assets: assets,
|
||||
Tasks: tasks,
|
||||
Results: results,
|
||||
Authorizations: authorizations,
|
||||
Freight: freight,
|
||||
},
|
||||
emptyAdminWeb{},
|
||||
)
|
||||
@@ -765,6 +880,56 @@ func newAdminIntegrationFixture(t *testing.T) *adminIntegrationFixture {
|
||||
return &adminIntegrationFixture{router: router, db: db}
|
||||
}
|
||||
|
||||
type staticFreightSource struct{}
|
||||
|
||||
func (staticFreightSource) QueryOrder(
|
||||
context.Context,
|
||||
string,
|
||||
) (domain.FreightSourceBatch, error) {
|
||||
shop := "测试店铺"
|
||||
created := "2026-07-28 08:00:00"
|
||||
quantityOne := 1
|
||||
quantityTwo := 2
|
||||
return domain.FreightSourceBatch{
|
||||
SchemaVersion: 1,
|
||||
Query: domain.FreightSourceQuery{
|
||||
Mode: domain.FreightSyncOrderNumber,
|
||||
},
|
||||
Orders: []domain.FreightSourceOrder{{
|
||||
ExternalStockID: "12",
|
||||
SourceCode: "SOURCE-12",
|
||||
ShopName: &shop,
|
||||
SourceCreatedAt: &created,
|
||||
Items: []domain.FreightSourceItem{
|
||||
{
|
||||
ExternalItemID: "88",
|
||||
Title: "商品一",
|
||||
ProductSpec: "黑色,L",
|
||||
SKU: "BLACK-L",
|
||||
Quantity: &quantityOne,
|
||||
},
|
||||
{
|
||||
ExternalItemID: "89",
|
||||
Title: "商品二",
|
||||
ProductSpec: "白色,M",
|
||||
SKU: "WHITE-M",
|
||||
Quantity: &quantityTwo,
|
||||
},
|
||||
},
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func mustDecodeAny(
|
||||
t *testing.T,
|
||||
response *httptest.ResponseRecorder,
|
||||
) any {
|
||||
t.Helper()
|
||||
var decoded any
|
||||
decodeResponse(t, response, &decoded)
|
||||
return decoded
|
||||
}
|
||||
|
||||
func referenceUpload(t *testing.T, key string) (io.Reader, string) {
|
||||
t.Helper()
|
||||
var imageBytes bytes.Buffer
|
||||
|
||||
@@ -231,7 +231,9 @@ func denyAdminSession(ctx *gin.Context) {
|
||||
next := ctx.Request.URL.RequestURI()
|
||||
if next == "" ||
|
||||
(next != "/tasks" && !strings.HasPrefix(next, "/tasks?") &&
|
||||
!strings.HasPrefix(next, "/tasks/")) {
|
||||
!strings.HasPrefix(next, "/tasks/") &&
|
||||
next != "/freight" && !strings.HasPrefix(next, "/freight?") &&
|
||||
!strings.HasPrefix(next, "/freight/")) {
|
||||
next = "/tasks"
|
||||
}
|
||||
ctx.Abort()
|
||||
|
||||
@@ -713,6 +713,9 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("migration.New() after review error = %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err != nil {
|
||||
t.Fatalf("freight migration down: %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err != nil {
|
||||
t.Fatalf("order submission migration down: %v", err)
|
||||
}
|
||||
@@ -727,8 +730,8 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
|
||||
}
|
||||
if applied, err := runner.Up(context.Background()); err != nil {
|
||||
t.Fatalf("restore device command migration: %v", err)
|
||||
} else if applied != 3 {
|
||||
t.Fatalf("restored migrations = %d, want 3", applied)
|
||||
} else if applied != 4 {
|
||||
t.Fatalf("restored migrations = %d, want 4", applied)
|
||||
}
|
||||
|
||||
completePayload := fmt.Sprintf(
|
||||
@@ -1417,6 +1420,9 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable(
|
||||
if err != nil {
|
||||
t.Fatalf("migration.New() error = %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err != nil {
|
||||
t.Fatalf("freight migration down: %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err == nil {
|
||||
t.Fatal("order submission migration down succeeded with retained data")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
"cmroubao/backend-api/internal/usecase"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (h *adminHandlers) createFreightSync(ctx *gin.Context) {
|
||||
if !hasMediaType(ctx, "application/json") {
|
||||
writePublicError(
|
||||
ctx,
|
||||
http.StatusUnsupportedMediaType,
|
||||
"UNSUPPORTED_MEDIA_TYPE",
|
||||
"application/json is required",
|
||||
false,
|
||||
gin.H{},
|
||||
)
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
Mode string `json:"mode"`
|
||||
OrderNumber string `json:"order_number"`
|
||||
}
|
||||
if err := decodeJSON(ctx, &request); err != nil {
|
||||
writePublicError(
|
||||
ctx,
|
||||
http.StatusBadRequest,
|
||||
"INVALID_JSON",
|
||||
"request body must be valid JSON",
|
||||
false,
|
||||
gin.H{},
|
||||
)
|
||||
return
|
||||
}
|
||||
if request.Mode != domain.FreightSyncOrderNumber {
|
||||
writePublicError(
|
||||
ctx,
|
||||
http.StatusUnprocessableEntity,
|
||||
"FREIGHT_SYNC_MODE_INVALID",
|
||||
"freight sync mode is not supported",
|
||||
false,
|
||||
fieldDetails("mode", "must be ORDER_NUMBER"),
|
||||
)
|
||||
return
|
||||
}
|
||||
result, err := h.services.Freight.CreateOrderSync(
|
||||
ctx.Request.Context(),
|
||||
usecase.CreateFreightSyncCommand{
|
||||
CreatorSubject: localAdminSubject,
|
||||
ActorUserID: adminActorUserID(ctx),
|
||||
IdempotencyKey: ctx.GetHeader("Idempotency-Key"),
|
||||
OrderNumber: request.OrderNumber,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
writeUsecaseError(ctx, err)
|
||||
return
|
||||
}
|
||||
ctx.Header("Cache-Control", "no-store")
|
||||
ctx.JSON(http.StatusAccepted, gin.H{
|
||||
"sync": freightSyncResponse(result.Run),
|
||||
"replayed": result.Replayed,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *adminHandlers) freightSyncDetail(ctx *gin.Context) {
|
||||
run, err := h.services.Freight.GetSync(
|
||||
ctx.Request.Context(),
|
||||
localAdminSubject,
|
||||
ctx.Param("id"),
|
||||
)
|
||||
if err != nil {
|
||||
writeUsecaseError(ctx, err)
|
||||
return
|
||||
}
|
||||
ctx.Header("Cache-Control", "no-store")
|
||||
ctx.JSON(http.StatusOK, gin.H{"sync": freightSyncResponse(run)})
|
||||
}
|
||||
|
||||
func (h *adminHandlers) listFreightOrders(ctx *gin.Context) {
|
||||
limit := 0
|
||||
if value := strings.TrimSpace(ctx.Query("limit")); value != "" {
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
writePublicError(
|
||||
ctx,
|
||||
http.StatusBadRequest,
|
||||
"FREIGHT_LIST_INVALID",
|
||||
"freight list filter is invalid",
|
||||
false,
|
||||
fieldDetails("limit", "must be an integer"),
|
||||
)
|
||||
return
|
||||
}
|
||||
limit = parsed
|
||||
}
|
||||
orders, err := h.services.Freight.ListOrders(
|
||||
ctx.Request.Context(),
|
||||
localAdminSubject,
|
||||
limit,
|
||||
)
|
||||
if err != nil {
|
||||
writeUsecaseError(ctx, err)
|
||||
return
|
||||
}
|
||||
items := make([]gin.H, 0, len(orders))
|
||||
for _, order := range orders {
|
||||
items = append(items, freightOrderResponse(order))
|
||||
}
|
||||
ctx.Header("Cache-Control", "no-store")
|
||||
ctx.JSON(http.StatusOK, gin.H{
|
||||
"items": items,
|
||||
"next_cursor": nil,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *adminHandlers) freightOrderDetail(ctx *gin.Context) {
|
||||
detail, err := h.services.Freight.GetOrder(
|
||||
ctx.Request.Context(),
|
||||
localAdminSubject,
|
||||
ctx.Param("id"),
|
||||
)
|
||||
if err != nil {
|
||||
writeUsecaseError(ctx, err)
|
||||
return
|
||||
}
|
||||
items := make([]gin.H, 0, len(detail.Items))
|
||||
for _, item := range detail.Items {
|
||||
items = append(items, gin.H{
|
||||
"id": item.ID,
|
||||
"external_item_id": item.ExternalItemID,
|
||||
"title": item.Title,
|
||||
"product_spec": item.ProductSpec,
|
||||
"sku": item.SKU,
|
||||
"quantity": item.Quantity,
|
||||
"product_thumb_ref": item.ProductThumbRef,
|
||||
"purchase_status": item.PurchaseStatus,
|
||||
"revision": item.Revision,
|
||||
"canonical_sha256": item.CanonicalSHA256,
|
||||
"updated_at": formatTime(item.UpdatedAt),
|
||||
})
|
||||
}
|
||||
ctx.Header("Cache-Control", "no-store")
|
||||
ctx.JSON(http.StatusOK, gin.H{
|
||||
"order": freightOrderResponse(detail.Order),
|
||||
"items": items,
|
||||
})
|
||||
}
|
||||
|
||||
func freightSyncResponse(run domain.FreightSyncRun) gin.H {
|
||||
return gin.H{
|
||||
"id": run.ID,
|
||||
"mode": run.Mode,
|
||||
"query_sha256": run.QuerySHA256,
|
||||
"status": run.Status,
|
||||
"error_code": run.ErrorCode,
|
||||
"order_count": run.OrderCount,
|
||||
"item_count": run.ItemCount,
|
||||
"created_at": formatTime(run.CreatedAt),
|
||||
"started_at": formatOptionalTime(run.StartedAt),
|
||||
"finished_at": formatOptionalTime(run.FinishedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func freightOrderResponse(order domain.FreightOrder) gin.H {
|
||||
return gin.H{
|
||||
"id": order.ID,
|
||||
"source_system": order.SourceSystem,
|
||||
"external_stock_id": order.ExternalStockID,
|
||||
"source_code": order.SourceCode,
|
||||
"platform_order_no": order.PlatformOrderNo,
|
||||
"shop_name": order.ShopName,
|
||||
"source_created_at": formatOptionalTime(order.SourceCreatedAt),
|
||||
"order_status": order.OrderStatus,
|
||||
"purchase_status": order.PurchaseStatus,
|
||||
"is_canceled": order.IsCanceled,
|
||||
"revision": order.Revision,
|
||||
"canonical_sha256": order.CanonicalSHA256,
|
||||
"item_count": order.ItemCount,
|
||||
"updated_at": formatTime(order.UpdatedAt),
|
||||
}
|
||||
}
|
||||
@@ -267,7 +267,9 @@ func safeNext(value string) string {
|
||||
return "/tasks"
|
||||
}
|
||||
if parsed.Path != "/tasks" &&
|
||||
!strings.HasPrefix(parsed.Path, "/tasks/") {
|
||||
!strings.HasPrefix(parsed.Path, "/tasks/") &&
|
||||
parsed.Path != "/freight" &&
|
||||
!strings.HasPrefix(parsed.Path, "/freight/") {
|
||||
return "/tasks"
|
||||
}
|
||||
return parsed.String()
|
||||
|
||||
@@ -294,6 +294,9 @@ func TestSafeNextRejectsExternalAndAmbiguousPaths(t *testing.T) {
|
||||
if actual := safeNext("/tasks/item?id=1"); actual != "/tasks/item?id=1" {
|
||||
t.Fatalf("safeNext(valid) = %q", actual)
|
||||
}
|
||||
if actual := safeNext("/freight/import"); actual != "/freight/import" {
|
||||
t.Fatalf("safeNext(freight) = %q", actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoutRequiresCSRFAndRevokesSession(t *testing.T) {
|
||||
|
||||
@@ -71,6 +71,136 @@ func (h *Handler) RegisterProtected(routes gin.IRoutes) {
|
||||
SecurityHeaders(),
|
||||
h.AuthorizeOrder,
|
||||
)
|
||||
if _, ok := h.service.(FreightService); ok {
|
||||
routes.GET("/freight", SecurityHeaders(), h.ListFreight)
|
||||
routes.GET("/freight/import", SecurityHeaders(), h.ImportFreight)
|
||||
routes.POST("/freight/import", SecurityHeaders(), h.CreateFreightImport)
|
||||
routes.GET("/freight/:id", SecurityHeaders(), h.FreightDetail)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) ListFreight(ctx *gin.Context) {
|
||||
service := h.service.(FreightService)
|
||||
orders, err := service.ListFreightOrders(
|
||||
ctx.Request.Context(),
|
||||
defaultListLimit,
|
||||
)
|
||||
if err != nil {
|
||||
h.renderServiceError(ctx, err, "无法加载货运列表,请稍后重试。")
|
||||
return
|
||||
}
|
||||
token, err := csrfToken(ctx)
|
||||
if err != nil {
|
||||
h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
|
||||
return
|
||||
}
|
||||
h.render(ctx, http.StatusOK, "freight", freightPage{
|
||||
Page: pageView{
|
||||
Title: "ERP 货运",
|
||||
FreightCurrent: true,
|
||||
CSRFToken: token,
|
||||
},
|
||||
Orders: orders,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) ImportFreight(ctx *gin.Context) {
|
||||
service := h.service.(FreightService)
|
||||
token, err := csrfToken(ctx)
|
||||
if err != nil {
|
||||
h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
|
||||
return
|
||||
}
|
||||
key, err := newToken()
|
||||
if err != nil {
|
||||
h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
|
||||
return
|
||||
}
|
||||
page := freightImportPage{
|
||||
Page: pageView{
|
||||
Title: "导入 ERP 货运",
|
||||
FreightCurrent: true,
|
||||
CSRFToken: token,
|
||||
},
|
||||
IdempotencyKey: key,
|
||||
}
|
||||
if syncID := strings.TrimSpace(ctx.Query("sync")); syncID != "" {
|
||||
run, getErr := service.GetFreightSync(ctx.Request.Context(), syncID)
|
||||
if getErr == nil {
|
||||
page.Sync = &run
|
||||
}
|
||||
}
|
||||
h.render(ctx, http.StatusOK, "freight-import", page)
|
||||
}
|
||||
|
||||
func (h *Handler) CreateFreightImport(ctx *gin.Context) {
|
||||
ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, 16<<10)
|
||||
if err := ctx.Request.ParseForm(); err != nil || !validCSRF(ctx) {
|
||||
h.renderError(ctx, http.StatusForbidden, "请求已失效", "请返回导入页面后重新提交。")
|
||||
return
|
||||
}
|
||||
orderNumber := strings.TrimSpace(ctx.PostForm("order_number"))
|
||||
key := strings.TrimSpace(ctx.PostForm("idempotency_key"))
|
||||
if orderNumber == "" || len([]byte(orderNumber)) > 128 ||
|
||||
!validToken(key) {
|
||||
token, _ := csrfToken(ctx)
|
||||
h.render(ctx, http.StatusUnprocessableEntity, "freight-import", freightImportPage{
|
||||
Page: pageView{
|
||||
Title: "导入 ERP 货运",
|
||||
FreightCurrent: true,
|
||||
CSRFToken: token,
|
||||
},
|
||||
OrderNumber: orderNumber,
|
||||
IdempotencyKey: key,
|
||||
Error: "请输入完整单号后重试。",
|
||||
})
|
||||
return
|
||||
}
|
||||
service := h.service.(FreightService)
|
||||
run, err := service.CreateFreightSync(
|
||||
ctx.Request.Context(),
|
||||
CreateFreightSyncInput{
|
||||
ActorUserID: actorUserID(ctx.Request.Context()),
|
||||
IdempotencyKey: key,
|
||||
OrderNumber: orderNumber,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
token, _ := csrfToken(ctx)
|
||||
h.render(ctx, serviceErrorStatus(err), "freight-import", freightImportPage{
|
||||
Page: pageView{
|
||||
Title: "导入 ERP 货运",
|
||||
FreightCurrent: true,
|
||||
CSRFToken: token,
|
||||
},
|
||||
OrderNumber: orderNumber,
|
||||
IdempotencyKey: key,
|
||||
Error: "同步任务创建失败,请稍后使用相同提交标识重试。",
|
||||
})
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/freight/import?sync="+pathEscape(run.ID))
|
||||
}
|
||||
|
||||
func (h *Handler) FreightDetail(ctx *gin.Context) {
|
||||
service := h.service.(FreightService)
|
||||
detail, err := service.GetFreightOrder(
|
||||
ctx.Request.Context(),
|
||||
strings.TrimSpace(ctx.Param("id")),
|
||||
)
|
||||
if err != nil {
|
||||
h.renderServiceError(ctx, err, "无法加载货运详情,请稍后重试。")
|
||||
return
|
||||
}
|
||||
token, _ := csrfToken(ctx)
|
||||
h.render(ctx, http.StatusOK, "freight-detail", freightDetailPage{
|
||||
Page: pageView{
|
||||
Title: "货运详情",
|
||||
FreightCurrent: true,
|
||||
CSRFToken: token,
|
||||
},
|
||||
Detail: detail,
|
||||
})
|
||||
}
|
||||
|
||||
func SecurityHeaders() gin.HandlerFunc {
|
||||
@@ -739,10 +869,29 @@ func fallback(value string, fallbackValue string) string {
|
||||
}
|
||||
|
||||
type pageView struct {
|
||||
Title string
|
||||
TasksCurrent bool
|
||||
NewCurrent bool
|
||||
CSRFToken string
|
||||
Title string
|
||||
TasksCurrent bool
|
||||
NewCurrent bool
|
||||
FreightCurrent bool
|
||||
CSRFToken string
|
||||
}
|
||||
|
||||
type freightPage struct {
|
||||
Page pageView
|
||||
Orders []FreightOrder
|
||||
}
|
||||
|
||||
type freightImportPage struct {
|
||||
Page pageView
|
||||
OrderNumber string
|
||||
IdempotencyKey string
|
||||
Error string
|
||||
Sync *FreightSync
|
||||
}
|
||||
|
||||
type freightDetailPage struct {
|
||||
Page pageView
|
||||
Detail FreightOrderDetail
|
||||
}
|
||||
|
||||
type statusOption struct {
|
||||
|
||||
@@ -922,6 +922,78 @@ func TestRendererUsesMissingKeyErrors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreightPagesEscapeSourceDataAndCreateAsyncSync(t *testing.T) {
|
||||
now := time.Date(2026, 7, 28, 3, 4, 5, 0, time.UTC)
|
||||
service := &fakeFreightService{
|
||||
fakeService: &fakeService{},
|
||||
orders: []FreightOrder{{
|
||||
ID: testTaskID,
|
||||
ExternalStockID: "12",
|
||||
SourceCode: `<script>private</script>`,
|
||||
ShopName: "测试店铺",
|
||||
ItemCount: 2,
|
||||
Revision: 1,
|
||||
UpdatedAt: now,
|
||||
}},
|
||||
createResult: FreightSync{
|
||||
ID: testTaskID,
|
||||
Status: "PENDING",
|
||||
CreatedAt: now,
|
||||
},
|
||||
}
|
||||
router := newTestRouter(t, service)
|
||||
list := performRequest(t, router, http.MethodGet, "/freight", nil, "")
|
||||
if list.Code != http.StatusOK ||
|
||||
strings.Contains(list.Body.String(), `<script>private</script>`) ||
|
||||
!strings.Contains(list.Body.String(), "<script>private") ||
|
||||
!strings.Contains(list.Body.String(), "2 项") {
|
||||
t.Fatalf("freight list status/body = %d / %s", list.Code, list.Body)
|
||||
}
|
||||
assertSecurityHeaders(t, list)
|
||||
|
||||
form := performRequest(
|
||||
t,
|
||||
router,
|
||||
http.MethodGet,
|
||||
"/freight/import",
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
cookie := csrfCookie(t, form)
|
||||
idempotencyKey := hiddenValue(
|
||||
t,
|
||||
form.Body.String(),
|
||||
"idempotency_key",
|
||||
)
|
||||
values := url.Values{
|
||||
"csrf_token": {cookie.Value},
|
||||
"idempotency_key": {idempotencyKey},
|
||||
"order_number": {"SOURCE-12"},
|
||||
}
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/freight/import",
|
||||
strings.NewReader(values.Encode()),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(cookie)
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusSeeOther ||
|
||||
response.Header().Get("Location") !=
|
||||
"/freight/import?sync="+testTaskID {
|
||||
t.Fatalf(
|
||||
"create sync status/location = %d / %q",
|
||||
response.Code,
|
||||
response.Header().Get("Location"),
|
||||
)
|
||||
}
|
||||
if service.createInput.OrderNumber != "SOURCE-12" ||
|
||||
service.createInput.IdempotencyKey != idempotencyKey {
|
||||
t.Fatalf("create input = %+v", service.createInput)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeService struct {
|
||||
listInput ListTasksInput
|
||||
listResult TaskList
|
||||
@@ -945,6 +1017,45 @@ type fakeService struct {
|
||||
authorizeInput AuthorizeOrderInput
|
||||
}
|
||||
|
||||
type fakeFreightService struct {
|
||||
*fakeService
|
||||
orders []FreightOrder
|
||||
orderDetail FreightOrderDetail
|
||||
sync FreightSync
|
||||
createInput CreateFreightSyncInput
|
||||
createResult FreightSync
|
||||
err error
|
||||
}
|
||||
|
||||
func (service *fakeFreightService) ListFreightOrders(
|
||||
context.Context,
|
||||
int,
|
||||
) ([]FreightOrder, error) {
|
||||
return service.orders, service.err
|
||||
}
|
||||
|
||||
func (service *fakeFreightService) GetFreightOrder(
|
||||
context.Context,
|
||||
string,
|
||||
) (FreightOrderDetail, error) {
|
||||
return service.orderDetail, service.err
|
||||
}
|
||||
|
||||
func (service *fakeFreightService) GetFreightSync(
|
||||
context.Context,
|
||||
string,
|
||||
) (FreightSync, error) {
|
||||
return service.sync, service.err
|
||||
}
|
||||
|
||||
func (service *fakeFreightService) CreateFreightSync(
|
||||
_ context.Context,
|
||||
input CreateFreightSyncInput,
|
||||
) (FreightSync, error) {
|
||||
service.createInput = input
|
||||
return service.createResult, service.err
|
||||
}
|
||||
|
||||
func (service *fakeService) ListTasks(
|
||||
_ context.Context,
|
||||
input ListTasksInput,
|
||||
|
||||
@@ -673,6 +673,54 @@ tbody tr:last-child td {
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
|
||||
.notice.danger {
|
||||
border-color: var(--danger);
|
||||
color: var(--danger-dark);
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
|
||||
.form-panel {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
padding: 22px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
.detail-grid > div {
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.detail-grid dt {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.detail-grid dd {
|
||||
margin: 4px 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.task-form {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
@@ -1139,6 +1187,10 @@ tbody tr:last-child td {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.authorization-note {
|
||||
grid-column: auto;
|
||||
}
|
||||
@@ -1154,11 +1206,7 @@ tbody tr:last-child td {
|
||||
padding-inline: 10px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
.brand span:not(.brand-mark) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1167,13 +1215,13 @@ tbody tr:last-child td {
|
||||
}
|
||||
|
||||
.main-nav a {
|
||||
padding-inline: 8px;
|
||||
font-size: 13px;
|
||||
padding-inline: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.logout-button {
|
||||
padding-inline: 8px;
|
||||
font-size: 13px;
|
||||
padding-inline: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.title-row,
|
||||
@@ -1186,6 +1234,10 @@ tbody tr:last-child td {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.title-actions {
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
{{define "freight-detail"}}
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<title>{{.Page.Title}} - 采购任务管理</title>
|
||||
{{template "document-head" .}}
|
||||
</head>
|
||||
<body>
|
||||
{{template "site-header" .}}
|
||||
<main id="main-content" class="page">
|
||||
<div class="title-row">
|
||||
<div>
|
||||
<h1>{{if .Detail.Order.SourceCode}}{{.Detail.Order.SourceCode}}{{else}}货运详情{{end}}</h1>
|
||||
<p class="subtitle">ERP ID:{{.Detail.Order.ExternalStockID}} · 来源版本 {{.Detail.Order.Revision}}</p>
|
||||
</div>
|
||||
<a class="button" href="/freight">返回列表</a>
|
||||
</div>
|
||||
<section class="detail-section" aria-labelledby="freight-source-title">
|
||||
<h2 id="freight-source-title">来源信息</h2>
|
||||
<dl class="detail-grid">
|
||||
<div><dt>店铺</dt><dd>{{if .Detail.Order.ShopName}}{{.Detail.Order.ShopName}}{{else}}未提供{{end}}</dd></div>
|
||||
<div><dt>ERP 创建时间</dt><dd>{{displayTime .Detail.Order.SourceCreatedAt}}</dd></div>
|
||||
<div><dt>订单状态</dt><dd>{{if .Detail.Order.OrderStatus}}{{.Detail.Order.OrderStatus}}{{else}}未提供{{end}}</dd></div>
|
||||
<div><dt>采购状态</dt><dd>{{if .Detail.Order.PurchaseStatus}}{{.Detail.Order.PurchaseStatus}}{{else}}未提供{{end}}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
<section class="table-region" aria-labelledby="freight-items-title">
|
||||
<h2 id="freight-items-title">商品明细</h2>
|
||||
{{if .Detail.Items}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">商品</th>
|
||||
<th scope="col">规格 / SKU</th>
|
||||
<th scope="col">数量</th>
|
||||
<th scope="col">采购状态</th>
|
||||
<th scope="col">来源版本</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Detail.Items}}
|
||||
<tr>
|
||||
<td data-label="商品">
|
||||
<strong>{{if .Title}}{{.Title}}{{else}}缺少标题{{end}}</strong>
|
||||
<span class="secondary">明细 ID:{{.ExternalItemID}}</span>
|
||||
{{if .ProductThumbRef}}<span class="secondary">图片引用:{{.ProductThumbRef}}</span>{{end}}
|
||||
</td>
|
||||
<td data-label="规格 / SKU">
|
||||
<span>{{if .ProductSpec}}{{.ProductSpec}}{{else}}未提供规格{{end}}</span>
|
||||
<span class="secondary">SKU:{{if .SKU}}{{.SKU}}{{else}}未提供{{end}}</span>
|
||||
</td>
|
||||
<td data-label="数量">{{if .Quantity}}{{.Quantity}}{{else}}未提供{{end}}</td>
|
||||
<td data-label="采购状态">{{if .PurchaseStatus}}{{.PurchaseStatus}}{{else}}未提供{{end}}</td>
|
||||
<td data-label="来源版本">{{.Revision}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<div class="empty-state"><h2>该货运单没有商品明细</h2></div>
|
||||
{{end}}
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,46 @@
|
||||
{{define "freight-import"}}
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<title>{{.Page.Title}} - 采购任务管理</title>
|
||||
{{template "document-head" .}}
|
||||
</head>
|
||||
<body>
|
||||
{{template "site-header" .}}
|
||||
<main id="main-content" class="page narrow-page">
|
||||
<div class="title-row">
|
||||
<div>
|
||||
<h1>导入 ERP 货运</h1>
|
||||
<p class="subtitle">使用 ERP 页面“全部单号”中的完整单号</p>
|
||||
</div>
|
||||
<a class="button" href="/freight">返回列表</a>
|
||||
</div>
|
||||
{{if .Error}}<div class="notice danger" role="alert">{{.Error}}</div>{{end}}
|
||||
{{if .Sync}}
|
||||
<section class="detail-section" aria-labelledby="sync-result-title">
|
||||
<h2 id="sync-result-title">同步状态</h2>
|
||||
<dl class="detail-grid">
|
||||
<div><dt>状态</dt><dd>{{.Sync.Status}}</dd></div>
|
||||
<div><dt>货运单</dt><dd>{{.Sync.OrderCount}}</dd></div>
|
||||
<div><dt>商品明细</dt><dd>{{.Sync.ItemCount}}</dd></div>
|
||||
<div><dt>错误码</dt><dd>{{if .Sync.ErrorCode}}{{.Sync.ErrorCode}}{{else}}无{{end}}</dd></div>
|
||||
</dl>
|
||||
{{if or (eq .Sync.Status "PENDING") (eq .Sync.Status "RUNNING")}}
|
||||
<p class="secondary">同步仍在后台执行,刷新本页查看结果。</p>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
<form class="form-panel" method="post" action="/freight/import" data-loading-form>
|
||||
<input type="hidden" name="csrf_token" value="{{.Page.CSRFToken}}">
|
||||
<input type="hidden" name="idempotency_key" value="{{.IdempotencyKey}}">
|
||||
<div class="field">
|
||||
<label for="order-number">完整单号</label>
|
||||
<input id="order-number" name="order_number" value="{{.OrderNumber}}"
|
||||
maxlength="128" autocomplete="off" required>
|
||||
</div>
|
||||
<button class="button primary" type="submit" data-loading-label="正在创建…">开始同步</button>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,62 @@
|
||||
{{define "freight"}}
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<title>{{.Page.Title}} - 采购任务管理</title>
|
||||
{{template "document-head" .}}
|
||||
</head>
|
||||
<body>
|
||||
{{template "site-header" .}}
|
||||
<main id="main-content" class="page">
|
||||
<div class="title-row">
|
||||
<div>
|
||||
<h1>ERP 货运</h1>
|
||||
<p class="subtitle">核对已同步的货运单和全部商品明细</p>
|
||||
</div>
|
||||
<a class="button primary" href="/freight/import">导入货运单</a>
|
||||
</div>
|
||||
<section class="table-region" aria-labelledby="freight-table-title">
|
||||
<h2 id="freight-table-title" class="visually-hidden">货运单列表</h2>
|
||||
{{if .Orders}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">来源单号</th>
|
||||
<th scope="col">店铺</th>
|
||||
<th scope="col">状态</th>
|
||||
<th scope="col">商品</th>
|
||||
<th scope="col">更新时间</th>
|
||||
<th scope="col"><span class="visually-hidden">操作</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Orders}}
|
||||
<tr>
|
||||
<td data-label="来源单号">
|
||||
<strong>{{if .SourceCode}}{{.SourceCode}}{{else}}{{.ExternalStockID}}{{end}}</strong>
|
||||
<span class="secondary">ERP ID:{{.ExternalStockID}}</span>
|
||||
</td>
|
||||
<td data-label="店铺">{{if .ShopName}}{{.ShopName}}{{else}}未提供{{end}}</td>
|
||||
<td data-label="状态">
|
||||
<span class="secondary">订单:{{if .OrderStatus}}{{.OrderStatus}}{{else}}未提供{{end}}</span>
|
||||
<span class="secondary">采购:{{if .PurchaseStatus}}{{.PurchaseStatus}}{{else}}未提供{{end}}</span>
|
||||
</td>
|
||||
<td data-label="商品">{{.ItemCount}} 项</td>
|
||||
<td data-label="更新时间"><time datetime="{{machineTime .UpdatedAt}}">{{displayTime .UpdatedAt}}</time></td>
|
||||
<td data-label="操作"><a class="detail-link" href="/freight/{{pathPart .ID}}">查看详情</a></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<h2>尚未导入货运单</h2>
|
||||
<p>按完整单号创建第一条 ERP 同步任务。</p>
|
||||
<a class="button primary" href="/freight/import">导入货运单</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -17,6 +17,7 @@
|
||||
<nav class="main-nav" aria-label="主导航">
|
||||
<a href="/tasks" {{if .Page.TasksCurrent}}aria-current="page"{{end}}>任务列表</a>
|
||||
<a href="/tasks/new" {{if .Page.NewCurrent}}aria-current="page"{{end}}>新建任务</a>
|
||||
<a href="/freight" {{if .Page.FreightCurrent}}aria-current="page"{{end}}>ERP 货运</a>
|
||||
</nav>
|
||||
{{if .Page.CSRFToken}}
|
||||
<form class="logout-form" method="post" action="/logout">
|
||||
|
||||
@@ -27,6 +27,63 @@ type Service interface {
|
||||
AuthorizeOrder(context.Context, AuthorizeOrderInput) (OrderAuthorization, error)
|
||||
}
|
||||
|
||||
type FreightService interface {
|
||||
ListFreightOrders(context.Context, int) ([]FreightOrder, error)
|
||||
GetFreightOrder(context.Context, string) (FreightOrderDetail, error)
|
||||
GetFreightSync(context.Context, string) (FreightSync, error)
|
||||
CreateFreightSync(
|
||||
context.Context,
|
||||
CreateFreightSyncInput,
|
||||
) (FreightSync, error)
|
||||
}
|
||||
|
||||
type FreightSync struct {
|
||||
ID string
|
||||
Status string
|
||||
ErrorCode string
|
||||
OrderCount int
|
||||
ItemCount int
|
||||
CreatedAt time.Time
|
||||
FinishedAt time.Time
|
||||
}
|
||||
|
||||
type CreateFreightSyncInput struct {
|
||||
ActorUserID string
|
||||
IdempotencyKey string
|
||||
OrderNumber string
|
||||
}
|
||||
|
||||
type FreightOrder struct {
|
||||
ID string
|
||||
ExternalStockID string
|
||||
SourceCode string
|
||||
ShopName string
|
||||
SourceCreatedAt time.Time
|
||||
OrderStatus string
|
||||
PurchaseStatus string
|
||||
IsCanceled bool
|
||||
ItemCount int
|
||||
Revision int
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type FreightOrderItem struct {
|
||||
ID string
|
||||
ExternalItemID string
|
||||
Title string
|
||||
ProductSpec string
|
||||
SKU string
|
||||
Quantity int
|
||||
ProductThumbRef string
|
||||
PurchaseStatus string
|
||||
Revision int
|
||||
}
|
||||
|
||||
type FreightOrderDetail struct {
|
||||
Order FreightOrder
|
||||
Items []FreightOrderItem
|
||||
}
|
||||
|
||||
type ListTasksInput struct {
|
||||
Query string
|
||||
Status string
|
||||
|
||||
@@ -17,23 +17,157 @@ type UsecaseAdapter struct {
|
||||
tasks *usecase.TaskService
|
||||
assets *usecase.AssetService
|
||||
authorizations *usecase.OrderAuthorizationService
|
||||
freight *usecase.FreightService
|
||||
}
|
||||
|
||||
func NewUsecaseAdapter(
|
||||
tasks *usecase.TaskService,
|
||||
assets *usecase.AssetService,
|
||||
authorizations *usecase.OrderAuthorizationService,
|
||||
freight ...*usecase.FreightService,
|
||||
) (*UsecaseAdapter, error) {
|
||||
if tasks == nil || assets == nil || authorizations == nil {
|
||||
return nil, errors.New("admin web use cases are required")
|
||||
}
|
||||
return &UsecaseAdapter{
|
||||
adapter := &UsecaseAdapter{
|
||||
tasks: tasks,
|
||||
assets: assets,
|
||||
authorizations: authorizations,
|
||||
}
|
||||
if len(freight) > 0 {
|
||||
adapter.freight = freight[0]
|
||||
}
|
||||
return adapter, nil
|
||||
}
|
||||
|
||||
func (adapter *UsecaseAdapter) ListFreightOrders(
|
||||
ctx context.Context,
|
||||
limit int,
|
||||
) ([]FreightOrder, error) {
|
||||
if adapter.freight == nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
orders, err := adapter.freight.ListOrders(ctx, localAdminSubject, limit)
|
||||
if err != nil {
|
||||
return nil, mapUsecaseError(err)
|
||||
}
|
||||
result := make([]FreightOrder, 0, len(orders))
|
||||
for _, order := range orders {
|
||||
result = append(result, freightOrderFrom(order))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (adapter *UsecaseAdapter) GetFreightOrder(
|
||||
ctx context.Context,
|
||||
orderID string,
|
||||
) (FreightOrderDetail, error) {
|
||||
if adapter.freight == nil {
|
||||
return FreightOrderDetail{}, ErrUnavailable
|
||||
}
|
||||
detail, err := adapter.freight.GetOrder(
|
||||
ctx,
|
||||
localAdminSubject,
|
||||
orderID,
|
||||
)
|
||||
if err != nil {
|
||||
return FreightOrderDetail{}, mapUsecaseError(err)
|
||||
}
|
||||
items := make([]FreightOrderItem, 0, len(detail.Items))
|
||||
for _, item := range detail.Items {
|
||||
view := FreightOrderItem{
|
||||
ID: item.ID,
|
||||
ExternalItemID: item.ExternalItemID,
|
||||
Title: item.Title,
|
||||
ProductSpec: item.ProductSpec,
|
||||
SKU: item.SKU,
|
||||
ProductThumbRef: stringValue(item.ProductThumbRef),
|
||||
PurchaseStatus: stringValue(item.PurchaseStatus),
|
||||
Revision: item.Revision,
|
||||
}
|
||||
if item.Quantity != nil {
|
||||
view.Quantity = *item.Quantity
|
||||
}
|
||||
items = append(items, view)
|
||||
}
|
||||
return FreightOrderDetail{
|
||||
Order: freightOrderFrom(detail.Order),
|
||||
Items: items,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (adapter *UsecaseAdapter) GetFreightSync(
|
||||
ctx context.Context,
|
||||
syncID string,
|
||||
) (FreightSync, error) {
|
||||
if adapter.freight == nil {
|
||||
return FreightSync{}, ErrUnavailable
|
||||
}
|
||||
run, err := adapter.freight.GetSync(ctx, localAdminSubject, syncID)
|
||||
if err != nil {
|
||||
return FreightSync{}, mapUsecaseError(err)
|
||||
}
|
||||
return freightSyncFrom(run), nil
|
||||
}
|
||||
|
||||
func (adapter *UsecaseAdapter) CreateFreightSync(
|
||||
ctx context.Context,
|
||||
input CreateFreightSyncInput,
|
||||
) (FreightSync, error) {
|
||||
if adapter.freight == nil {
|
||||
return FreightSync{}, ErrUnavailable
|
||||
}
|
||||
result, err := adapter.freight.CreateOrderSync(
|
||||
ctx,
|
||||
usecase.CreateFreightSyncCommand{
|
||||
CreatorSubject: localAdminSubject,
|
||||
ActorUserID: input.ActorUserID,
|
||||
IdempotencyKey: input.IdempotencyKey,
|
||||
OrderNumber: input.OrderNumber,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return FreightSync{}, mapUsecaseError(err)
|
||||
}
|
||||
return freightSyncFrom(result.Run), nil
|
||||
}
|
||||
|
||||
func freightOrderFrom(order domain.FreightOrder) FreightOrder {
|
||||
result := FreightOrder{
|
||||
ID: order.ID,
|
||||
ExternalStockID: order.ExternalStockID,
|
||||
SourceCode: order.SourceCode,
|
||||
ShopName: stringValue(order.ShopName),
|
||||
OrderStatus: stringValue(order.OrderStatus),
|
||||
PurchaseStatus: stringValue(order.PurchaseStatus),
|
||||
ItemCount: order.ItemCount,
|
||||
Revision: order.Revision,
|
||||
UpdatedAt: order.UpdatedAt,
|
||||
}
|
||||
if order.SourceCreatedAt != nil {
|
||||
result.SourceCreatedAt = *order.SourceCreatedAt
|
||||
}
|
||||
if order.IsCanceled != nil {
|
||||
result.IsCanceled = *order.IsCanceled
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func freightSyncFrom(run domain.FreightSyncRun) FreightSync {
|
||||
result := FreightSync{
|
||||
ID: run.ID,
|
||||
Status: string(run.Status),
|
||||
ErrorCode: stringValue(run.ErrorCode),
|
||||
OrderCount: run.OrderCount,
|
||||
ItemCount: run.ItemCount,
|
||||
CreatedAt: run.CreatedAt,
|
||||
}
|
||||
if run.FinishedAt != nil {
|
||||
result.FinishedAt = *run.FinishedAt
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (adapter *UsecaseAdapter) ListTasks(
|
||||
ctx context.Context,
|
||||
input ListTasksInput,
|
||||
@@ -525,3 +659,4 @@ func (err *adapterError) Unwrap() []error {
|
||||
}
|
||||
|
||||
var _ Service = (*UsecaseAdapter)(nil)
|
||||
var _ FreightService = (*UsecaseAdapter)(nil)
|
||||
|
||||
Reference in New Issue
Block a user