feat(t223): generate tasks from freight items
This commit is contained in:
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,23 @@ func (h *Handler) RegisterProtected(routes gin.IRoutes) {
|
||||
routes.POST("/freight/import", SecurityHeaders(), h.CreateFreightImport)
|
||||
routes.GET("/freight/:id", SecurityHeaders(), h.FreightDetail)
|
||||
}
|
||||
if _, ok := h.service.(ProcurementService); ok {
|
||||
routes.POST(
|
||||
"/freight/items/:id/procurement-request",
|
||||
SecurityHeaders(),
|
||||
h.CreateFreightProcurementRequest,
|
||||
)
|
||||
routes.POST(
|
||||
"/freight/procurement-requests/:id/reference",
|
||||
SecurityHeaders(),
|
||||
h.BindFreightProcurementReference,
|
||||
)
|
||||
routes.POST(
|
||||
"/freight/procurement-requests/:id/purchase-task",
|
||||
SecurityHeaders(),
|
||||
h.CreateFreightProcurementTask,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) ListFreight(ctx *gin.Context) {
|
||||
@@ -193,6 +210,23 @@ func (h *Handler) FreightDetail(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
token, _ := csrfToken(ctx)
|
||||
for index := range detail.Items {
|
||||
if detail.Items[index].Request == nil {
|
||||
continue
|
||||
}
|
||||
uploadKey, keyErr := newToken()
|
||||
if keyErr != nil {
|
||||
h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
|
||||
return
|
||||
}
|
||||
taskKey, keyErr := newToken()
|
||||
if keyErr != nil {
|
||||
h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
|
||||
return
|
||||
}
|
||||
detail.Items[index].Request.UploadKey = uploadKey
|
||||
detail.Items[index].Request.TaskKey = taskKey
|
||||
}
|
||||
h.render(ctx, http.StatusOK, "freight-detail", freightDetailPage{
|
||||
Page: pageView{
|
||||
Title: "货运详情",
|
||||
@@ -200,9 +234,148 @@ func (h *Handler) FreightDetail(ctx *gin.Context) {
|
||||
CSRFToken: token,
|
||||
},
|
||||
Detail: detail,
|
||||
Notice: freightNotice(ctx.Query("notice")),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) CreateFreightProcurementRequest(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
|
||||
}
|
||||
orderID := strings.TrimSpace(ctx.PostForm("order_id"))
|
||||
if pathEscape(orderID) == "invalid" ||
|
||||
ctx.PostForm("confirm_procurement_needed") != "1" {
|
||||
h.renderError(ctx, http.StatusUnprocessableEntity, "必须人工确认", "请核对来源商品后确认仍需采购。")
|
||||
return
|
||||
}
|
||||
service := h.service.(ProcurementService)
|
||||
_, err := service.CreateProcurementRequest(
|
||||
ctx.Request.Context(),
|
||||
CreateProcurementRequestInput{
|
||||
ActorUserID: actorUserID(ctx.Request.Context()),
|
||||
FreightOrderItemID: strings.TrimSpace(ctx.Param("id")),
|
||||
ConfirmProcurementNeeded: true,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrConflict) || errors.Is(err, ErrValidation) {
|
||||
ctx.Redirect(
|
||||
http.StatusSeeOther,
|
||||
"/freight/"+pathEscape(orderID)+"?notice=request-conflict",
|
||||
)
|
||||
return
|
||||
}
|
||||
h.renderServiceError(ctx, err, "采购需求创建失败,请稍后重试。")
|
||||
return
|
||||
}
|
||||
ctx.Redirect(
|
||||
http.StatusSeeOther,
|
||||
"/freight/"+pathEscape(orderID)+"?notice=request-created",
|
||||
)
|
||||
}
|
||||
|
||||
func (h *Handler) BindFreightProcurementReference(ctx *gin.Context) {
|
||||
ctx.Request.Body = http.MaxBytesReader(
|
||||
ctx.Writer,
|
||||
ctx.Request.Body,
|
||||
maxRequestBytes,
|
||||
)
|
||||
if err := ctx.Request.ParseMultipartForm(maxRequestBytes); err != nil ||
|
||||
!validCSRF(ctx) {
|
||||
h.renderError(ctx, http.StatusForbidden, "请求已失效", "请返回货运详情后重新操作。")
|
||||
return
|
||||
}
|
||||
if ctx.Request.MultipartForm != nil {
|
||||
defer ctx.Request.MultipartForm.RemoveAll()
|
||||
}
|
||||
orderID := strings.TrimSpace(ctx.PostForm("order_id"))
|
||||
uploadKey := strings.TrimSpace(ctx.PostForm("upload_key"))
|
||||
if pathEscape(orderID) == "invalid" || !validToken(uploadKey) {
|
||||
h.renderError(ctx, http.StatusForbidden, "请求已失效", "请返回货运详情后重新操作。")
|
||||
return
|
||||
}
|
||||
asset, err := h.uploadReference(ctx, uploadKey)
|
||||
if err != nil {
|
||||
ctx.Redirect(
|
||||
http.StatusSeeOther,
|
||||
"/freight/"+pathEscape(orderID)+"?notice=image-invalid",
|
||||
)
|
||||
return
|
||||
}
|
||||
service := h.service.(ProcurementService)
|
||||
_, err = service.BindProcurementReference(
|
||||
ctx.Request.Context(),
|
||||
BindProcurementReferenceInput{
|
||||
ActorUserID: actorUserID(ctx.Request.Context()),
|
||||
RequestID: strings.TrimSpace(ctx.Param("id")),
|
||||
ImageAssetID: asset.ID,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
ctx.Redirect(
|
||||
http.StatusSeeOther,
|
||||
"/freight/"+pathEscape(orderID)+"?notice=reference-conflict",
|
||||
)
|
||||
return
|
||||
}
|
||||
ctx.Redirect(
|
||||
http.StatusSeeOther,
|
||||
"/freight/"+pathEscape(orderID)+"?notice=reference-bound",
|
||||
)
|
||||
}
|
||||
|
||||
func (h *Handler) CreateFreightProcurementTask(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
|
||||
}
|
||||
orderID := strings.TrimSpace(ctx.PostForm("order_id"))
|
||||
taskKey := strings.TrimSpace(ctx.PostForm("task_key"))
|
||||
if pathEscape(orderID) == "invalid" || !validToken(taskKey) {
|
||||
h.renderError(ctx, http.StatusForbidden, "请求已失效", "请返回货运详情后重新操作。")
|
||||
return
|
||||
}
|
||||
service := h.service.(ProcurementService)
|
||||
task, err := service.CreateProcurementTask(
|
||||
ctx.Request.Context(),
|
||||
CreateProcurementTaskInput{
|
||||
ActorUserID: actorUserID(ctx.Request.Context()),
|
||||
RequestID: strings.TrimSpace(ctx.Param("id")),
|
||||
IdempotencyKey: taskKey,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
ctx.Redirect(
|
||||
http.StatusSeeOther,
|
||||
"/freight/"+pathEscape(orderID)+"?notice=task-conflict",
|
||||
)
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/tasks/"+pathEscape(task.ID))
|
||||
}
|
||||
|
||||
func freightNotice(value string) string {
|
||||
switch value {
|
||||
case "request-created":
|
||||
return "采购需求已创建,请补充参考图。"
|
||||
case "request-conflict":
|
||||
return "来源已变化或当前商品不能创建采购需求。"
|
||||
case "image-invalid":
|
||||
return "参考图片无效,请选择 JPG、PNG 或 WebP 后重试。"
|
||||
case "reference-conflict":
|
||||
return "参考图已被使用或来源已变化,请刷新后重试。"
|
||||
case "reference-bound":
|
||||
return "参考图已绑定,可以生成采购任务。"
|
||||
case "task-conflict":
|
||||
return "需求状态或来源已变化,当前不能生成任务。"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func SecurityHeaders() gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
ctx.Header(
|
||||
@@ -892,6 +1065,7 @@ type freightImportPage struct {
|
||||
type freightDetailPage struct {
|
||||
Page pageView
|
||||
Detail FreightOrderDetail
|
||||
Notice string
|
||||
}
|
||||
|
||||
type statusOption struct {
|
||||
|
||||
@@ -994,6 +994,83 @@ func TestFreightPagesEscapeSourceDataAndCreateAsyncSync(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreightDetailCreatesProcurementTaskWithCSRF(t *testing.T) {
|
||||
const itemID = "00000000-0000-4000-8000-000000000002"
|
||||
service := &fakeProcurementService{
|
||||
fakeFreightService: &fakeFreightService{
|
||||
fakeService: &fakeService{},
|
||||
orderDetail: FreightOrderDetail{
|
||||
Order: FreightOrder{
|
||||
ID: testTaskID,
|
||||
ExternalStockID: "12",
|
||||
SourceCode: "SOURCE-12",
|
||||
},
|
||||
Items: []FreightItemReview{{
|
||||
Item: FreightOrderItem{
|
||||
ID: itemID,
|
||||
ExternalItemID: "88",
|
||||
Title: "商品",
|
||||
SKU: "BLACK-L",
|
||||
Quantity: 2,
|
||||
},
|
||||
Request: &ProcurementRequest{
|
||||
ID: itemID,
|
||||
FreightOrderItemID: itemID,
|
||||
Status: "READY",
|
||||
StatusLabel: "可以生成任务",
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
createTaskResult: Task{
|
||||
ID: testTaskID,
|
||||
Status: "PENDING",
|
||||
},
|
||||
}
|
||||
router := newTestRouter(t, service)
|
||||
detail := performRequest(
|
||||
t,
|
||||
router,
|
||||
http.MethodGet,
|
||||
"/freight/"+testTaskID,
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
if detail.Code != http.StatusOK ||
|
||||
!strings.Contains(detail.Body.String(), "生成采购任务") ||
|
||||
!strings.Contains(detail.Body.String(), "可以生成任务") {
|
||||
t.Fatalf("detail status/body = %d / %s", detail.Code, detail.Body)
|
||||
}
|
||||
cookie := csrfCookie(t, detail)
|
||||
taskKey := hiddenValue(t, detail.Body.String(), "task_key")
|
||||
values := url.Values{
|
||||
"csrf_token": {cookie.Value},
|
||||
"order_id": {testTaskID},
|
||||
"task_key": {taskKey},
|
||||
}
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/freight/procurement-requests/"+itemID+"/purchase-task",
|
||||
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") != "/tasks/"+testTaskID {
|
||||
t.Fatalf(
|
||||
"create task status/location = %d / %q",
|
||||
response.Code,
|
||||
response.Header().Get("Location"),
|
||||
)
|
||||
}
|
||||
if service.createTaskInput.RequestID != itemID ||
|
||||
service.createTaskInput.IdempotencyKey != taskKey {
|
||||
t.Fatalf("create task input = %+v", service.createTaskInput)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeService struct {
|
||||
listInput ListTasksInput
|
||||
listResult TaskList
|
||||
@@ -1027,6 +1104,40 @@ type fakeFreightService struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type fakeProcurementService struct {
|
||||
*fakeFreightService
|
||||
createRequestInput CreateProcurementRequestInput
|
||||
bindInput BindProcurementReferenceInput
|
||||
createTaskInput CreateProcurementTaskInput
|
||||
procurementResult ProcurementRequest
|
||||
createTaskResult Task
|
||||
procurementError error
|
||||
}
|
||||
|
||||
func (service *fakeProcurementService) CreateProcurementRequest(
|
||||
_ context.Context,
|
||||
input CreateProcurementRequestInput,
|
||||
) (ProcurementRequest, error) {
|
||||
service.createRequestInput = input
|
||||
return service.procurementResult, service.procurementError
|
||||
}
|
||||
|
||||
func (service *fakeProcurementService) BindProcurementReference(
|
||||
_ context.Context,
|
||||
input BindProcurementReferenceInput,
|
||||
) (ProcurementRequest, error) {
|
||||
service.bindInput = input
|
||||
return service.procurementResult, service.procurementError
|
||||
}
|
||||
|
||||
func (service *fakeProcurementService) CreateProcurementTask(
|
||||
_ context.Context,
|
||||
input CreateProcurementTaskInput,
|
||||
) (Task, error) {
|
||||
service.createTaskInput = input
|
||||
return service.createTaskResult, service.procurementError
|
||||
}
|
||||
|
||||
func (service *fakeFreightService) ListFreightOrders(
|
||||
context.Context,
|
||||
int,
|
||||
|
||||
@@ -721,6 +721,35 @@ tbody tr:last-child td {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.procurement-actions {
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.procurement-actions form {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.procurement-actions input[type="file"] {
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.confirm-line {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.confirm-line input {
|
||||
width: 20px;
|
||||
min-height: 20px;
|
||||
flex: 0 0 auto;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.task-form {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
</div>
|
||||
<a class="button" href="/freight">返回列表</a>
|
||||
</div>
|
||||
{{if .Notice}}<div class="notice" role="status">{{.Notice}}</div>{{end}}
|
||||
<section class="detail-section" aria-labelledby="freight-source-title">
|
||||
<h2 id="freight-source-title">来源信息</h2>
|
||||
<dl class="detail-grid">
|
||||
@@ -35,23 +36,73 @@
|
||||
<th scope="col">数量</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}}
|
||||
<strong>{{if .Item.Title}}{{.Item.Title}}{{else}}缺少标题{{end}}</strong>
|
||||
<span class="secondary">明细 ID:{{.Item.ExternalItemID}}</span>
|
||||
{{if .Item.ProductThumbRef}}<span class="secondary">图片引用:{{.Item.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>
|
||||
<span>{{if .Item.ProductSpec}}{{.Item.ProductSpec}}{{else}}未提供规格{{end}}</span>
|
||||
<span class="secondary">SKU:{{if .Item.SKU}}{{.Item.SKU}}{{else}}未提供{{end}}</span>
|
||||
</td>
|
||||
<td data-label="数量">{{if .Item.Quantity}}{{.Item.Quantity}}{{else}}未提供{{end}}</td>
|
||||
<td data-label="采购状态">{{if .Item.PurchaseStatus}}{{.Item.PurchaseStatus}}{{else}}未提供{{end}}</td>
|
||||
<td data-label="来源版本">{{.Item.Revision}}</td>
|
||||
<td data-label="采购处理" class="procurement-actions">
|
||||
{{if .Request}}
|
||||
<strong>{{.Request.StatusLabel}}</strong>
|
||||
{{if .Request.BlockingLabel}}<span class="secondary">{{.Request.BlockingLabel}}</span>{{end}}
|
||||
{{if .Request.SourceChanged}}
|
||||
<form method="post" action="/freight/items/{{pathPart .Item.ID}}/procurement-request">
|
||||
<input type="hidden" name="csrf_token" value="{{$.Page.CSRFToken}}">
|
||||
<input type="hidden" name="order_id" value="{{$.Detail.Order.ID}}">
|
||||
<label class="confirm-line">
|
||||
<input type="checkbox" name="confirm_procurement_needed" value="1" required>
|
||||
<span>已核对当前版本仍需采购</span>
|
||||
</label>
|
||||
<button class="button" type="submit">创建当前版本需求</button>
|
||||
</form>
|
||||
{{else if eq .Request.Status "NEEDS_IMAGE"}}
|
||||
<form method="post" enctype="multipart/form-data"
|
||||
action="/freight/procurement-requests/{{pathPart .Request.ID}}/reference">
|
||||
<input type="hidden" name="csrf_token" value="{{$.Page.CSRFToken}}">
|
||||
<input type="hidden" name="order_id" value="{{$.Detail.Order.ID}}">
|
||||
<input type="hidden" name="upload_key" value="{{.Request.UploadKey}}">
|
||||
<label>
|
||||
<span class="visually-hidden">参考图片</span>
|
||||
<input name="image" type="file" accept="image/jpeg,image/png,image/webp" required>
|
||||
</label>
|
||||
<button class="button" type="submit">上传参考图</button>
|
||||
</form>
|
||||
{{else if eq .Request.Status "READY"}}
|
||||
<form method="post"
|
||||
action="/freight/procurement-requests/{{pathPart .Request.ID}}/purchase-task">
|
||||
<input type="hidden" name="csrf_token" value="{{$.Page.CSRFToken}}">
|
||||
<input type="hidden" name="order_id" value="{{$.Detail.Order.ID}}">
|
||||
<input type="hidden" name="task_key" value="{{.Request.TaskKey}}">
|
||||
<button class="button primary" type="submit">生成采购任务</button>
|
||||
</form>
|
||||
{{else if .Request.PurchaseTaskID}}
|
||||
<a class="detail-link" href="/tasks/{{pathPart .Request.PurchaseTaskID}}">查看采购任务</a>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<form method="post" action="/freight/items/{{pathPart .Item.ID}}/procurement-request">
|
||||
<input type="hidden" name="csrf_token" value="{{$.Page.CSRFToken}}">
|
||||
<input type="hidden" name="order_id" value="{{$.Detail.Order.ID}}">
|
||||
<label class="confirm-line">
|
||||
<input type="checkbox" name="confirm_procurement_needed" value="1" required>
|
||||
<span>已核对该商品仍需采购</span>
|
||||
</label>
|
||||
<button class="button" type="submit">创建采购需求</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</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>
|
||||
|
||||
@@ -37,6 +37,39 @@ type FreightService interface {
|
||||
) (FreightSync, error)
|
||||
}
|
||||
|
||||
type ProcurementService interface {
|
||||
CreateProcurementRequest(
|
||||
context.Context,
|
||||
CreateProcurementRequestInput,
|
||||
) (ProcurementRequest, error)
|
||||
BindProcurementReference(
|
||||
context.Context,
|
||||
BindProcurementReferenceInput,
|
||||
) (ProcurementRequest, error)
|
||||
CreateProcurementTask(
|
||||
context.Context,
|
||||
CreateProcurementTaskInput,
|
||||
) (Task, error)
|
||||
}
|
||||
|
||||
type CreateProcurementRequestInput struct {
|
||||
ActorUserID string
|
||||
FreightOrderItemID string
|
||||
ConfirmProcurementNeeded bool
|
||||
}
|
||||
|
||||
type BindProcurementReferenceInput struct {
|
||||
ActorUserID string
|
||||
RequestID string
|
||||
ImageAssetID string
|
||||
}
|
||||
|
||||
type CreateProcurementTaskInput struct {
|
||||
ActorUserID string
|
||||
RequestID string
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
type FreightSync struct {
|
||||
ID string
|
||||
Status string
|
||||
@@ -81,7 +114,27 @@ type FreightOrderItem struct {
|
||||
|
||||
type FreightOrderDetail struct {
|
||||
Order FreightOrder
|
||||
Items []FreightOrderItem
|
||||
Items []FreightItemReview
|
||||
}
|
||||
|
||||
type FreightItemReview struct {
|
||||
Item FreightOrderItem
|
||||
Request *ProcurementRequest
|
||||
}
|
||||
|
||||
type ProcurementRequest struct {
|
||||
ID string
|
||||
FreightOrderItemID string
|
||||
SourceRevision int
|
||||
Status string
|
||||
StatusLabel string
|
||||
BlockingCode string
|
||||
BlockingLabel string
|
||||
ReferenceAssetID string
|
||||
PurchaseTaskID string
|
||||
SourceChanged bool
|
||||
UploadKey string
|
||||
TaskKey string
|
||||
}
|
||||
|
||||
type ListTasksInput struct {
|
||||
|
||||
@@ -18,6 +18,13 @@ type UsecaseAdapter struct {
|
||||
assets *usecase.AssetService
|
||||
authorizations *usecase.OrderAuthorizationService
|
||||
freight *usecase.FreightService
|
||||
procurement *usecase.ProcurementService
|
||||
}
|
||||
|
||||
func (adapter *UsecaseAdapter) SetProcurement(
|
||||
procurement *usecase.ProcurementService,
|
||||
) {
|
||||
adapter.procurement = procurement
|
||||
}
|
||||
|
||||
func NewUsecaseAdapter(
|
||||
@@ -73,7 +80,23 @@ func (adapter *UsecaseAdapter) GetFreightOrder(
|
||||
if err != nil {
|
||||
return FreightOrderDetail{}, mapUsecaseError(err)
|
||||
}
|
||||
items := make([]FreightOrderItem, 0, len(detail.Items))
|
||||
requestByItem := map[string]domain.ProcurementRequest{}
|
||||
if adapter.procurement != nil {
|
||||
requests, requestErr := adapter.procurement.ListForOrder(
|
||||
ctx,
|
||||
localAdminSubject,
|
||||
detail.Order.ID,
|
||||
)
|
||||
if requestErr != nil {
|
||||
return FreightOrderDetail{}, mapUsecaseError(requestErr)
|
||||
}
|
||||
for _, request := range requests {
|
||||
if _, exists := requestByItem[request.FreightOrderItemID]; !exists {
|
||||
requestByItem[request.FreightOrderItemID] = request
|
||||
}
|
||||
}
|
||||
}
|
||||
items := make([]FreightItemReview, 0, len(detail.Items))
|
||||
for _, item := range detail.Items {
|
||||
view := FreightOrderItem{
|
||||
ID: item.ID,
|
||||
@@ -88,7 +111,12 @@ func (adapter *UsecaseAdapter) GetFreightOrder(
|
||||
if item.Quantity != nil {
|
||||
view.Quantity = *item.Quantity
|
||||
}
|
||||
items = append(items, view)
|
||||
review := FreightItemReview{Item: view}
|
||||
if request, exists := requestByItem[item.ID]; exists {
|
||||
requestView := procurementRequestFrom(request)
|
||||
review.Request = &requestView
|
||||
}
|
||||
items = append(items, review)
|
||||
}
|
||||
return FreightOrderDetail{
|
||||
Order: freightOrderFrom(detail.Order),
|
||||
@@ -96,6 +124,131 @@ func (adapter *UsecaseAdapter) GetFreightOrder(
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (adapter *UsecaseAdapter) CreateProcurementRequest(
|
||||
ctx context.Context,
|
||||
input CreateProcurementRequestInput,
|
||||
) (ProcurementRequest, error) {
|
||||
if adapter.procurement == nil {
|
||||
return ProcurementRequest{}, ErrUnavailable
|
||||
}
|
||||
result, err := adapter.procurement.CreateRequest(
|
||||
ctx,
|
||||
usecase.CreateProcurementRequestCommand{
|
||||
CreatorSubject: localAdminSubject,
|
||||
ActorUserID: input.ActorUserID,
|
||||
FreightOrderItemID: input.FreightOrderItemID,
|
||||
ConfirmProcurementNeeded: input.ConfirmProcurementNeeded,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return ProcurementRequest{}, mapUsecaseError(err)
|
||||
}
|
||||
return procurementRequestFrom(result.Request), nil
|
||||
}
|
||||
|
||||
func (adapter *UsecaseAdapter) BindProcurementReference(
|
||||
ctx context.Context,
|
||||
input BindProcurementReferenceInput,
|
||||
) (ProcurementRequest, error) {
|
||||
if adapter.procurement == nil {
|
||||
return ProcurementRequest{}, ErrUnavailable
|
||||
}
|
||||
request, err := adapter.procurement.BindReference(
|
||||
ctx,
|
||||
usecase.BindProcurementReferenceCommand{
|
||||
CreatorSubject: localAdminSubject,
|
||||
ActorUserID: input.ActorUserID,
|
||||
RequestID: input.RequestID,
|
||||
ImageAssetID: input.ImageAssetID,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return ProcurementRequest{}, mapUsecaseError(err)
|
||||
}
|
||||
return procurementRequestFrom(request), nil
|
||||
}
|
||||
|
||||
func (adapter *UsecaseAdapter) CreateProcurementTask(
|
||||
ctx context.Context,
|
||||
input CreateProcurementTaskInput,
|
||||
) (Task, error) {
|
||||
if adapter.procurement == nil {
|
||||
return Task{}, ErrUnavailable
|
||||
}
|
||||
result, err := adapter.procurement.CreateTask(
|
||||
ctx,
|
||||
usecase.CreateProcurementTaskCommand{
|
||||
CreatorSubject: localAdminSubject,
|
||||
ActorUserID: input.ActorUserID,
|
||||
RequestID: input.RequestID,
|
||||
IdempotencyKey: input.IdempotencyKey,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return Task{}, mapUsecaseError(err)
|
||||
}
|
||||
return taskFromPurchase(result.Task), nil
|
||||
}
|
||||
|
||||
func procurementRequestFrom(
|
||||
request domain.ProcurementRequest,
|
||||
) ProcurementRequest {
|
||||
result := ProcurementRequest{
|
||||
ID: request.ID,
|
||||
FreightOrderItemID: request.FreightOrderItemID,
|
||||
SourceRevision: request.SourceRevision,
|
||||
Status: string(request.Status),
|
||||
StatusLabel: procurementStatusLabel(request.Status),
|
||||
BlockingCode: stringValue(request.BlockingCode),
|
||||
BlockingLabel: procurementBlockingLabel(
|
||||
stringValue(request.BlockingCode),
|
||||
),
|
||||
ReferenceAssetID: stringValue(request.ReferenceAssetID),
|
||||
PurchaseTaskID: stringValue(request.PurchaseTaskID),
|
||||
SourceChanged: request.SourceChanged,
|
||||
}
|
||||
if request.SourceChanged {
|
||||
result.StatusLabel = "来源已变化"
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func procurementStatusLabel(
|
||||
status domain.ProcurementRequestStatus,
|
||||
) string {
|
||||
switch status {
|
||||
case domain.ProcurementBlocked:
|
||||
return "资料阻塞"
|
||||
case domain.ProcurementNeedsImage:
|
||||
return "需要参考图"
|
||||
case domain.ProcurementReady:
|
||||
return "可以生成任务"
|
||||
case domain.ProcurementTaskCreated:
|
||||
return "任务已生成"
|
||||
case domain.ProcurementSourceChanged:
|
||||
return "来源已变化"
|
||||
default:
|
||||
return "未知状态"
|
||||
}
|
||||
}
|
||||
|
||||
func procurementBlockingLabel(code string) string {
|
||||
switch code {
|
||||
case domain.ProcurementBlockSourceCanceled:
|
||||
return "货运单已取消"
|
||||
case domain.ProcurementBlockTitleRequired:
|
||||
return "商品标题缺失或超限"
|
||||
case domain.ProcurementBlockSKURequired:
|
||||
return "SKU 缺失或超限"
|
||||
case domain.ProcurementBlockQuantityRequired:
|
||||
return "数量缺失或无效"
|
||||
case domain.ProcurementBlockSourceChanged:
|
||||
return "ERP 来源已更新"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (adapter *UsecaseAdapter) GetFreightSync(
|
||||
ctx context.Context,
|
||||
syncID string,
|
||||
@@ -660,3 +813,4 @@ func (err *adapterError) Unwrap() []error {
|
||||
|
||||
var _ Service = (*UsecaseAdapter)(nil)
|
||||
var _ FreightService = (*UsecaseAdapter)(nil)
|
||||
var _ ProcurementService = (*UsecaseAdapter)(nil)
|
||||
|
||||
Reference in New Issue
Block a user