feat(t222): add freight ingestion admin flow

This commit is contained in:
QiuSW
2026-07-28 23:26:34 +08:00
parent c524e734fb
commit 6d052b0462
37 changed files with 3397 additions and 101 deletions
@@ -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) {
+153 -4
View File
@@ -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(), "&lt;script&gt;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)