feat(t215): add admin order authorization

This commit is contained in:
QiuSW
2026-07-28 12:50:42 +08:00
parent 16c2ea410a
commit 827afc7257
24 changed files with 2688 additions and 113 deletions
+121 -25
View File
@@ -66,6 +66,11 @@ func (h *Handler) RegisterProtected(routes gin.IRoutes) {
routes.POST("/tasks", SecurityHeaders(), h.CreateTask)
routes.GET("/tasks/:id", SecurityHeaders(), h.TaskDetail)
routes.POST("/tasks/:id/cancel", SecurityHeaders(), h.CancelTask)
routes.POST(
"/tasks/:id/order-authorizations",
SecurityHeaders(),
h.AuthorizeOrder,
)
}
func SecurityHeaders() gin.HandlerFunc {
@@ -264,20 +269,85 @@ func (h *Handler) TaskDetail(ctx *gin.Context) {
h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
return
}
authorizationKey, keyErr := newToken()
if keyErr != nil {
h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
return
}
page := taskDetailPage{
Page: pageView{
Title: "任务详情",
TasksCurrent: true,
CSRFToken: token,
},
Task: taskDetailViewFrom(task),
CSRFToken: token,
CancelKey: cancelKey,
Notice: detailNotice(ctx.Query("notice")),
Task: taskDetailViewFrom(task),
CSRFToken: token,
CancelKey: cancelKey,
AuthorizationKey: authorizationKey,
Notice: detailNotice(ctx.Query("notice")),
}
h.render(ctx, http.StatusOK, "task-detail", page)
}
func (h *Handler) AuthorizeOrder(ctx *gin.Context) {
if !validCSRF(ctx) {
h.renderError(
ctx,
http.StatusForbidden,
"请求已失效",
"请返回任务详情后重新操作。",
)
return
}
taskID := strings.TrimSpace(ctx.Param("id"))
authorizationKey := strings.TrimSpace(
ctx.PostForm("authorization_key"),
)
expectedVersion, versionErr := strconv.ParseInt(
strings.TrimSpace(ctx.PostForm("expected_task_version")),
10,
64,
)
if !validToken(authorizationKey) || versionErr != nil ||
expectedVersion < 1 {
h.renderError(
ctx,
http.StatusForbidden,
"请求已失效",
"请返回任务详情后重新操作。",
)
return
}
_, err := h.service.AuthorizeOrder(
ctx.Request.Context(),
AuthorizeOrderInput{
TaskID: taskID,
IdempotencyKey: authorizationKey,
ExpectedTaskVersion: expectedVersion,
CandidateKey: strings.TrimSpace(ctx.PostForm("candidate_key")),
SelectedReasonCode: strings.TrimSpace(ctx.PostForm("selected_reason_code")),
RejectedReasonCode: strings.TrimSpace(ctx.PostForm("rejected_reason_code")),
Note: strings.TrimSpace(ctx.PostForm("authorization_note")),
SupersedesAuthorizationID: strings.TrimSpace(ctx.PostForm("supersedes_authorization_id")),
},
)
if err != nil {
if errors.Is(err, ErrConflict) || errors.Is(err, ErrValidation) {
ctx.Redirect(
http.StatusSeeOther,
"/tasks/"+pathEscape(taskID)+"?notice=authorization-conflict",
)
return
}
h.renderServiceError(ctx, err, "授权失败,请稍后重试。")
return
}
ctx.Redirect(
http.StatusSeeOther,
"/tasks/"+pathEscape(taskID)+"?notice=authorization-created",
)
}
func (h *Handler) CancelTask(ctx *gin.Context) {
if !validCSRF(ctx) {
h.renderError(
@@ -652,6 +722,10 @@ func detailNotice(value string) string {
return "已请求设备安全停止;设备确认前任务仍保持当前执行状态。"
case "cancel-conflict":
return "任务状态已变化,当前不能取消。"
case "authorization-created":
return "候选已确认,待投递下单授权已创建。"
case "authorization-conflict":
return "候选或任务状态已变化,请检查最新证据后重新授权。"
default:
return ""
}
@@ -742,29 +816,35 @@ type newTaskPageView struct {
}
type taskDetailView struct {
ID string
Title string
SKU string
Description string
Quantity int64
MaxBudget string
Status string
StatusLabel string
StatusClass string
ReferenceAssetID string
CreatedAt time.Time
UpdatedAt time.Time
CanCancel bool
CancelRequiresAck bool
ExecutionReport *ExecutionReport
ID string
Title string
SKU string
Description string
Quantity int64
MaxBudget string
Status string
Version int64
StatusLabel string
StatusClass string
ReferenceAssetID string
CreatedAt time.Time
UpdatedAt time.Time
CanCancel bool
CancelRequiresAck bool
ExecutionReport *ExecutionReport
Candidates []AuthorizationCandidate
OrderAuthorizations []OrderAuthorization
CanAuthorizeOrder bool
ActiveAuthorizationID string
}
type taskDetailPage struct {
Page pageView
Task taskDetailView
CSRFToken string
CancelKey string
Notice string
Page pageView
Task taskDetailView
CSRFToken string
CancelKey string
AuthorizationKey string
Notice string
}
type errorPage struct {
@@ -774,6 +854,17 @@ type errorPage struct {
}
func taskDetailViewFrom(task Task) taskDetailView {
activeAuthorizationID := ""
canAuthorizeOrder := task.Status == "WAITING_CONFIRMATION" &&
len(task.Candidates) > 0
for _, authorization := range task.OrderAuthorizations {
switch authorization.Status {
case "PENDING_DELIVERY":
activeAuthorizationID = authorization.ID
case "DELIVERED", "ACKNOWLEDGED", "EXECUTING":
canAuthorizeOrder = false
}
}
return taskDetailView{
ID: task.ID,
Title: task.Title,
@@ -782,6 +873,7 @@ func taskDetailViewFrom(task Task) taskDetailView {
Quantity: task.Quantity,
MaxBudget: task.MaxBudget,
Status: task.Status,
Version: task.Version,
StatusLabel: statusLabel(task.Status),
StatusClass: statusClass(task.Status),
ReferenceAssetID: task.ReferenceAssetID,
@@ -790,7 +882,11 @@ func taskDetailViewFrom(task Task) taskDetailView {
CanCancel: canCancelTaskStatus(task.Status),
CancelRequiresAck: task.Status == "RUNNING" ||
task.Status == "WAITING_CONFIRMATION",
ExecutionReport: task.ExecutionReport,
ExecutionReport: task.ExecutionReport,
Candidates: task.Candidates,
OrderAuthorizations: task.OrderAuthorizations,
CanAuthorizeOrder: canAuthorizeOrder,
ActiveAuthorizationID: activeAuthorizationID,
}
}
@@ -509,6 +509,124 @@ func TestTaskDetailDoesNotLeakForbiddenResource(t *testing.T) {
}
}
func TestTaskDetailAuthorizesOneCandidateWithCSRFAndPRG(t *testing.T) {
firstKey := strings.Repeat("a", 64)
secondKey := strings.Repeat("b", 64)
service := &fakeService{
getResult: Task{
ID: testTaskID,
Title: "候选确认任务",
SKU: "BLACK-L",
Quantity: 2,
Status: "WAITING_CONFIRMATION",
Version: 7,
ReferenceAssetID: "00000000-0000-4000-8000-000000000009",
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
TaskContentSHA256: strings.Repeat("c", 64),
Candidates: []AuthorizationCandidate{
{
CandidateKey: firstKey,
Ordinal: 1,
Title: "候选一",
SKUText: "BLACK-L",
PriceText: "20.00",
EvidenceURLs: []string{"/api/v1/assets/evidence-1/content"},
},
{
CandidateKey: secondKey,
Ordinal: 2,
Title: "候选二",
SKUText: "BLACK-XL",
PriceText: "22.00",
},
},
},
}
router := newTestRouter(t, service)
detail := performRequest(
t,
router,
http.MethodGet,
"/tasks/"+testTaskID,
nil,
"",
)
if detail.Code != http.StatusOK {
t.Fatalf("detail status/body = %d/%s", detail.Code, detail.Body)
}
for _, expected := range []string{
"候选确认与下单授权",
"系统只创建待付款订单",
"候选一",
"BLACK-XL",
`name="candidate_key"`,
} {
if !strings.Contains(detail.Body.String(), expected) {
t.Fatalf("authorization detail missing %q", expected)
}
}
cookie := csrfCookie(t, detail)
authorizationKey := hiddenValue(
t,
detail.Body.String(),
"authorization_key",
)
form := url.Values{
"csrf_token": {cookie.Value},
"authorization_key": {authorizationKey},
"expected_task_version": {"7"},
"candidate_key": {firstKey},
"selected_reason_code": {"SKU_MATCH"},
"rejected_reason_code": {"NOT_BEST_MATCH"},
"authorization_note": {"已核对图片、规格与价格"},
"supersedes_authorization_id": {""},
}
request := httptest.NewRequest(
http.MethodPost,
"/tasks/"+testTaskID+"/order-authorizations",
strings.NewReader(form.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+"?notice=authorization-created" {
t.Fatalf(
"status/location = %d/%q",
response.Code,
response.Header().Get("Location"),
)
}
if service.authorizeInput.TaskID != testTaskID ||
service.authorizeInput.IdempotencyKey != authorizationKey ||
service.authorizeInput.ExpectedTaskVersion != 7 ||
service.authorizeInput.CandidateKey != firstKey ||
service.authorizeInput.SelectedReasonCode != "SKU_MATCH" ||
service.authorizeInput.RejectedReasonCode != "NOT_BEST_MATCH" {
t.Fatalf("authorize input = %+v", service.authorizeInput)
}
}
func TestTaskDetailDisablesAuthorizationAfterDelivery(t *testing.T) {
view := taskDetailViewFrom(Task{
Status: "WAITING_CONFIRMATION",
Candidates: []AuthorizationCandidate{{
CandidateKey: strings.Repeat("a", 64),
}},
OrderAuthorizations: []OrderAuthorization{{
ID: "00000000-0000-4000-8000-000000000010",
Status: "DELIVERED",
}},
})
if view.CanAuthorizeOrder {
t.Fatal("delivered authorization remains editable")
}
}
func TestTaskDetailCancelModeFollowsLifecycleStatus(t *testing.T) {
tests := []struct {
status string
@@ -614,23 +732,26 @@ func TestRendererUsesMissingKeyErrors(t *testing.T) {
}
type fakeService struct {
listInput ListTasksInput
listResult TaskList
listErr error
getResult Task
getErr error
uploadResult UploadedAsset
uploadErr error
uploadInput UploadReferenceInput
uploadBody []byte
uploadCalls int
createResult Task
createErr error
createInput CreateTaskInput
createCalls int
cancelResult Task
cancelErr error
cancelInput CancelTaskInput
listInput ListTasksInput
listResult TaskList
listErr error
getResult Task
getErr error
uploadResult UploadedAsset
uploadErr error
uploadInput UploadReferenceInput
uploadBody []byte
uploadCalls int
createResult Task
createErr error
createInput CreateTaskInput
createCalls int
cancelResult Task
cancelErr error
cancelInput CancelTaskInput
authorizeResult OrderAuthorization
authorizeErr error
authorizeInput AuthorizeOrderInput
}
func (service *fakeService) ListTasks(
@@ -679,6 +800,14 @@ func (service *fakeService) CancelTask(
return service.cancelResult, service.cancelErr
}
func (service *fakeService) AuthorizeOrder(
_ context.Context,
input AuthorizeOrderInput,
) (OrderAuthorization, error) {
service.authorizeInput = input
return service.authorizeResult, service.authorizeErr
}
func newTestRouter(t *testing.T, service Service) http.Handler {
t.Helper()
gin.SetMode(gin.TestMode)
@@ -101,6 +101,157 @@ textarea {
overflow-wrap: anywhere;
}
.authorization-section {
margin-top: 20px;
}
.section-heading-row {
display: flex;
align-items: start;
justify-content: space-between;
gap: 16px;
}
.section-heading-row h2 {
margin-bottom: 4px;
}
.candidate-count {
flex: 0 0 auto;
color: var(--muted);
font-weight: 700;
}
.authorization-form {
display: grid;
gap: 18px;
margin-top: 18px;
}
.candidate-fieldset {
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.candidate-fieldset legend {
margin-bottom: 9px;
font-weight: 700;
}
.authorization-candidates {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.authorization-candidate {
display: grid;
min-width: 0;
gap: 7px;
padding: 14px;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--surface-soft);
cursor: pointer;
}
.authorization-candidate:has(input:checked) {
border-color: var(--brand);
box-shadow: 0 0 0 2px rgba(11, 107, 80, 0.14);
}
.candidate-choice {
display: flex;
align-items: center;
gap: 8px;
color: var(--brand-dark);
font-weight: 800;
}
.candidate-choice input,
.authorization-confirm input {
width: 20px;
min-height: 20px;
margin: 0;
}
.candidate-key {
display: block;
overflow-wrap: anywhere;
color: var(--muted);
font-size: 11px;
}
.candidate-evidence {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
}
.candidate-evidence img {
width: 100%;
aspect-ratio: 4 / 3;
object-fit: contain;
border: 1px solid var(--line);
background: var(--surface);
}
.authorization-reasons {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.authorization-reasons label {
display: grid;
gap: 6px;
font-weight: 700;
}
.authorization-note {
grid-column: 1 / -1;
}
.authorization-confirm {
display: flex;
align-items: flex-start;
gap: 9px;
padding: 12px;
border-left: 4px solid var(--warn);
background: var(--warn-soft);
}
.authorization-form > .button {
justify-self: start;
}
.authorization-unavailable {
padding: 12px;
border-left: 4px solid var(--warn);
background: var(--warn-soft);
}
.authorization-history {
display: grid;
gap: 8px;
padding-left: 22px;
}
.authorization-history li {
padding: 10px 12px;
border-bottom: 1px solid var(--line);
}
.authorization-history span,
.authorization-history time {
display: block;
overflow-wrap: anywhere;
color: var(--muted);
font-size: 13px;
}
button,
input,
select {
@@ -901,10 +1052,15 @@ tbody tr:last-child td {
.upload-layout,
.detail-layout,
.requirement-layout {
.requirement-layout,
.authorization-reasons {
grid-template-columns: 1fr;
}
.authorization-note {
grid-column: auto;
}
.image-preview {
max-width: 320px;
}
@@ -978,6 +1134,19 @@ tbody tr:last-child td {
padding: 18px 14px;
}
.section-heading-row {
align-items: stretch;
flex-direction: column;
}
.authorization-candidates {
grid-template-columns: 1fr;
}
.authorization-form > .button {
width: 100%;
}
.form-actions,
.dialog-actions {
flex-direction: column-reverse;
@@ -73,6 +73,108 @@
</aside>
</div>
{{if .Task.Candidates}}
<section class="content-section authorization-section" aria-labelledby="authorization-heading">
<div class="section-heading-row">
<div>
<h2 id="authorization-heading">候选确认与下单授权</h2>
<p class="section-note">选择只会授权设备创建一笔待付款订单,不授权付款。</p>
</div>
<span class="candidate-count">{{len .Task.Candidates}} 个候选</span>
</div>
{{if .Task.CanAuthorizeOrder}}
<form class="authorization-form" method="post"
action="/tasks/{{pathPart .Task.ID}}/order-authorizations">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="authorization_key" value="{{.AuthorizationKey}}">
<input type="hidden" name="expected_task_version" value="{{.Task.Version}}">
<input type="hidden" name="supersedes_authorization_id"
value="{{.Task.ActiveAuthorizationID}}">
<fieldset class="candidate-fieldset">
<legend>选择要采购的商品</legend>
<div class="authorization-candidates">
{{range .Task.Candidates}}
<label class="authorization-candidate">
<span class="candidate-choice">
<input type="radio" name="candidate_key" value="{{.CandidateKey}}" required>
<span>候选 {{.Ordinal}}</span>
</span>
<strong>{{.Title}}</strong>
<span>规格:{{if .SKUText}}{{.SKUText}}{{else}}未读取{{end}}</span>
<span>组合价格:{{if .PriceText}}{{.PriceText}}{{else}}未读取{{end}}</span>
<code class="candidate-key">{{.CandidateKey}}</code>
{{if .EvidenceURLs}}
<span class="candidate-evidence">
{{range .EvidenceURLs}}<img src="{{.}}" alt="候选受控证据截图">{{end}}
</span>
{{end}}
</label>
{{end}}
</div>
</fieldset>
<div class="authorization-reasons">
<label>
<span>所选候选理由</span>
<select name="selected_reason_code" required>
<option value="">请选择</option>
<option value="SKU_MATCH">SKU 匹配</option>
<option value="IMAGE_MATCH">图片匹配</option>
{{if .Task.MaxBudget}}<option value="PRICE_ACCEPTABLE">价格可接受</option>{{end}}
<option value="EVIDENCE_SUFFICIENT">证据充分</option>
</select>
</label>
<label>
<span>其余候选拒绝理由</span>
<select name="rejected_reason_code" required>
<option value="">请选择</option>
<option value="NOT_BEST_MATCH">不是最佳匹配</option>
<option value="SKU_MISMATCH">SKU 不匹配</option>
<option value="IMAGE_MISMATCH">图片不匹配</option>
{{if .Task.MaxBudget}}<option value="PRICE_TOO_HIGH">价格过高</option>{{end}}
<option value="OUT_OF_STOCK">无库存</option>
<option value="EVIDENCE_INSUFFICIENT">证据不足</option>
</select>
</label>
<label class="authorization-note">
<span>授权备注(可选)</span>
<textarea name="authorization_note" rows="3" maxlength="200"></textarea>
</label>
</div>
<label class="authorization-confirm">
<input type="checkbox" required>
<span>我确认商品、SKU、数量和证据;系统只创建待付款订单,付款由人员在拼多多完成。</span>
</label>
<button class="button primary" type="submit">
{{if .Task.ActiveAuthorizationID}}改选并创建新授权{{else}}确认商品并授权下单{{end}}
</button>
</form>
{{else}}
<p class="authorization-unavailable" role="status">
当前候选不能创建或修改授权,请查看下方授权状态或刷新任务。
</p>
{{end}}
{{if .Task.OrderAuthorizations}}
<h3>授权历史</h3>
<ol class="authorization-history">
{{range .Task.OrderAuthorizations}}
<li>
<strong>版本 {{.Version}} · {{.Status}}</strong>
<span>候选 {{.CandidateKey}}</span>
<span>规格 {{if .CandidateSKUText}}{{.CandidateSKUText}}{{else}}未读取{{end}} ·
数量 {{.Quantity}} · 价格 {{if .CandidatePriceText}}{{.CandidatePriceText}}{{else}}未读取{{end}}</span>
<time datetime="{{machineTime .CreatedAt}}">{{displayTime .CreatedAt}}</time>
</li>
{{end}}
</ol>
{{end}}
</section>
{{end}}
{{with .Task.ExecutionReport}}
<section class="content-section execution-audit" aria-labelledby="execution-audit-heading">
<h2 id="execution-audit-heading">执行审计</h2>
+49 -11
View File
@@ -24,6 +24,7 @@ type Service interface {
UploadReference(context.Context, UploadReferenceInput) (UploadedAsset, error)
CreateTask(context.Context, CreateTaskInput) (Task, error)
CancelTask(context.Context, CancelTaskInput) (Task, error)
AuthorizeOrder(context.Context, AuthorizeOrderInput) (OrderAuthorization, error)
}
type ListTasksInput struct {
@@ -48,17 +49,43 @@ type TaskSummary struct {
}
type Task struct {
ID string
Title string
SKU string
Description string
Quantity int64
MaxBudget string
Status string
ReferenceAssetID string
CreatedAt time.Time
UpdatedAt time.Time
ExecutionReport *ExecutionReport
ID string
Title string
SKU string
Description string
Quantity int64
MaxBudget string
Status string
Version int64
ExecutionID string
TaskContentSHA256 string
ReferenceAssetID string
CreatedAt time.Time
UpdatedAt time.Time
ExecutionReport *ExecutionReport
Candidates []AuthorizationCandidate
OrderAuthorizations []OrderAuthorization
}
type AuthorizationCandidate struct {
CandidateKey string
Ordinal int
Title string
SKUText string
PriceText string
EvidenceURLs []string
}
type OrderAuthorization struct {
ID string
Version int
CandidateKey string
CandidateSKUText string
CandidatePriceText string
Quantity int
Status string
SupersedesID string
CreatedAt time.Time
}
type ExecutionReport struct {
@@ -130,3 +157,14 @@ type CancelTaskInput struct {
TaskID string
IdempotencyKey string
}
type AuthorizeOrderInput struct {
TaskID string
IdempotencyKey string
ExpectedTaskVersion int64
CandidateKey string
SelectedReasonCode string
RejectedReasonCode string
Note string
SupersedesAuthorizationID string
}
@@ -14,20 +14,23 @@ import (
const localAdminSubject = "local-admin"
type UsecaseAdapter struct {
tasks *usecase.TaskService
assets *usecase.AssetService
tasks *usecase.TaskService
assets *usecase.AssetService
authorizations *usecase.OrderAuthorizationService
}
func NewUsecaseAdapter(
tasks *usecase.TaskService,
assets *usecase.AssetService,
authorizations *usecase.OrderAuthorizationService,
) (*UsecaseAdapter, error) {
if tasks == nil || assets == nil {
if tasks == nil || assets == nil || authorizations == nil {
return nil, errors.New("admin web use cases are required")
}
return &UsecaseAdapter{
tasks: tasks,
assets: assets,
tasks: tasks,
assets: assets,
authorizations: authorizations,
}, nil
}
@@ -146,6 +149,70 @@ func (adapter *UsecaseAdapter) CancelTask(
return taskFromPurchase(task), nil
}
func (adapter *UsecaseAdapter) AuthorizeOrder(
ctx context.Context,
input AuthorizeOrderInput,
) (OrderAuthorization, error) {
detail, err := adapter.tasks.Get(ctx, localAdminSubject, input.TaskID)
if err != nil {
return OrderAuthorization{}, mapUsecaseError(err)
}
if detail.Task.Version != input.ExpectedTaskVersion ||
detail.Execution == nil ||
detail.Report == nil ||
detail.Report.DecisionDataset == nil {
return OrderAuthorization{}, ErrConflict
}
var supersedes *string
if input.SupersedesAuthorizationID != "" {
value := input.SupersedesAuthorizationID
supersedes = &value
}
items := make(
[]usecase.OrderAuthorizationItemInput,
0,
len(detail.Report.DecisionDataset.Observations),
)
for _, observation := range detail.Report.DecisionDataset.Observations {
if observation.Identity == nil {
return OrderAuthorization{}, ErrConflict
}
label := "REJECT"
reason := input.RejectedReasonCode
if observation.Identity.CandidateKey == input.CandidateKey {
label = "ACCEPT"
reason = input.SelectedReasonCode
}
items = append(items, usecase.OrderAuthorizationItemInput{
CandidateKey: observation.Identity.CandidateKey,
Label: label,
PrimaryReasonCode: reason,
ReasonCodes: []string{reason},
})
}
result, err := adapter.authorizations.Create(
ctx,
usecase.CreateOrderAuthorizationCommand{
ActorUserID: actorUserID(ctx),
TaskID: input.TaskID,
IdempotencyKey: input.IdempotencyKey,
ExecutionID: detail.Execution.ID,
TaskContentSHA256: usecase.TaskContentSHA256(detail.Task),
ExpectedTaskVersion: input.ExpectedTaskVersion,
CandidateKey: input.CandidateKey,
ReasonSchemaVersion: 1,
PrimaryReasonCode: "SELECTED_BEST_MATCH",
Note: input.Note,
SupersedesAuthorizationID: supersedes,
Items: items,
},
)
if err != nil {
return OrderAuthorization{}, mapUsecaseError(err)
}
return orderAuthorizationFrom(result.Authorization), nil
}
func taskSummaryFrom(task domain.PurchaseTask) TaskSummary {
return TaskSummary{
ID: task.ID,
@@ -177,11 +244,64 @@ func taskFromPurchase(task domain.PurchaseTask) Task {
func taskFromDetail(detail domain.TaskDetail) Task {
task := taskFromPurchase(detail.Task)
task.Version = detail.Task.Version
task.ReferenceAssetID = detail.Asset.ID
task.ExecutionReport = executionReportFrom(detail.Report)
task.TaskContentSHA256 = usecase.TaskContentSHA256(detail.Task)
if detail.Execution != nil {
task.ExecutionID = detail.Execution.ID
}
if detail.Report != nil && detail.Report.DecisionDataset != nil {
for _, observation := range detail.Report.DecisionDataset.Observations {
if observation.Identity == nil {
continue
}
candidate := AuthorizationCandidate{
CandidateKey: observation.Identity.CandidateKey,
Ordinal: observation.Ordinal,
Title: observation.Title,
SKUText: observation.SKUText,
PriceText: observation.PriceText,
EvidenceURLs: make([]string, 0, len(observation.EvidenceAssetIDs)),
}
for _, evidenceID := range observation.EvidenceAssetIDs {
candidate.EvidenceURLs = append(
candidate.EvidenceURLs,
"/api/v1/tasks/"+detail.Task.ID+
"/evidence/"+evidenceID+"/content",
)
}
task.Candidates = append(task.Candidates, candidate)
}
}
for _, authorization := range detail.OrderAuthorizations {
task.OrderAuthorizations = append(
task.OrderAuthorizations,
orderAuthorizationFrom(authorization),
)
}
return task
}
func orderAuthorizationFrom(
authorization domain.OrderAuthorization,
) OrderAuthorization {
result := OrderAuthorization{
ID: authorization.ID,
Version: authorization.AuthorizationVersion,
CandidateKey: authorization.CandidateKey,
CandidateSKUText: authorization.CandidateSKUText,
CandidatePriceText: authorization.CandidatePriceText,
Quantity: authorization.Quantity,
Status: string(authorization.Status),
CreatedAt: authorization.CreatedAt,
}
if authorization.SupersedesAuthorizationID != nil {
result.SupersedesID = *authorization.SupersedesAuthorizationID
}
return result
}
func executionReportFrom(report *domain.ExecutionReport) *ExecutionReport {
if report == nil {
return nil