feat(purchase): allow one in-panel spec rematch #263

This commit is contained in:
QiuSW
2026-09-11 08:45:45 +08:00
parent 1e506006d5
commit 855d052e4e
5 changed files with 52 additions and 5 deletions
@@ -69,6 +69,7 @@ data class PurchaseExecutionInput(
val mappedColor: String,
val mappedSize: String,
val specResolutionAllowed: Boolean = false,
val specRematchAllowed: Boolean = false,
val quantity: Long,
val minUnitPriceCent: Long,
val maxUnitPriceCent: Long,
@@ -95,6 +96,7 @@ class PurchaseRehearsalExecutor(
private val beforeOrderSubmit: (FinalConfirmationEvidence) -> Unit = { throw PurchaseLiveException("PURCHASE_MODE_NOT_ALLOWED", "当前执行器没有正式采购授权") },
private val probeHandoffActivity: String? = null,
private val onProbeHandoff: (String) -> Unit = {},
private val rematchSpecs: ((PurchaseExecutionInput) -> PurchaseExecutionInput?)? = null,
) {
private var purchasePanelContext: PurchasePanelContext? = null
private var pageIdentity: Pair<String?, String?> = null to null
@@ -127,7 +129,7 @@ class PurchaseRehearsalExecutor(
PurchaseActionType.OPEN_SPEC_PANEL -> {
openSpecPanel(input, action) ?: recoverSoldOut(input, closeSpecPanel = true) ?: openSpecPanel(input, action)
}
PurchaseActionType.SELECT_SPEC -> if (input.phase == "spec_probe") null else selectSpecs(input, rule, specSelectionProofs)
PurchaseActionType.SELECT_SPEC -> if (input.phase == "spec_probe") null else selectSpecsWithRematch(input, rule, specSelectionProofs)
PurchaseActionType.SET_QUANTITY -> if (input.phase == "spec_probe") null else setQuantity(input.quantity)
PurchaseActionType.VERIFY_UNIT_PRICE -> if (input.phase == "spec_probe") null else verifyPrice(input).also {
if (it == null) observedPrice = currentScreen(input).priceCent
@@ -190,6 +192,15 @@ class PurchaseRehearsalExecutor(
else failure("PURCHASE_RULE_INVALID", "正式采购规则缺少核单动作")
}
private fun selectSpecsWithRematch(input: PurchaseExecutionInput, rule: PurchaseRule, proofs: MutableMap<String, ExactSpecSelectionProof>): PurchaseExecutionOutcome? {
val first = selectSpecs(input, rule, proofs)
if (first?.errorCode != SPEC_TARGET_NOT_VISIBLE || !input.specRematchAllowed || rematchSpecs == null) return first
val refreshed = rematchSpecs.invoke(input) ?: return first
panelDiagnostic("specRematch=accepted;pageReuse=true")
proofs.clear()
return selectSpecs(refreshed.copy(specRematchAllowed = false), rule, proofs)
}
private fun canReuseCurrentProduct(input: PurchaseExecutionInput): Boolean {
if (probeHandoffActivity == null) {
val screen = currentScreen(input)
@@ -84,6 +84,7 @@ data class PurchaseAgentTask(
val mappedColor: String,
val mappedSize: String,
val specResolutionAllowed: Boolean,
val specRematchAllowed: Boolean,
val quantity: Long,
val minUnitPriceCent: Long,
val maxUnitPriceCent: Long,
@@ -453,6 +454,16 @@ class AgentApiClient(private val serverUrl: String) {
requireNotNull(request("POST", "/api/agent/v1/purchase-tasks/$taskId/result", payload, token))
}
fun rematchPurchaseTask(taskId: Long, attemptId: String, probedSpecs: String, token: String): PurchaseAgentTask {
val payload = JSONObject().put("requestId", UUID.randomUUID().toString())
.put("taskAttemptId", attemptId).put("resultType", "spec_rematch_completed")
.put("probedSpecs", JSONObject(probedSpecs))
val data = requireNotNull(request("POST", "/api/agent/v1/purchase-tasks/$taskId/result", payload, token)).getJSONObject("data")
val pending = purchaseTask(data)
val claimed = claimPurchaseTask(taskId, UUID.randomUUID().toString(), token)
return startPurchaseTask(claimed.taskId, UUID.randomUUID().toString(), token)
}
fun collectionHistory(token: String, page: Int, status: String?, taskNo: String?, days: Int = 30, pageSize: Int = 20): HistoryPage<CollectionHistoryItem> {
val data = requireNotNull(request("GET", historyPath("/api/agent/v1/collection-tasks", page, status, taskNo, days, pageSize), null, token)).getJSONObject("data")
return HistoryPage(
@@ -600,6 +611,7 @@ class AgentApiClient(private val serverUrl: String) {
mappedColor = data.optString("mappedColor"),
mappedSize = data.optString("mappedSize"),
specResolutionAllowed = data.optBoolean("specResolutionAllowed", false),
specRematchAllowed = data.optBoolean("specRematchAllowed", false),
quantity = data.getLong("quantity"),
minUnitPriceCent = data.getLong("minUnitPriceCent"),
maxUnitPriceCent = data.getLong("maxUnitPriceCent"),
@@ -464,7 +464,7 @@ class AgentForegroundService : Service() {
val claimed = if (initial.status == "pending") {
api.claimPurchaseTask(initial.taskId, UUID.randomUUID().toString(), token)
} else initial
val task = if (claimed.status == "pending") {
var task = if (claimed.status == "pending") {
api.startPurchaseTask(claimed.taskId, UUID.randomUUID().toString(), token)
} else claimed
check(task.status == "running" && task.taskAttemptId.isNotBlank()) { "采购任务没有有效 attempt" }
@@ -511,6 +511,15 @@ class AgentForegroundService : Service() {
onProbeHandoff = { activity -> purchaseProbeHandoff.remember(handoffKey, activity,
accessibility.currentForegroundRevision(), SystemClock.elapsedRealtime()) },
driver = accessibility,
rematchSpecs = { execution ->
val raw = collectPurchaseProbe(accessibility, task, parsedRule)
if (raw == null) null else {
val refreshed = api.rematchPurchaseTask(task.taskId, task.taskAttemptId, raw, token)
task = refreshed
execution.copy(mappedColor = refreshed.mappedColor, mappedSize = refreshed.mappedSize,
specRematchAllowed = refreshed.specRematchAllowed)
}
},
openLink = { PddLinkLauncher(this).open(it, preferDirect = true) },
probeSpecs = { collectPurchaseProbe(accessibility, task, parsedRule) },
stepChanged = { step ->
@@ -549,6 +558,7 @@ class AgentForegroundService : Service() {
minUnitPriceCent = task.minUnitPriceCent,
maxUnitPriceCent = task.maxUnitPriceCent,
addressSuffix = task.addressSuffix,
specRematchAllowed = task.specRematchAllowed,
),
parsedRule,
PurchaseAgentCapabilities.supported,
+15 -3
View File
@@ -342,6 +342,18 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq
now := s.Now()
next := ""
switch req.ResultType {
case "spec_rematch_completed":
if t.ExecutionMode != models.PurchaseExecutionModeLive || t.Status != models.PurchaseTaskStatusRunning || t.SpecDecisionRequestID == nil || t.IrreversibleAt != nil || len(req.ProbedSpecs) == 0 {
return TaskPayload{}, fail(CodeSpecRematchRejected, "当前任务不满足一次现场重新匹配条件")
}
var rematchCount int64
if e := tx.Model(&models.PurchaseTaskAttempt{}).Where("task_id = ? AND result_type = ?", t.ID, "spec_rematch_completed").Count(&rematchCount).Error; e != nil { return TaskPayload{}, internal(e) }
if rematchCount > 0 { return TaskPayload{}, fail(CodeSpecRematchRejected, "该任务的现场重新匹配次数已用尽") }
next = models.PurchaseTaskStatusSpecProbePending
a.Status = models.PurchaseAttemptStatusCompleted
t.SpecDecisionRequestID = nil
t.SpecDecisionBy = nil
t.MappedColorSnapshot, t.MappedSizeSnapshot, t.SpecSource = "", "", "unresolved"
case "spec_probe_completed":
if len(req.ProbedSpecs) == 0 {
return TaskPayload{}, fail(CodeInvalidRequest, "规格探测结果无效")
@@ -413,7 +425,7 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq
a.ResultHash = &digest
a.ResultType = &req.ResultType
a.FinishedAt = &now
if req.ResultType == "spec_probe_completed" {
if req.ResultType == "spec_probe_completed" || req.ResultType == "spec_rematch_completed" {
a.SpecDecisionSnapshot = string(req.ProbedSpecs)
}
if e := tx.Omit("Task").Save(a).Error; e != nil {
@@ -424,7 +436,7 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq
}
return valuePayload(s, t, a, false)
})
if err != nil || req.ResultType != "spec_probe_completed" || payload.Replayed || payload.Status != models.PurchaseTaskStatusSpecProbePending {
if err != nil || (req.ResultType != "spec_probe_completed" && req.ResultType != "spec_rematch_completed") || payload.Replayed || payload.Status != models.PurchaseTaskStatusSpecProbePending {
return payload, err
}
return s.resolveProbedSpecs(ctx, taskID, req.TaskAttemptID, req.ProbedSpecs)
@@ -700,7 +712,7 @@ func ensureAccountFree(tx *gorm.DB, accountID *uint64, taskID uint64, now time.T
return nil
}
func (s *Service) payload(t models.PurchaseTask, a *models.PurchaseTaskAttempt, replayed bool) (*TaskPayload, error) {
p := &TaskPayload{TaskID: t.ID, ExecutionMode: t.ExecutionMode, Status: t.Status, DeviceID: t.DeviceID, PDDProductID: t.PDDProductID, PDDURL: t.PDDURLSnapshot, PDDGoodsID: t.PDDGoodsIDSnapshot, TargetColor: t.TargetColorSnapshot, TargetSize: t.TargetSizeSnapshot, MappedColor: t.MappedColorSnapshot, MappedSize: t.MappedSizeSnapshot, SpecResolutionAllowed: specResolutionAllowed(t), Quantity: t.Quantity, MinUnitPriceCent: t.MinUnitPriceCent, MaxUnitPriceCent: t.MaxUnitPriceCent, Currency: t.Currency, AddressSuffix: t.AddressSuffix, RuleSnapshot: json.RawMessage(t.RuleSnapshot), LeaseExpiresAt: t.LeaseExpiresAt, LeaseVersion: t.LeaseVersion, Replayed: replayed}
p := &TaskPayload{TaskID: t.ID, ExecutionMode: t.ExecutionMode, Status: t.Status, DeviceID: t.DeviceID, PDDProductID: t.PDDProductID, PDDURL: t.PDDURLSnapshot, PDDGoodsID: t.PDDGoodsIDSnapshot, TargetColor: t.TargetColorSnapshot, TargetSize: t.TargetSizeSnapshot, MappedColor: t.MappedColorSnapshot, MappedSize: t.MappedSizeSnapshot, SpecResolutionAllowed: specResolutionAllowed(t), SpecRematchAllowed: t.SpecDecisionRequestID != nil && t.TaskType == models.PurchaseTaskTypeSYBOrder && t.Status == models.PurchaseTaskStatusRunning, Quantity: t.Quantity, MinUnitPriceCent: t.MinUnitPriceCent, MaxUnitPriceCent: t.MaxUnitPriceCent, Currency: t.Currency, AddressSuffix: t.AddressSuffix, RuleSnapshot: json.RawMessage(t.RuleSnapshot), LeaseExpiresAt: t.LeaseExpiresAt, LeaseVersion: t.LeaseVersion, Replayed: replayed}
if a != nil {
p.TaskAttemptID = a.AttemptID
p.AttemptNumber = a.AttemptNumber
+2
View File
@@ -21,6 +21,7 @@ const (
CodeRetryUnsafe = "PURCHASE_RETRY_UNSAFE"
CodeRetryStale = "PURCHASE_RETRY_STALE"
CodeSpecReprobeRejected = "PURCHASE_SPEC_REPROBE_REJECTED"
CodeSpecRematchRejected = "PURCHASE_SPEC_REMATCH_REJECTED"
CodeOrderResultUnknown = "PURCHASE_ORDER_RESULT_UNKNOWN"
CodeOrderEmptyTimeout = "PURCHASE_ORDER_EMPTY_TIMEOUT"
CodeOrderChooserBack = "PURCHASE_ORDER_CHOOSER_BACK_FAILED"
@@ -137,6 +138,7 @@ type TaskPayload struct {
MappedColor string `json:"mappedColor"`
MappedSize string `json:"mappedSize"`
SpecResolutionAllowed bool `json:"specResolutionAllowed"`
SpecRematchAllowed bool `json:"specRematchAllowed"`
Quantity int64 `json:"quantity"`
MinUnitPriceCent int64 `json:"minUnitPriceCent"`
MaxUnitPriceCent int64 `json:"maxUnitPriceCent"`