feat(purchase): allow one in-panel spec rematch #263
This commit is contained in:
+12
-1
@@ -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"),
|
||||
|
||||
+11
-1
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user