feat(#132): continue purchase after replacement

This commit is contained in:
QiuSW
2026-08-28 18:32:17 +08:00
parent 33d4bb17ad
commit 0f522d5339
8 changed files with 253 additions and 14 deletions
@@ -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))
+10 -2
View File
@@ -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 批量重试行为不变。
- 真机点击“继续采购”可能创建新的正式采购任务,必须先取得独立人工授权;永久禁止支付。
+20 -2
View File
@@ -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)
}
}
+57
View File
@@ -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}