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
@@ -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>
+54 -1
View File
@@ -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)