feat(t223): generate tasks from freight items

This commit is contained in:
QiuSW
2026-07-28 23:51:59 +08:00
parent 6d052b0462
commit 433db45100
25 changed files with 2697 additions and 48 deletions
@@ -29,6 +29,7 @@ type AdminServices struct {
Results *usecase.ExecutionResultService
Authorizations *usecase.OrderAuthorizationService
Freight *usecase.FreightService
Procurement *usecase.ProcurementService
}
func (s AdminServices) validate() error {
@@ -68,6 +69,20 @@ func registerAdminAPI(routes gin.IRoutes, services AdminServices) error {
routes.GET("/api/v1/freight-orders", handler.listFreightOrders)
routes.GET("/api/v1/freight-orders/:id", handler.freightOrderDetail)
}
if services.Procurement != nil {
routes.POST(
"/api/v1/freight-items/:id/procurement-request",
handler.createProcurementRequest,
)
routes.PUT(
"/api/v1/procurement-requests/:id/reference-asset",
handler.bindProcurementReference,
)
routes.POST(
"/api/v1/procurement-requests/:id/purchase-task",
handler.createProcurementTask,
)
}
return nil
}
@@ -395,6 +395,199 @@ func TestAdminFreightAPIImportsAllItemsWithoutPII(t *testing.T) {
}
}
func TestAdminProcurementAPIProducesImmutablePendingTask(t *testing.T) {
fixture := newAdminIntegrationFixture(t)
createSync := performAdminRequest(
t,
fixture.router,
http.MethodPost,
"/api/v1/freight-syncs",
"application/json",
strings.NewReader(
`{"mode":"ORDER_NUMBER","order_number":"SOURCE-12"}`,
),
"procurement-freight-sync",
)
var syncBody struct {
Sync struct {
ID string `json:"id"`
} `json:"sync"`
}
decodeResponse(t, createSync, &syncBody)
succeeded := false
for attempt := 0; attempt < 50; attempt++ {
status := performAdminRequest(
t,
fixture.router,
http.MethodGet,
"/api/v1/freight-syncs/"+syncBody.Sync.ID,
"",
nil,
"",
)
if strings.Contains(status.Body.String(), `"status":"SUCCEEDED"`) {
succeeded = true
break
}
time.Sleep(10 * time.Millisecond)
}
if !succeeded {
t.Fatal("freight sync did not succeed")
}
orders := performAdminRequest(
t,
fixture.router,
http.MethodGet,
"/api/v1/freight-orders",
"",
nil,
"",
)
var orderBody struct {
Items []struct {
ID string `json:"id"`
} `json:"items"`
}
decodeResponse(t, orders, &orderBody)
detail := performAdminRequest(
t,
fixture.router,
http.MethodGet,
"/api/v1/freight-orders/"+orderBody.Items[0].ID,
"",
nil,
"",
)
var detailBody struct {
Items []struct {
ID string `json:"id"`
} `json:"items"`
}
decodeResponse(t, detail, &detailBody)
createRequest := performAdminRequest(
t,
fixture.router,
http.MethodPost,
"/api/v1/freight-items/"+detailBody.Items[0].ID+
"/procurement-request",
"application/json",
strings.NewReader(`{"confirm_procurement_needed":true}`),
"",
)
if createRequest.Code != http.StatusCreated ||
!strings.Contains(createRequest.Body.String(), `"status":"NEEDS_IMAGE"`) {
t.Fatalf(
"create request status/body = %d / %s",
createRequest.Code,
createRequest.Body,
)
}
var requestBody struct {
Request struct {
ID string `json:"id"`
} `json:"procurement_request"`
}
decodeResponse(t, createRequest, &requestBody)
imageBody, imageContentType := referenceUpload(t, "procurement-image")
upload := performAdminRequest(
t,
fixture.router,
http.MethodPost,
"/api/v1/assets",
imageContentType,
imageBody,
"procurement-image",
)
var assetBody struct {
ID string `json:"id"`
}
decodeResponse(t, upload, &assetBody)
bind := performAdminRequest(
t,
fixture.router,
http.MethodPut,
"/api/v1/procurement-requests/"+requestBody.Request.ID+
"/reference-asset",
"application/json",
strings.NewReader(
fmt.Sprintf(`{"image_asset_id":%q}`, assetBody.ID),
),
"",
)
if bind.Code != http.StatusOK ||
!strings.Contains(bind.Body.String(), `"status":"READY"`) {
t.Fatalf("bind status/body = %d / %s", bind.Code, bind.Body)
}
createTask := performAdminRequest(
t,
fixture.router,
http.MethodPost,
"/api/v1/procurement-requests/"+requestBody.Request.ID+
"/purchase-task",
"application/json",
strings.NewReader(`{}`),
"procurement-task-create",
)
if createTask.Code != http.StatusCreated ||
!strings.Contains(createTask.Body.String(), `"status":"PENDING"`) {
t.Fatalf(
"create task status/body = %d / %s",
createTask.Code,
createTask.Body,
)
}
var taskBody struct {
Task struct {
ID string `json:"id"`
} `json:"task"`
}
decodeResponse(t, createTask, &taskBody)
taskDetail := performAdminRequest(
t,
fixture.router,
http.MethodGet,
"/api/v1/tasks/"+taskBody.Task.ID,
"",
nil,
"",
)
for _, required := range []string{
`"title":"商品一"`,
`"sku":"BLACK-L"`,
`"quantity":1`,
`"image_asset_id":"` + assetBody.ID + `"`,
`"description":"ERP 规格:黑色,L"`,
} {
if !strings.Contains(taskDetail.Body.String(), required) {
t.Fatalf("task detail missing %q: %s", required, taskDetail.Body)
}
}
replay := performAdminRequest(
t,
fixture.router,
http.MethodPost,
"/api/v1/procurement-requests/"+requestBody.Request.ID+
"/purchase-task",
"application/json",
strings.NewReader(`{}`),
"procurement-task-replay",
)
if replay.Code != http.StatusCreated ||
!strings.Contains(replay.Body.String(), `"replayed":true`) ||
!strings.Contains(replay.Body.String(), taskBody.Task.ID) {
t.Fatalf("task replay status/body = %d / %s", replay.Code, replay.Body)
}
var sourceCount int
if err := fixture.db.QueryRow(
`SELECT count(*) FROM purchase_task_sources
WHERE task_id = ? AND procurement_request_id = ?`,
taskBody.Task.ID,
requestBody.Request.ID,
).Scan(&sourceCount); err != nil || sourceCount != 1 {
t.Fatalf("task source count = %d, error = %v", sourceCount, err)
}
}
func TestAdminOrderAuthorizationIsIdempotentAndRevisioned(t *testing.T) {
fixture := newAdminIntegrationFixture(t)
taskID, executionID, taskHash, firstKey, secondKey :=
@@ -515,6 +708,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("procurement migration down: %v", err)
}
if err := runner.Down(context.Background()); err != nil {
t.Fatalf("freight migration down: %v", err)
}
@@ -852,6 +1048,14 @@ func newAdminIntegrationFixture(t *testing.T) *adminIntegrationFixture {
if err != nil {
t.Fatalf("usecase.NewFreightService() error = %v", err)
}
procurement, err := usecase.NewProcurementService(
repositories,
clock,
ids,
)
if err != nil {
t.Fatalf("usecase.NewProcurementService() error = %v", err)
}
registrar, err := NewAdminRouteRegistrar(
AdminServices{
Assets: assets,
@@ -859,6 +1063,7 @@ func newAdminIntegrationFixture(t *testing.T) *adminIntegrationFixture {
Results: results,
Authorizations: authorizations,
Freight: freight,
Procurement: procurement,
},
emptyAdminWeb{},
)
@@ -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("procurement migration down: %v", err)
}
if err := runner.Down(context.Background()); err != nil {
t.Fatalf("freight migration down: %v", err)
}
@@ -730,8 +733,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 != 4 {
t.Fatalf("restored migrations = %d, want 4", applied)
} else if applied != 5 {
t.Fatalf("restored migrations = %d, want 5", applied)
}
completePayload := fmt.Sprintf(
@@ -1420,6 +1423,9 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable(
if err != nil {
t.Fatalf("migration.New() error = %v", err)
}
if err := runner.Down(context.Background()); err != nil {
t.Fatalf("procurement migration down: %v", err)
}
if err := runner.Down(context.Background()); err != nil {
t.Fatalf("freight migration down: %v", err)
}
@@ -147,10 +147,30 @@ func (h *adminHandlers) freightOrderDetail(ctx *gin.Context) {
})
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, gin.H{
response := gin.H{
"order": freightOrderResponse(detail.Order),
"items": items,
})
}
if h.services.Procurement != nil {
requests, requestErr := h.services.Procurement.ListForOrder(
ctx.Request.Context(),
localAdminSubject,
detail.Order.ID,
)
if requestErr != nil {
writeUsecaseError(ctx, requestErr)
return
}
requestItems := make([]gin.H, 0, len(requests))
for _, request := range requests {
requestItems = append(
requestItems,
procurementRequestResponse(request),
)
}
response["procurement_requests"] = requestItems
}
ctx.JSON(http.StatusOK, response)
}
func freightSyncResponse(run domain.FreightSyncRun) gin.H {
@@ -0,0 +1,168 @@
package httpapi
import (
"net/http"
"cmroubao/backend-api/internal/domain"
"cmroubao/backend-api/internal/usecase"
"github.com/gin-gonic/gin"
)
func (h *adminHandlers) createProcurementRequest(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 {
ConfirmProcurementNeeded bool `json:"confirm_procurement_needed"`
}
if err := decodeJSON(ctx, &request); err != nil {
writePublicError(
ctx,
http.StatusBadRequest,
"INVALID_JSON",
"request body must be valid JSON",
false,
gin.H{},
)
return
}
result, err := h.services.Procurement.CreateRequest(
ctx.Request.Context(),
usecase.CreateProcurementRequestCommand{
CreatorSubject: localAdminSubject,
ActorUserID: adminActorUserID(ctx),
FreightOrderItemID: ctx.Param("id"),
ConfirmProcurementNeeded: request.ConfirmProcurementNeeded,
},
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusCreated, gin.H{
"procurement_request": procurementRequestResponse(result.Request),
"replayed": result.Replayed,
})
}
func (h *adminHandlers) bindProcurementReference(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 {
ImageAssetID string `json:"image_asset_id"`
}
if err := decodeJSON(ctx, &request); err != nil {
writePublicError(
ctx,
http.StatusBadRequest,
"INVALID_JSON",
"request body must be valid JSON",
false,
gin.H{},
)
return
}
result, err := h.services.Procurement.BindReference(
ctx.Request.Context(),
usecase.BindProcurementReferenceCommand{
CreatorSubject: localAdminSubject,
ActorUserID: adminActorUserID(ctx),
RequestID: ctx.Param("id"),
ImageAssetID: request.ImageAssetID,
},
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, gin.H{
"procurement_request": procurementRequestResponse(result),
})
}
func (h *adminHandlers) createProcurementTask(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{}
if err := decodeJSON(ctx, &request); err != nil {
writePublicError(
ctx,
http.StatusBadRequest,
"INVALID_JSON",
"request body must be an empty JSON object",
false,
gin.H{},
)
return
}
result, err := h.services.Procurement.CreateTask(
ctx.Request.Context(),
usecase.CreateProcurementTaskCommand{
CreatorSubject: localAdminSubject,
ActorUserID: adminActorUserID(ctx),
RequestID: ctx.Param("id"),
IdempotencyKey: ctx.GetHeader("Idempotency-Key"),
},
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusCreated, gin.H{
"task": taskSummaryResponse(result.Task),
"replayed": result.Replayed,
})
}
func procurementRequestResponse(
request domain.ProcurementRequest,
) gin.H {
return gin.H{
"id": request.ID,
"freight_order_item_id": request.FreightOrderItemID,
"source_revision": request.SourceRevision,
"source_sha256": request.SourceSHA256,
"title": request.Title,
"product_spec": request.ProductSpec,
"sku": request.SKU,
"quantity": request.Quantity,
"source_purchase_status": request.SourcePurchaseStatus,
"status": request.Status,
"blocking_code": request.BlockingCode,
"reference_asset_id": request.ReferenceAssetID,
"purchase_task_id": request.PurchaseTaskID,
"source_changed": request.SourceChanged,
"created_at": formatTime(request.CreatedAt),
"updated_at": formatTime(request.UpdatedAt),
}
}