feat(#132): continue purchase after replacement
This commit is contained in:
@@ -145,6 +145,8 @@ data class PurchaseHistoryDetail(
|
||||
val replacementMappingStatus: String?,
|
||||
val replacementActivationStatus: String?,
|
||||
val replacementActivationErrorMessage: String?,
|
||||
val continuePurchaseEligible: Boolean,
|
||||
val continuePurchaseDisabledReason: String?,
|
||||
)
|
||||
|
||||
class AgentApiException(
|
||||
@@ -330,6 +332,8 @@ class AgentApiClient(private val serverUrl: String) {
|
||||
replacementMappingStatus = data.nullableString("replacementMappingStatus"),
|
||||
replacementActivationStatus = data.nullableString("replacementActivationStatus"),
|
||||
replacementActivationErrorMessage = data.nullableString("replacementActivationErrorMessage"),
|
||||
continuePurchaseEligible = data.optBoolean("continuePurchaseEligible"),
|
||||
continuePurchaseDisabledReason = data.nullableString("continuePurchaseDisabledReason"),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -97,6 +97,10 @@ internal object ReplacementActionPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
internal object ContinuePurchasePolicy {
|
||||
fun showsAction(serverEligible: Boolean): Boolean = serverEligible
|
||||
}
|
||||
|
||||
internal class TaskDetailState(restoredTaskId: Long? = null) {
|
||||
var taskId: Long? = restoredTaskId?.takeIf { it > 0L }
|
||||
private set
|
||||
@@ -695,7 +699,8 @@ class TaskHistoryFragment : Fragment() {
|
||||
addView(context.label(info, 14f))
|
||||
}), collectionCardParams())
|
||||
if (task.errorMessage != null) resultColumn.addView(context.centeredMessage(task.errorMessage, "错误代码:${task.errorCode ?: "—"}"))
|
||||
if (PurchaseRetryPolicy.showsAction(task.status, task.retryable)) {
|
||||
val replacementInProgress = !detail.replacementMappingStatus.isNullOrBlank() || !detail.replacementActivationStatus.isNullOrBlank()
|
||||
if (!replacementInProgress && PurchaseRetryPolicy.showsAction(task.status, task.retryable)) {
|
||||
resultColumn.addView(MaterialButton(context).apply {
|
||||
text = "重试采购"
|
||||
minimumHeight = context.dp(48)
|
||||
@@ -703,7 +708,7 @@ class TaskHistoryFragment : Fragment() {
|
||||
setOnClickListener { confirmPurchaseRetry(task) }
|
||||
}, collectionCardParams())
|
||||
resultColumn.addView(context.centeredMessage("重试边界", "系统保留旧任务并创建新任务;不会执行支付。"))
|
||||
} else if (task.status == "failed") {
|
||||
} else if (!replacementInProgress && task.status == "failed") {
|
||||
val reason = task.retryDisabledReason?.takeIf(String::isNotBlank) ?: "请在管理端核对任务状态。"
|
||||
resultColumn.addView(context.centeredMessage("不可重试", reason))
|
||||
} else {
|
||||
@@ -717,6 +722,9 @@ class TaskHistoryFragment : Fragment() {
|
||||
originType = "purchase",
|
||||
taskId = task.taskId,
|
||||
taskNo = "CG-${task.taskId}",
|
||||
continuePurchaseEligible = detail.continuePurchaseEligible,
|
||||
continuePurchaseDisabledReason = detail.continuePurchaseDisabledReason,
|
||||
purchaseTask = task,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -728,8 +736,20 @@ class TaskHistoryFragment : Fragment() {
|
||||
originType: String,
|
||||
taskId: Long,
|
||||
taskNo: String,
|
||||
continuePurchaseEligible: Boolean = false,
|
||||
continuePurchaseDisabledReason: String? = null,
|
||||
purchaseTask: PurchaseHistoryItem? = null,
|
||||
) {
|
||||
val context = requireContext()
|
||||
if (ContinuePurchasePolicy.showsAction(continuePurchaseEligible) && purchaseTask != null) {
|
||||
resultColumn.addView(MaterialButton(context).apply {
|
||||
text = "继续采购"
|
||||
minimumHeight = context.dp(48)
|
||||
contentDescription = "继续采购任务 $taskNo"
|
||||
setOnClickListener { confirmPurchaseRetry(purchaseTask, continuing = true) }
|
||||
}, collectionCardParams())
|
||||
return
|
||||
}
|
||||
when (ReplacementActionPolicy.presentation(eligible, mappingStatus, activationStatus)) {
|
||||
ReplacementPresentation.ACTIVATION_FAILED -> resultColumn.addView(
|
||||
context.centeredMessage(
|
||||
@@ -738,7 +758,18 @@ class TaskHistoryFragment : Fragment() {
|
||||
),
|
||||
)
|
||||
ReplacementPresentation.MATCHING -> resultColumn.addView(context.centeredMessage("规格匹配中", "替代商品已提交,正在匹配规格。"))
|
||||
ReplacementPresentation.MATCHED -> resultColumn.addView(context.centeredMessage("规格已匹配", "可继续处理原任务。"))
|
||||
ReplacementPresentation.MATCHED -> {
|
||||
if (purchaseTask != null) {
|
||||
resultColumn.addView(
|
||||
context.centeredMessage(
|
||||
"暂不可继续采购",
|
||||
continuePurchaseDisabledReason?.takeIf(String::isNotBlank) ?: "请刷新后重试。",
|
||||
),
|
||||
)
|
||||
} else {
|
||||
resultColumn.addView(context.centeredMessage("规格已匹配", "替代商品规格已经确认。"))
|
||||
}
|
||||
}
|
||||
ReplacementPresentation.MANUAL_REQUIRED -> resultColumn.addView(context.centeredMessage("需要人工处理", "需在 Admin 人工匹配规格。"))
|
||||
ReplacementPresentation.ACTION -> resultColumn.addView(MaterialButton(context, null, com.google.android.material.R.attr.materialButtonOutlinedStyle).apply {
|
||||
text = "采集替代商品"
|
||||
@@ -763,25 +794,26 @@ class TaskHistoryFragment : Fragment() {
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun confirmPurchaseRetry(task: PurchaseHistoryItem) {
|
||||
private fun confirmPurchaseRetry(task: PurchaseHistoryItem, continuing: Boolean = false) {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle("重试采购 CG-${task.taskId}?")
|
||||
.setTitle(if (continuing) "继续采购 CG-${task.taskId}?" else "重试采购 CG-${task.taskId}?")
|
||||
.setMessage(PurchaseRetryPolicy.confirmationMessage())
|
||||
.setNegativeButton("取消", null)
|
||||
.setPositiveButton("确认重试") { _, _ -> retryPurchaseTask(task.taskId) }
|
||||
.setPositiveButton(if (continuing) "确认继续" else "确认重试") { _, _ -> retryPurchaseTask(task.taskId, continuing) }
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun retryPurchaseTask(taskId: Long) {
|
||||
private fun retryPurchaseTask(taskId: Long, continuing: Boolean = false) {
|
||||
val context = requireContext()
|
||||
val credentials = runCatching { SecureDeviceStore(context).credentials() }.getOrNull()
|
||||
val serverUrl = AgentSettingsStore(context).serverUrl()
|
||||
val failureTitle = if (continuing) "无法继续采购" else "无法重试采购"
|
||||
if (credentials == null || serverUrl.isBlank()) {
|
||||
showMessage("无法重试采购", "设备尚未连接服务端,请先检查设置。", "返回任务详情") { loadPurchaseDetail(taskId) }
|
||||
showMessage(failureTitle, "设备尚未连接服务端,请先检查设置。", "返回任务详情") { loadPurchaseDetail(taskId) }
|
||||
return
|
||||
}
|
||||
val generation = ++requestGeneration
|
||||
showLoading("正在提交重试请求…")
|
||||
showLoading(if (continuing) "正在提交继续采购请求…" else "正在提交重试请求…")
|
||||
Thread {
|
||||
runCatching { AgentApiClient(serverUrl).retryPurchaseTask(taskId, UUID.randomUUID().toString(), credentials.token) }
|
||||
.onSuccess { result ->
|
||||
@@ -794,7 +826,7 @@ class TaskHistoryFragment : Fragment() {
|
||||
.onFailure { error ->
|
||||
resultColumn.post {
|
||||
if (isAdded && generation == requestGeneration) {
|
||||
showMessage("无法重试采购", error.message ?: "请检查设备状态后重试。", "返回任务详情") { loadPurchaseDetail(taskId) }
|
||||
showMessage(failureTitle, error.message ?: "请检查设备状态后重试。", "返回任务详情") { loadPurchaseDetail(taskId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,19 @@ package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.ui.ReplacementActionPolicy
|
||||
import cn.ilapage.goauto.agent.ui.ReplacementPresentation
|
||||
import cn.ilapage.goauto.agent.ui.ContinuePurchasePolicy
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ReplacementActionPolicyTest {
|
||||
@Test
|
||||
fun continuePurchaseActionTrustsOnlyServerEligibility() {
|
||||
assertTrue(ContinuePurchasePolicy.showsAction(true))
|
||||
assertFalse(ContinuePurchasePolicy.showsAction(false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun serverEligibilityIsTheOnlyWayToShowTheAction() {
|
||||
assertEquals(ReplacementPresentation.ACTION, ReplacementActionPolicy.presentation(true, null, null))
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Business-Rules-and-Glossary
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
|
||||
wiki_revision: 5f829d4d42583b0654d772043047e93a9e1deb79
|
||||
synchronized_at: 2026-08-28T10:14:45Z
|
||||
wiki_revision: 6e33660c976c4412c3c456d04d5b4784a3043732
|
||||
synchronized_at: 2026-08-28T10:27:03Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -326,3 +326,11 @@ synchronized_at: 2026-08-28T10:14:45Z
|
||||
- 替代商品采集结果先按普通采集事务完整保存。随后调用 #131 的原子生效流程;生效失败不得回滚或覆盖已采集商品、规格和 SKU,而是记录稳定的 `failed` 激活状态与限长错误,服务启动后可按同一幂等键仅重试生效,不重新采集。
|
||||
- 任务详情的 `replacementMappingStatus` 必须由来源任务对应的替换分项推导,不能只读取主表总体状态。状态为 `matching` 时等待自动匹配,`manual_required` 时由 Admin 人工处理,`matched` 后才进入 #132 的继续采购流程。
|
||||
- 替换采集沿用设备级单任务互斥、采集间隔、无障碍安全边界和禁止支付规则;不会增加轮询,也不会创建采购任务或订单。
|
||||
|
||||
## 替换匹配完成后继续采购(#132)
|
||||
|
||||
- 只有采购来源对应的替换分项为 `matched`,且既有采购重试的任务终态、最新任务、不可逆边界、当前档案、持久化规格映射、PDD 候选、价格、设备在线/空闲与能力门禁全部通过时,Agent 详情才返回 `continuePurchaseEligible=true`。
|
||||
- 继续资格的详情查询不调用 AI,也不把当前可重新推导的确定性匹配当作已确认映射;映射被人工清空、PDD 重新采集导致候选变化或价格缺失时返回服务端原因。点击时再次执行同一前置门禁,防止详情读取后的状态漂移。
|
||||
- Agent 的“继续采购”复用既有 `AgentRetry → BatchRetry → Create`,请求仍只有来源任务号和 `requestId`;不新增采购创建接口。旧失败任务保持不可变,创建的新任务重新固化当前替代商品、规格、价格、规则和设备快照。
|
||||
- `matching` / `manual_required` 只展示匹配状态;`matched` 但资格不通过时展示服务端下发原因。Admin 批量重试行为不变。
|
||||
- 真机点击“继续采购”可能创建新的正式采购任务,必须先取得独立人工授权;永久禁止支付。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Android-Agent-API-Contract
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
|
||||
wiki_revision: 345d3b29ff7532ba4fd48d64aa3309b9d33225af
|
||||
synchronized_at: 2026-08-28T10:15:31Z
|
||||
wiki_revision: 81d3fec214250f9b5be4b049e65ca6a1144e52ad
|
||||
synchronized_at: 2026-08-28T10:27:46Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# MVP 共享 API 契约
|
||||
@@ -682,3 +682,21 @@ Content-Type: application/json
|
||||
`replacementOrigin.type` 只能是 `collection` 或 `purchase`。服务端在创建事务内重新检查资格,并把来源、可选纠错记录和初始 `pending` 激活状态固化到任务;同一 `requestId` 重放时来源必须完全一致。资格变化返回 HTTP 409 和 `REPLACEMENT_ORIGIN_NOT_ELIGIBLE`。
|
||||
|
||||
识别与结果提交继续复用当前页面采集接口。结果事务提交后,服务端调用 #131 的登记/纠错并生效流程:成功返回正常完成详情;生效失败返回 HTTP 409、`REPLACEMENT_ACTIVATION_FAILED`,但采集任务、商品、规格和 SKU 已安全保存。服务启动恢复只重试 `pending` / `failed` 的激活步骤,不重新打开 PDD 或重新采集。
|
||||
|
||||
### 替换匹配完成后继续采购(#132)
|
||||
|
||||
采购任务详情在 #130 字段之外增加:
|
||||
|
||||
```json
|
||||
{
|
||||
"replacementMappingStatus": "matched",
|
||||
"continuePurchaseEligible": true,
|
||||
"continuePurchaseDisabledReason": null
|
||||
}
|
||||
```
|
||||
|
||||
- `continuePurchaseEligible` 是 Agent 是否显示“继续采购”的唯一资格事实;Android 不组合任务状态、替换状态或既有 `retryable` 自行推断。
|
||||
- 服务端仅在该采购任务对应虾皮商品的 replacement item 为 `matched` 时计算继续资格,不读取替换主表总体状态。资格包含既有失败重试的终态、最新任务、不可逆边界、档案、持久化映射、PDD 候选、价格、设备与能力门禁。
|
||||
- 详情资格检查不调用 AI,也不把重新推导出的候选当成已确认映射。资格不通过时返回普通人可理解的 `continuePurchaseDisabledReason`;`matching` / `manual_required` 继续使用对应状态文字且不显示按钮。
|
||||
- 点击仍调用既有 `POST /api/agent/v1/purchase-tasks/{taskId}/retry`,请求体仍为 `{"requestId":"<uuid>"}`。AgentRetry 在委托 BatchRetry 前重新校验 replacement item 与继续资格,随后由 BatchRetry/Create 执行最终档案和并发校验;没有新增采购任务创建路径。
|
||||
- Admin 的 BatchRetry 接口和行为不变。相同 `requestId` 重放返回同一新任务;不同 requestId 再点由最新任务门禁拒绝。
|
||||
|
||||
@@ -58,6 +58,8 @@ type AgentPurchaseDetail struct {
|
||||
ReplacementMappingStatus string `json:"replacementMappingStatus,omitempty"`
|
||||
ReplacementActivationStatus string `json:"replacementActivationStatus,omitempty"`
|
||||
ReplacementActivationErrorMessage string `json:"replacementActivationErrorMessage,omitempty"`
|
||||
ContinuePurchaseEligible bool `json:"continuePurchaseEligible"`
|
||||
ContinuePurchaseDisabledReason string `json:"continuePurchaseDisabledReason,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Service) AgentHistory(ctx context.Context, req AgentHistoryRequest, token string) (AgentPurchaseList, error) {
|
||||
@@ -129,11 +131,17 @@ func (s *Service) AgentHistoryDetail(ctx context.Context, taskID uint64, token s
|
||||
if err != nil {
|
||||
return AgentPurchaseDetail{}, internal(err)
|
||||
}
|
||||
continueDecision := retryDecision{}
|
||||
if inspection.MappingStatus == models.ReplacementItemMappingMatched {
|
||||
continueDecision = s.continuePurchaseEligibility(ctx, task)
|
||||
}
|
||||
return AgentPurchaseDetail{
|
||||
Task: agentPurchaseItem(task, s.retryQueryEligibility(ctx, task, true)),
|
||||
ReplacementEligible: inspection.Eligible, ReplacementDisabledReason: inspection.DisabledReason,
|
||||
ReplacementMappingStatus: inspection.MappingStatus, ReplacementActivationStatus: inspection.ActivationStatus,
|
||||
ReplacementActivationErrorMessage: inspection.ActivationErrorMessage,
|
||||
ContinuePurchaseEligible: continueDecision.Allowed,
|
||||
ContinuePurchaseDisabledReason: continueDecision.Reason,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@ package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/device"
|
||||
"go-admin/app/goauto/models"
|
||||
@@ -11,6 +13,59 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func matchedReplacementForAgentRetry(t *testing.T, db *gorm.DB, f fixture, failed *models.PurchaseTask) models.PDDProduct {
|
||||
t.Helper()
|
||||
if err := db.First(&f.pdd, f.pdd.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := models.PDDProduct{
|
||||
GoodsID: "719834019132", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=719834019132",
|
||||
Title: "替代商品", Status: "active", SpecsJSON: f.pdd.SpecsJSON,
|
||||
}
|
||||
if err := db.Create(&target).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rule := models.CollectionRule{Name: "replacement-proof", ContentJSON: `{}`}
|
||||
if err := db.Create(&rule).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
proof := models.CollectionTask{
|
||||
PDDProductID: &target.ID, RuleID: rule.ID, DeviceID: &f.device.ID,
|
||||
Source: models.CollectionTaskSourceAgentCurrentPage, Status: models.TaskStatusCompleted,
|
||||
URLSnapshot: target.URL, GoodsIDSnapshot: target.GoodsID, RuleSnapshot: `{}`,
|
||||
}
|
||||
if err := db.Create(&proof).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 8, 20, 11, 0, 0, 0, time.UTC)
|
||||
record := models.PDDProductReplacement{
|
||||
SourceProductID: f.pdd.ID, TargetProductID: target.ID,
|
||||
OriginType: models.ReplacementOriginPurchase, OriginTaskID: failed.ID,
|
||||
TargetCollectionTaskID: proof.ID, CreatedByDeviceID: f.device.ID,
|
||||
Status: models.ReplacementStatusActive, MappingStatus: models.ReplacementMappingCompleted,
|
||||
CreateRequestID: uuid.NewString(), ActivatedAt: &now,
|
||||
}
|
||||
if err := db.Create(&record).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := models.ReplacementItemSourceManualMapping
|
||||
item := models.PDDProductReplacementItem{
|
||||
ReplacementID: record.ID, ShopeeProductID: f.shopee.ID,
|
||||
MappingStatus: models.ReplacementItemMappingMatched, Source: &source,
|
||||
}
|
||||
if err := db.Create(&item).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", f.shopee.ID).Update("pdd_product_id", target.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
errorCode := "PDD_GOODS_SOLD_OUT"
|
||||
if err := db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", failed.ID).Update("error_code", errorCode).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
func TestAgentRetryCreatesOneFixedDeviceTaskAndReplays(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
@@ -77,3 +132,51 @@ func TestAgentRetryRejectsAnotherDeviceAndUnsafeBoundary(t *testing.T) {
|
||||
t.Fatalf("unsafe boundary was retryable: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchedReplacementExposesContinueAndReusesAgentRetry(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
setCollectedPDDPrice(t, db, f.pdd.ID)
|
||||
service := testService(db)
|
||||
failed := failedLiveTask(t, db, service, f)
|
||||
target := matchedReplacementForAgentRetry(t, db, f, &failed)
|
||||
|
||||
detail, err := service.AgentHistoryDetail(context.Background(), failed.ID, f.token)
|
||||
if err != nil || detail.ReplacementMappingStatus != models.ReplacementItemMappingMatched || !detail.ContinuePurchaseEligible || detail.ContinuePurchaseDisabledReason != "" {
|
||||
t.Fatalf("continue qualification mismatch: detail=%+v error=%v", detail, err)
|
||||
}
|
||||
created, err := service.AgentRetry(context.Background(), failed.ID, AgentRetryRequest{RequestID: uuid.NewString()}, f.token)
|
||||
if err != nil {
|
||||
t.Fatalf("continue through AgentRetry: %v", err)
|
||||
}
|
||||
var newTask models.PurchaseTask
|
||||
if err := db.First(&newTask, created.TaskID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if newTask.PDDProductID != target.ID || newTask.PDDGoodsIDSnapshot != target.GoodsID {
|
||||
t.Fatalf("continue did not resolve current replacement archive: %+v", newTask)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchedReplacementRejectsClearedMappingWithoutCallingAI(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
setCollectedPDDPrice(t, db, f.pdd.ID)
|
||||
service := testService(db)
|
||||
failed := failedLiveTask(t, db, service, f)
|
||||
matchedReplacementForAgentRetry(t, db, f, &failed)
|
||||
if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", f.shopee.ID).Update("specs_json", `[]`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
matcher := &countingRetryMatcher{}
|
||||
service.Matcher = matcher
|
||||
|
||||
detail, err := service.AgentHistoryDetail(context.Background(), failed.ID, f.token)
|
||||
if err != nil || detail.ContinuePurchaseEligible || !strings.Contains(detail.ContinuePurchaseDisabledReason, "规格匹配已失效") || matcher.calls != 0 {
|
||||
t.Fatalf("cleared mapping qualification mismatch: detail=%+v calls=%d error=%v", detail, matcher.calls, err)
|
||||
}
|
||||
_, err = service.AgentRetry(context.Background(), failed.ID, AgentRetryRequest{RequestID: uuid.NewString()}, f.token)
|
||||
if code(err) != CodeMappingRequired || matcher.calls != 0 {
|
||||
t.Fatalf("cleared mapping reached AI or retry: code=%s calls=%d error=%v", code(err), matcher.calls, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"go-admin/app/goauto/device"
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/purchasecontract"
|
||||
"go-admin/app/goauto/replacement"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
@@ -161,6 +162,18 @@ func (s *Service) AgentRetry(ctx context.Context, taskID uint64, req AgentRetryR
|
||||
if queryErr != nil {
|
||||
return AgentRetryResponse{}, internal(queryErr)
|
||||
}
|
||||
inspection, err := replacement.NewService(s.DB).InspectOrigin(ctx, models.ReplacementOriginPurchase, taskID, deviceRecord.ID)
|
||||
if err != nil {
|
||||
return AgentRetryResponse{}, internal(err)
|
||||
}
|
||||
if inspection.MappingStatus != "" {
|
||||
if inspection.MappingStatus != models.ReplacementItemMappingMatched {
|
||||
return AgentRetryResponse{}, fail(CodeRetryNotAllowed, "替代商品规格尚未匹配完成")
|
||||
}
|
||||
if decision := s.continuePurchaseEligibility(ctx, source); !decision.Allowed {
|
||||
return AgentRetryResponse{}, fail(decision.ReasonCode, decision.Reason)
|
||||
}
|
||||
}
|
||||
result, err := s.BatchRetry(ctx, BatchRetryRequest{RequestID: req.RequestID, TaskIDs: []uint64{taskID}})
|
||||
if err != nil {
|
||||
return AgentRetryResponse{}, err
|
||||
@@ -224,6 +237,50 @@ func (s *Service) retryQueryEligibility(ctx context.Context, task models.Purchas
|
||||
return s.retryDeviceEligibility(ctx, task, checkDeviceBusy)
|
||||
}
|
||||
|
||||
// continuePurchaseEligibility is the read-side qualification used after a
|
||||
// replacement item reached matched. It applies the same state, archive, price
|
||||
// and device gates as retry, but never invokes AI and never treats a newly
|
||||
// derivable mapping as a persisted confirmation.
|
||||
func (s *Service) continuePurchaseEligibility(ctx context.Context, task models.PurchaseTask) retryDecision {
|
||||
decision := s.retryStateEligibility(ctx, task)
|
||||
if !decision.Allowed {
|
||||
return decision
|
||||
}
|
||||
dataset, err := s.loadBatchPreviewDataset(ctx, []uint64{*task.SYBProductID})
|
||||
if err != nil {
|
||||
return retryDecision{ReasonCode: CodeInternal, Reason: "服务端处理失败"}
|
||||
}
|
||||
syb, found := dataset.sybByID[*task.SYBProductID]
|
||||
if !found || syb.ShopeeProductID == nil {
|
||||
preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false)
|
||||
return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason}
|
||||
}
|
||||
shopee, found := dataset.shopeeByID[*syb.ShopeeProductID]
|
||||
if !found || shopee.PDDProductID == nil {
|
||||
preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false)
|
||||
return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason}
|
||||
}
|
||||
pdd, found := dataset.pddByID[*shopee.PDDProductID]
|
||||
if !found {
|
||||
preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false)
|
||||
return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason}
|
||||
}
|
||||
mappedColor, mappedSize, source := confirmedMappings(shopee.SpecsJSON, syb.TargetColor, syb.TargetSize)
|
||||
candidates, _ := archiveCandidates(pdd.SpecsJSON, syb.TargetColor, syb.TargetSize)
|
||||
if source == "unresolved" || !mappingTargetsValid(candidates, syb.TargetColor, syb.TargetSize, mappedColor, mappedSize) {
|
||||
return retryDecision{ReasonCode: CodeMappingRequired, Reason: "规格匹配已失效,请在 Admin 重新确认"}
|
||||
}
|
||||
preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false)
|
||||
if !preview.Eligible {
|
||||
return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason}
|
||||
}
|
||||
decision = s.retryDeviceEligibility(ctx, task, true)
|
||||
if !decision.Allowed {
|
||||
return decision
|
||||
}
|
||||
return retryDecision{Allowed: true, Preview: preview}
|
||||
}
|
||||
|
||||
func (s *Service) retryStateEligibility(ctx context.Context, task models.PurchaseTask) retryDecision {
|
||||
deny := func(code, message string) retryDecision {
|
||||
return retryDecision{ReasonCode: code, Reason: message}
|
||||
|
||||
Reference in New Issue
Block a user