feat(t219): show pending-payment reconciliation

This commit is contained in:
QiuSW
2026-07-28 18:16:09 +08:00
parent c2ec98d5be
commit 78dc595815
20 changed files with 768 additions and 37 deletions
@@ -834,6 +834,8 @@ type taskDetailView struct {
ExecutionReport *ExecutionReport
Candidates []AuthorizationCandidate
OrderAuthorizations []OrderAuthorization
OrderSubmissions []OrderSubmission
HasReconciledOrder bool
CanAuthorizeOrder bool
ActiveAuthorizationID string
}
@@ -865,6 +867,13 @@ func taskDetailViewFrom(task Task) taskDetailView {
canAuthorizeOrder = false
}
}
hasReconciledOrder := false
for _, submission := range task.OrderSubmissions {
if submission.Status == "RECONCILED" {
hasReconciledOrder = true
break
}
}
return taskDetailView{
ID: task.ID,
Title: task.Title,
@@ -885,6 +894,8 @@ func taskDetailViewFrom(task Task) taskDetailView {
ExecutionReport: task.ExecutionReport,
Candidates: task.Candidates,
OrderAuthorizations: task.OrderAuthorizations,
OrderSubmissions: task.OrderSubmissions,
HasReconciledOrder: hasReconciledOrder,
CanAuthorizeOrder: canAuthorizeOrder,
ActiveAuthorizationID: activeAuthorizationID,
}
@@ -14,6 +14,7 @@ import (
"testing"
"time"
"cmroubao/backend-api/internal/domain"
"cmroubao/backend-api/internal/usecase"
"github.com/gin-gonic/gin"
@@ -627,6 +628,196 @@ func TestTaskDetailDisablesAuthorizationAfterDelivery(t *testing.T) {
}
}
func TestTaskDetailRendersOrderSubmissionStatesWithoutPaymentActions(
t *testing.T,
) {
now := time.Date(2026, 7, 28, 9, 30, 0, 0, time.UTC)
tests := []struct {
name string
taskStatus string
submission OrderSubmission
expected []string
forbidden []string
}{
{
name: "reconciling",
taskStatus: "WAITING_CONFIRMATION",
submission: OrderSubmission{
Status: "FENCED",
StatusLabel: "正在对账",
FencedAt: now,
},
expected: []string{
"订单提交结果正在对账",
"禁止重复提交",
},
forbidden: []string{"待人工确认付款", "验证完成,未提交订单"},
},
{
name: "manual review",
taskStatus: "WAITING_CONFIRMATION",
submission: OrderSubmission{
Status: "MANUAL_REVIEW",
StatusLabel: "需要人工对账",
ManualReasonLabel: "找到多个可能订单",
FencedAt: now,
ManualReviewAt: now.Add(time.Minute),
},
expected: []string{
"需要人工对账",
"找到多个可能订单",
"禁止重新提交订单",
},
forbidden: []string{"待人工确认付款", "验证完成,未提交订单"},
},
{
name: "pending payment",
taskStatus: "SUCCEEDED",
submission: OrderSubmission{
Status: "RECONCILED",
StatusLabel: "待人工确认付款",
ExpectedTitle: "已授权灰色上衣",
ExpectedSKU: "灰色,2XL",
ExpectedQuantity: 2,
ExpectedUnitPrice: "21.50",
ExpectedTotalPrice: "43.00",
PlatformOrderNo: "12345678901234567890",
PlatformOrderedAt: now,
PlatformOrderStatus: "PENDING_PAYMENT",
EvidenceContentURL: "/api/v1/tasks/" + testTaskID +
"/evidence/00000000-0000-4000-8000-000000000078/content",
FencedAt: now.Add(-time.Minute),
ReconciledAt: now.Add(time.Minute),
},
expected: []string{
"待人工确认付款",
"请采购员打开拼多多订单列表",
"12345678901234567890",
"灰色,2XL",
"¥43.00",
"待付款订单列表对账截图",
},
forbidden: []string{
"验证完成,未提交订单",
"重试提交",
`href="pinduoduo`,
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
service := &fakeService{
getResult: Task{
ID: testTaskID,
Title: "订单状态任务",
SKU: "灰色,2XL",
Quantity: 2,
Status: test.taskStatus,
ReferenceAssetID: "00000000-0000-4000-8000-000000000009",
CreatedAt: now,
UpdatedAt: now,
OrderSubmissions: []OrderSubmission{test.submission},
},
}
response := performRequest(
t,
newTestRouter(t, service),
http.MethodGet,
"/tasks/"+testTaskID,
nil,
"",
)
if response.Code != http.StatusOK {
t.Fatalf(
"detail status/body = %d/%s",
response.Code,
response.Body,
)
}
body := response.Body.String()
for _, expected := range test.expected {
if !strings.Contains(body, expected) {
t.Fatalf("detail missing %q: %s", expected, body)
}
}
for _, forbidden := range test.forbidden {
if strings.Contains(body, forbidden) {
t.Fatalf("detail contains forbidden %q", forbidden)
}
}
if regexp.MustCompile(
`(?s)<(?:a|button)[^>]*>[^<]*(?:立即支付|确认支付|自动付款)`,
).MatchString(body) {
t.Fatal("detail exposes a payment action")
}
})
}
}
func TestTaskDetailKeepsHistoricalSucceededCopyWithoutSubmission(t *testing.T) {
now := time.Date(2026, 7, 28, 9, 30, 0, 0, time.UTC)
response := performRequest(
t,
newTestRouter(t, &fakeService{getResult: Task{
ID: testTaskID,
Title: "历史验证任务",
SKU: "HISTORY-SKU",
Quantity: 1,
Status: "SUCCEEDED",
ReferenceAssetID: "00000000-0000-4000-8000-000000000009",
CreatedAt: now,
UpdatedAt: now,
}}),
http.MethodGet,
"/tasks/"+testTaskID,
nil,
"",
)
if response.Code != http.StatusOK ||
!strings.Contains(
response.Body.String(),
"验证完成,未提交订单",
) {
t.Fatalf(
"historical succeeded detail = %d/%s",
response.Code,
response.Body,
)
}
}
func TestOrderSubmissionAdapterBuildsAuthenticatedEvidenceURL(t *testing.T) {
orderNo := "12345678901234567890"
status := "PENDING_PAYMENT"
evidenceID := "00000000-0000-4000-8000-000000000078"
orderedAt := time.Date(2026, 7, 28, 9, 30, 0, 0, time.UTC)
submission := orderSubmissionFrom(domain.OrderSubmission{
ID: "00000000-0000-4000-8000-000000000079",
TaskID: testTaskID,
Status: domain.OrderSubmissionReconciled,
ExpectedTitle: "已授权商品",
ExpectedSKU: "灰色,2XL",
ExpectedQuantity: 2,
ExpectedUnitPriceCents: 2150,
ExpectedTotalPriceCents: 4300,
PlatformOrderNo: &orderNo,
PlatformOrderedAt: &orderedAt,
PlatformOrderStatus: &status,
ReconciliationEvidenceAssetID: &evidenceID,
FencedAt: orderedAt.Add(-time.Minute),
ReconciledAt: &orderedAt,
})
if submission.StatusLabel != "待人工确认付款" ||
submission.PlatformOrderNo != orderNo ||
submission.ExpectedUnitPrice != "21.50" ||
submission.ExpectedTotalPrice != "43.00" ||
submission.EvidenceContentURL !=
"/api/v1/tasks/"+testTaskID+"/evidence/"+evidenceID+"/content" ||
strings.Contains(submission.EvidenceContentURL, orderNo) {
t.Fatalf("submission view = %+v", submission)
}
}
func TestTaskDetailCancelModeFollowsLifecycleStatus(t *testing.T) {
tests := []struct {
status string
@@ -813,6 +813,87 @@ tbody tr:last-child td {
font-weight: 800;
}
.order-state-banner {
margin-bottom: 18px;
padding: 18px;
border-left: 5px solid var(--info);
background: var(--info-soft);
}
.order-state-banner h2,
.order-state-banner p {
margin-top: 0;
}
.order-state-heading {
display: flex;
align-items: start;
justify-content: space-between;
gap: 16px;
}
.order-state-label {
margin-bottom: 3px;
color: var(--success);
font-size: 13px;
font-weight: 800;
}
.order-state-payment {
border-left-color: var(--success);
background: var(--success-soft);
}
.order-state-manual {
border-left-color: var(--warn);
background: var(--warn-soft);
}
.order-state-reconciling {
border-left-color: var(--info);
background: var(--info-soft);
}
.order-state-instruction {
margin-bottom: 16px;
font-weight: 700;
}
.pending-payment-layout {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(220px, 0.6fr);
gap: 20px;
align-items: start;
}
.pending-payment-details {
margin: 0;
}
.order-number {
overflow-wrap: anywhere;
font: 700 15px/1.5 ui-monospace, SFMono-Regular, Consolas, monospace;
}
.pending-payment-evidence {
margin: 0;
}
.pending-payment-evidence img {
display: block;
width: 100%;
max-height: 300px;
object-fit: contain;
border: 1px solid var(--line);
background: var(--surface);
}
.pending-payment-evidence figcaption {
margin-top: 6px;
color: var(--muted);
font-size: 12px;
}
.detail-layout {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(260px, 1fr);
@@ -1053,6 +1134,7 @@ tbody tr:last-child td {
.upload-layout,
.detail-layout,
.requirement-layout,
.pending-payment-layout,
.authorization-reasons {
grid-template-columns: 1fr;
}
@@ -29,7 +29,54 @@
</div>
</div>
{{if eq .Task.Status "SUCCEEDED"}}
{{range .Task.OrderSubmissions}}
{{if eq .Status "RECONCILED"}}
<section class="order-state-banner order-state-payment"
aria-labelledby="pending-payment-heading">
<div class="order-state-heading">
<div>
<p class="order-state-label">{{.StatusLabel}}</p>
<h2 id="pending-payment-heading">待人工确认付款</h2>
</div>
<span class="status status-success">待付款</span>
</div>
<p class="order-state-instruction">
请采购员打开拼多多订单列表,核对商品、规格、数量和金额后人工付款。
</p>
<div class="pending-payment-layout">
<dl class="definition-list pending-payment-details">
<dt>拼多多订单号</dt><dd class="order-number">{{.PlatformOrderNo}}</dd>
<dt>平台下单时间</dt>
<dd><time datetime="{{machineTime .PlatformOrderedAt}}">{{displayTime .PlatformOrderedAt}}</time></dd>
<dt>平台状态</dt><dd>待付款({{.PlatformOrderStatus}})</dd>
<dt>商品标题</dt><dd>{{.ExpectedTitle}}</dd>
<dt>SKU</dt><dd>{{.ExpectedSKU}}</dd>
<dt>数量</dt><dd>{{.ExpectedQuantity}}</dd>
<dt>核验单价</dt><dd>¥{{.ExpectedUnitPrice}}</dd>
<dt>核验总额</dt><dd>¥{{.ExpectedTotalPrice}}</dd>
</dl>
{{if .EvidenceContentURL}}
<figure class="pending-payment-evidence">
<img src="{{.EvidenceContentURL}}" alt="待付款订单列表对账截图">
<figcaption>设备回传的订单列表对账证据</figcaption>
</figure>
{{end}}
</div>
</section>
{{else if eq .Status "MANUAL_REVIEW"}}
<section class="order-state-banner order-state-manual" role="status">
<h2>{{.StatusLabel}}</h2>
<p>{{.ManualReasonLabel}}。提交围栏保持有效,禁止重新提交订单。</p>
</section>
{{else if eq .Status "FENCED"}}
<section class="order-state-banner order-state-reconciling" role="status">
<h2>{{.StatusLabel}}</h2>
<p>订单提交结果正在对账,提交围栏已经消费,禁止重复提交。</p>
</section>
{{end}}
{{end}}
{{if and (eq .Task.Status "SUCCEEDED") (not .Task.HasReconciledOrder)}}
<section class="success-banner" aria-labelledby="success-heading">
<div aria-hidden="true" class="success-mark">✓</div>
<div>
@@ -65,6 +65,7 @@ type Task struct {
ExecutionReport *ExecutionReport
Candidates []AuthorizationCandidate
OrderAuthorizations []OrderAuthorization
OrderSubmissions []OrderSubmission
}
type AuthorizationCandidate struct {
@@ -88,6 +89,25 @@ type OrderAuthorization struct {
CreatedAt time.Time
}
type OrderSubmission struct {
ID string
Status string
StatusLabel string
ExpectedTitle string
ExpectedSKU string
ExpectedQuantity int
ExpectedUnitPrice string
ExpectedTotalPrice string
PlatformOrderNo string
PlatformOrderedAt time.Time
PlatformOrderStatus string
EvidenceContentURL string
ManualReasonLabel string
FencedAt time.Time
ReconciledAt time.Time
ManualReviewAt time.Time
}
type ExecutionReport struct {
Events []ExecutionReportEvent
Evidence []ExecutionReportEvidence
@@ -280,9 +280,91 @@ func taskFromDetail(detail domain.TaskDetail) Task {
orderAuthorizationFrom(authorization),
)
}
for _, submission := range detail.OrderSubmissions {
task.OrderSubmissions = append(
task.OrderSubmissions,
orderSubmissionFrom(submission),
)
}
return task
}
func orderSubmissionFrom(
submission domain.OrderSubmission,
) OrderSubmission {
unitPrice := submission.ExpectedUnitPriceCents
totalPrice := submission.ExpectedTotalPriceCents
result := OrderSubmission{
ID: submission.ID,
Status: string(submission.Status),
StatusLabel: orderSubmissionStatusLabel(submission.Status),
ExpectedTitle: submission.ExpectedTitle,
ExpectedSKU: submission.ExpectedSKU,
ExpectedQuantity: submission.ExpectedQuantity,
ExpectedUnitPrice: *domain.FormatOptionalCNY(&unitPrice),
ExpectedTotalPrice: *domain.FormatOptionalCNY(&totalPrice),
FencedAt: submission.FencedAt,
}
if submission.PlatformOrderNo != nil {
result.PlatformOrderNo = *submission.PlatformOrderNo
}
if submission.PlatformOrderedAt != nil {
result.PlatformOrderedAt = *submission.PlatformOrderedAt
}
if submission.PlatformOrderStatus != nil {
result.PlatformOrderStatus = *submission.PlatformOrderStatus
}
if submission.ReconciliationEvidenceAssetID != nil {
result.EvidenceContentURL = "/api/v1/tasks/" +
submission.TaskID + "/evidence/" +
*submission.ReconciliationEvidenceAssetID + "/content"
}
if submission.ManualReasonCode != nil {
result.ManualReasonLabel = orderSubmissionManualReasonLabel(
*submission.ManualReasonCode,
)
}
if submission.ReconciledAt != nil {
result.ReconciledAt = *submission.ReconciledAt
}
if submission.ManualReviewAt != nil {
result.ManualReviewAt = *submission.ManualReviewAt
}
return result
}
func orderSubmissionStatusLabel(status domain.OrderSubmissionStatus) string {
switch status {
case domain.OrderSubmissionFenced:
return "正在对账"
case domain.OrderSubmissionManualReview:
return "需要人工对账"
case domain.OrderSubmissionReconciled:
return "待人工确认付款"
default:
return "未知状态"
}
}
func orderSubmissionManualReasonLabel(code string) string {
switch code {
case "ORDER_NOT_FOUND":
return "未找到符合条件的新订单"
case "ORDER_AMBIGUOUS":
return "找到多个可能订单"
case "ORDER_FIELDS_INCOMPLETE":
return "订单字段不完整"
case "ORDER_PAGE_UNKNOWN":
return "订单页面无法确认"
case "RISK_OR_PAYMENT_BOUNDARY":
return "遇到风控或付款边界"
case "EVIDENCE_UNAVAILABLE":
return "无法取得对账证据"
default:
return "订单需要人工核对"
}
}
func orderAuthorizationFrom(
authorization domain.OrderAuthorization,
) OrderAuthorization {