diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/OrderBackfillPolicy.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/OrderBackfillPolicy.kt index 61824e8..123b116 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/OrderBackfillPolicy.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/OrderBackfillPolicy.kt @@ -1,6 +1,7 @@ package cn.ilapage.goauto.agent.automation import java.math.BigInteger +import java.math.BigDecimal import java.text.SimpleDateFormat import java.util.Locale import java.util.TimeZone @@ -43,7 +44,12 @@ class OrderBackfillWindow(days: String, val confirmedAt: Long) { } } -data class BackfillItem(val addressSuffix: String, val pddOrderNo: String, val orderSubmittedAt: String?) +data class BackfillItem( + val addressSuffix: String, + val pddOrderNo: String, + val orderSubmittedAt: String?, + val pddOrderAmountCent: Long? = null, +) data class BackfillDetail(val item: BackfillItem?, val timeMillis: Long?) /** Per-detail accumulator. Never retains raw text, addresses, names or phone numbers. */ @@ -51,11 +57,16 @@ class BackfillDetailReader(private val zone: TimeZone = TimeZone.getDefault()) { private val suffixes = mutableSetOf() private val orders = mutableSetOf() private val times = mutableSetOf() + private val amounts = mutableSetOf() + private var invalidAmount = false fun accept(text: String) { SUFFIX.findAll(text).forEach { suffixes += it.value } ORDER_NO.findAll(text).forEach { orders += it.groupValues[1] } ORDER_TIME.findAll(text).forEach { match -> parseTime(match.groupValues[1])?.let(times::add) } + // Treat each accessibility node label independently: a label and an + // unrelated price in the next node must never be joined into evidence. + text.lineSequence().forEach(::acceptAmountLabel) } fun finish(): BackfillDetail { @@ -63,14 +74,34 @@ class BackfillDetailReader(private val zone: TimeZone = TimeZone.getDefault()) { val item = if (suffixes.size == 1 && orders.size == 1 && times.size <= 1) { BackfillItem(suffixes.single(), orders.single(), time?.let { SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.ROOT).apply { timeZone = zone }.format(it) - }) + }, amounts.singleOrNull()?.takeUnless { invalidAmount }) } else null suffixes.clear() orders.clear() times.clear() + amounts.clear() + invalidAmount = false return BackfillDetail(item, time) } + private fun acceptAmountLabel(label: String) { + var start = label.indexOf("实付") + while (start >= 0) { + val ignored = label.substring(0, start).trimEnd().let { it.endsWith("未") || it.endsWith("预计") } + if (!ignored) { + val candidate = label.substring(start) + val match = PAID_AMOUNT.find(candidate) + if (match == null || match.range.first != 0) invalidAmount = true + else try { + amounts += BigDecimal(match.groupValues[1]).movePointRight(2).toBigIntegerExact().longValueExact() + } catch (_: ArithmeticException) { + invalidAmount = true + } + } + start = label.indexOf("实付", start + 2) + } + } + private fun parseTime(raw: String): Long? { val normalized = raw.replace('年', '-').replace('月', '-').replace("日", "").replace('/', '-').replace('.', '-') val pattern = if (normalized.count { it == ':' } == 2) "yyyy-M-d H:mm:ss" else "yyyy-M-d H:mm" @@ -82,6 +113,7 @@ class BackfillDetailReader(private val zone: TimeZone = TimeZone.getDefault()) { companion object { val ORDER_NO = Regex("(?:订单编号|订单号)\\s*[::]?\\s*([A-Za-z0-9-]{6,64})") val ORDER_TIME = Regex("(?:下单时间|创建时间)\\s*[::]?\\s*(20\\d{2}[-/.年]\\d{1,2}[-/.月]\\d{1,2}日?\\s+\\d{1,2}:\\d{2}(?::\\d{2})?)") + private val PAID_AMOUNT = Regex("实付[ \\t::,,]*(\\d+(?:\\.\\d{1,2})?)[ \\t]*元(?![0-9.])") private val SUFFIX = Regex("_cg[1-9][0-9]*(?![0-9A-Za-z_0-9])") } } diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/network/OrderBackfillUpload.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/network/OrderBackfillUpload.kt index c755c35..6636c13 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/network/OrderBackfillUpload.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/network/OrderBackfillUpload.kt @@ -10,6 +10,7 @@ data class BackfillResult( val index: Int, val taskId: Long, val result: String, val code: String, val status: String, val statusVersion: Long, val pddOrderNo: String?, val orderSubmittedAt: String?, val timeSource: String, + val pddOrderAmountCent: Long? = null, val warningCode: String? = null, val warningMessage: String? = null, ) { val success get() = result in setOf("backfilled", "already_backfilled") val needsReview get() = !success && code != "INTERNAL_ERROR" && code != "NETWORK_ERROR" @@ -19,7 +20,8 @@ data class BackfillResult( "irreversible_at" -> "估算时间(提交订单时刻)" else -> "已有时间(来源未知)" } - "CG-$taskId:${if (result == "already_backfilled") "已回填" else "成功"};$source ${orderSubmittedAt.orEmpty()}" + "CG-$taskId:${if (result == "already_backfilled") "已回填" else "成功"};$source ${orderSubmittedAt.orEmpty()}" + + (warningCode?.let { ";需人工检查($it${warningMessage?.let { message -> ":$message" }.orEmpty()})" } ?: "") } else "${if (taskId > 0) "CG-$taskId" else "条目 ${index + 1}"}:${if (needsReview) "需人工检查" else "重试耗尽,未确认"}($code)" } @@ -28,6 +30,7 @@ internal fun backfillPayload(requestId: String, items: List): JSON return JSONObject().put("requestId", requestId).put("items", JSONArray().apply { items.forEach { item -> put(JSONObject().put("addressSuffix", item.addressSuffix).put("pddOrderNo", item.pddOrderNo).apply { item.orderSubmittedAt?.let { put("orderSubmittedAt", it) } + item.pddOrderAmountCent?.let { put("pddOrderAmountCent", it) } }) } }) } @@ -38,7 +41,10 @@ internal fun parseBackfillResults(data: JSONObject): List { BackfillResult(it.getInt("index"), it.optLong("taskId"), it.getString("result"), it.getString("code"), it.optString("status"), it.optLong("statusVersion"), if (it.isNull("pddOrderNo")) null else it.optString("pddOrderNo"), - if (it.isNull("orderSubmittedAt")) null else it.optString("orderSubmittedAt"), it.optString("timeSource")) + if (it.isNull("orderSubmittedAt")) null else it.optString("orderSubmittedAt"), it.optString("timeSource"), + if (it.isNull("pddOrderAmountCent")) null else it.optLong("pddOrderAmountCent"), + if (it.isNull("warningCode")) null else it.optString("warningCode"), + if (it.isNull("warningMessage")) null else it.optString("warningMessage")) } } } diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/TaskHistoryCache.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/TaskHistoryCache.kt index f2b9604..ecf78eb 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/TaskHistoryCache.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/TaskHistoryCache.kt @@ -26,6 +26,8 @@ class TaskHistoryCache(context: Context) { val confirmed = JSONObject().put("taskId", result.taskId).put("status", result.status) .put("statusVersion", result.statusVersion).putNullable("pddOrderNo", result.pddOrderNo) .putNullable("orderSubmittedAt", result.orderSubmittedAt).put("timeSource", result.timeSource) + .putNullable("pddOrderAmountCent", result.pddOrderAmountCent) + .putNullable("warningCode", result.warningCode).putNullable("warningMessage", result.warningMessage) .put("environment", environment) check(preferences.edit().putString(PURCHASE, values.toString()) .putString("backfill_confirmed_${result.taskId}", confirmed.toString()).commit()) @@ -128,6 +130,9 @@ internal fun applyBackfillFacts(task: JSONObject, result: cn.ilapage.goauto.agen if (!result.success || task.optLong("taskId") != result.taskId) return task.put("status", result.status).put("pddOrderNo", result.pddOrderNo ?: JSONObject.NULL) .put("orderSubmittedAt", result.orderSubmittedAt ?: JSONObject.NULL) + .put("pddOrderAmountCent", result.pddOrderAmountCent ?: JSONObject.NULL) + .put("backfillWarningCode", result.warningCode ?: JSONObject.NULL) + .put("backfillWarningMessage", result.warningMessage ?: JSONObject.NULL) .put("errorCode", JSONObject.NULL).put("errorMessage", JSONObject.NULL) .put("retryable", false).put("retryDisabledReason", JSONObject.NULL) } diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/OrderBackfillTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/OrderBackfillTest.kt index c138662..e0d2e22 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/OrderBackfillTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/OrderBackfillTest.kt @@ -27,6 +27,40 @@ class OrderBackfillTest { assertNull(reader.finish().item) } + @Test fun `paid amount accepts exact label formats and ignores unrelated prices`() { + for ((label, cents) in listOf("实付:,22.98元,(免运费)" to 2298L, "实付: 22元" to 2200L, "实付,22.9 元" to 2290L)) { + val detail = BackfillDetailReader(zone).apply { accept("_cg7\n订单号:TEST-000007\n¥999.00\n$label") }.finish() + assertEquals(cents, detail.item!!.pddOrderAmountCent) + } + } + + @Test fun `paid amount ambiguity or malformed candidate omits only amount`() { + for (labels in listOf( + "实付:22.98元\n实付:23.00元", + "实付:22.98元\n实付:坏值元", + "实付:22.345元", + "实付:\n22.98元", + "未实付:99.00元\n预计实付:88.00元", + )) { + val detail = BackfillDetailReader(zone).apply { accept("_cg7\n订单号:TEST-000007\n下单时间:2026-09-08 10:00:00\n$labels") }.finish() + assertNotNull(detail.item) + assertNull(labels, detail.item!!.pddOrderAmountCent) + assertEquals("TEST-000007", detail.item!!.pddOrderNo) + } + val duplicate = BackfillDetailReader(zone).apply { + accept("_cg7\n订单号:TEST-000007\n实付:22.98元") + accept("实付:22.98元") + }.finish() + assertEquals(2298L, duplicate.item!!.pddOrderAmountCent) + val reused = BackfillDetailReader(zone) + reused.accept("_cg7\n订单号:TEST-000007\n实付:1.00元") + assertEquals(100L, reused.finish().item!!.pddOrderAmountCent) + reused.accept("_cg8\n订单号:TEST-000008\n实付:0元") + assertEquals(0L, reused.finish().item!!.pddOrderAmountCent) + reused.accept("_cg9\n订单号:TEST-000009\n实付:999999999999999999999999.99元") + assertNull(reused.finish().item!!.pddOrderAmountCent) + } + @Test fun `untagged order never becomes retained or uploaded candidate`() { val reader = BackfillDetailReader(zone) reader.accept("个人订单\n订单编号:PERSONAL-1\n下单时间:2026-09-08 10:00:00") @@ -178,7 +212,15 @@ class OrderBackfillTest { assertEquals(setOf("addressSuffix", "pddOrderNo"), payload.getJSONArray("items").getJSONObject(0).keySet()) val parsed = parseBackfillResults(JSONObject("""{"items":[{"index":0,"taskId":7,"result":"backfilled","code":"BACKFILLED","status":"order_created","statusVersion":3,"pddOrderNo":"TEST-000007","orderSubmittedAt":"2026-09-08T10:00:00+08:00","timeSource":"irreversible_at","retryable":false}]}""")) assertTrue(parsed.single().display().contains("估算")) + assertNull(parsed.single().pddOrderAmountCent) + assertNull(parsed.single().warningCode) assertTrue(parsed.single().copy(timeSource = "page").display().contains("页面下单时间")) + val warning = parseBackfillResults(JSONObject("""{"items":[{"index":0,"taskId":7,"result":"already_backfilled","code":"ALREADY_BACKFILLED","status":"order_created","statusVersion":4,"pddOrderNo":"TEST-000007","pddOrderAmountCent":2298,"timeSource":"page","warningCode":"PURCHASE_BACKFILL_AMOUNT_CONFLICT","warningMessage":"已有不同实付金额,本次未覆盖","retryable":false}]}""")).single() + assertEquals(2298L, warning.pddOrderAmountCent) + assertTrue(warning.display().contains("PURCHASE_BACKFILL_AMOUNT_CONFLICT")) + + val amountPayload = backfillPayload("test", listOf(item.copy(pddOrderAmountCent = 2298))) + assertEquals(2298L, amountPayload.getJSONArray("items").getJSONObject(0).getLong("pddOrderAmountCent")) } @Test fun `cache updates confirmed server facts only and clears old errors`() { @@ -192,6 +234,11 @@ class OrderBackfillTest { assertTrue(task.isNull("errorCode")) assertTrue(task.isNull("errorMessage")) assertFalse(task.getBoolean("retryable")) + val warning = success().copy(pddOrderAmountCent = 2298, warningCode = "PURCHASE_BACKFILL_AMOUNT_CONFLICT", warningMessage = "conflict") + cn.ilapage.goauto.agent.persistence.applyBackfillFacts(task, warning) + assertEquals(2298L, task.getLong("pddOrderAmountCent")) + assertEquals("PURCHASE_BACKFILL_AMOUNT_CONFLICT", task.getString("backfillWarningCode")) + assertTrue(task.isNull("errorCode")) } @Test fun `expansion requires order information and product snapshot row`() { diff --git a/server/app/goauto/models/purchase.go b/server/app/goauto/models/purchase.go index 0289348..9a57f4a 100644 --- a/server/app/goauto/models/purchase.go +++ b/server/app/goauto/models/purchase.go @@ -138,9 +138,10 @@ type PurchaseTask struct { StatusVersion uint64 `json:"statusVersion" gorm:"not null;default:1"` StatusChangedAt time.Time `json:"statusChangedAt" gorm:"not null"` - PDDOrderNo *string `json:"pddOrderNo" gorm:"size:100;index"` - OrderSubmittedAt *time.Time `json:"orderSubmittedAt"` - IrreversibleAt *time.Time `json:"irreversibleAt"` + PDDOrderNo *string `json:"pddOrderNo" gorm:"size:100;index"` + OrderSubmittedAt *time.Time `json:"orderSubmittedAt"` + PDDOrderAmountCent *int64 `json:"pddOrderAmountCent,omitempty" gorm:"check:ck_purchase_task_order_amount,pdd_order_amount_cent IS NULL OR pdd_order_amount_cent >= 0"` + IrreversibleAt *time.Time `json:"irreversibleAt"` // PaymentPageObservedAt 仅在 order_result_unknown 时可能有值:采样期间见过 // 待付款/支付相关文案,说明大概率已经在 PDD 建了订单,只是没能读全证据。 // 用于给积压的 order_result_unknown 记录分优先级,不改变判定结果本身(#302)。 diff --git a/server/app/goauto/purchase/admin_query.go b/server/app/goauto/purchase/admin_query.go index d24dac3..ccfb0f0 100644 --- a/server/app/goauto/purchase/admin_query.go +++ b/server/app/goauto/purchase/admin_query.go @@ -54,6 +54,7 @@ type AdminTaskItem struct { AddressSuffix string `json:"addressSuffix"` PDDOrderNo *string `json:"pddOrderNo,omitempty"` OrderSubmittedAt *time.Time `json:"orderSubmittedAt,omitempty"` + PDDOrderAmountCent *int64 `json:"pddOrderAmountCent,omitempty"` IrreversibleAt *time.Time `json:"irreversibleAt,omitempty"` PaymentReviewStatus string `json:"paymentReviewStatus"` PaymentReviewedAt *time.Time `json:"paymentReviewedAt,omitempty"` @@ -282,7 +283,7 @@ func adminTaskItem(task models.PurchaseTask, deviceNames map[uint64]string, retr SpecSource: task.SpecSource, Quantity: task.Quantity, ReferenceUnitPriceCent: task.ReferenceUnitPriceCent, MinUnitPriceCent: task.MinUnitPriceCent, MaxUnitPriceCent: task.MaxUnitPriceCent, Currency: task.Currency, PDDAccountRefSnapshot: task.PDDAccountRefSnapshot, AddressSuffix: task.AddressSuffix, - PDDOrderNo: task.PDDOrderNo, OrderSubmittedAt: task.OrderSubmittedAt, IrreversibleAt: task.IrreversibleAt, + PDDOrderNo: task.PDDOrderNo, OrderSubmittedAt: task.OrderSubmittedAt, PDDOrderAmountCent: task.PDDOrderAmountCent, IrreversibleAt: task.IrreversibleAt, PaymentReviewStatus: task.PaymentReviewStatus, PaymentReviewedAt: task.PaymentReviewedAt, TrackingNo: task.TrackingNo, TrackingCollectedAt: task.TrackingCollectedAt, LogisticsStatus: task.LogisticsStatus, WritebackStatus: task.WritebackStatus, WritebackAt: task.WritebackAt, diff --git a/server/app/goauto/purchase/order_backfill.go b/server/app/goauto/purchase/order_backfill.go index fa4072d..ad3e14d 100644 --- a/server/app/goauto/purchase/order_backfill.go +++ b/server/app/goauto/purchase/order_backfill.go @@ -18,12 +18,13 @@ import ( ) const ( - MaxOrderBackfillItems = 50 - CodeBackfillSuffix = "PURCHASE_BACKFILL_SUFFIX_INVALID" - CodeBackfillDevice = "PURCHASE_BACKFILL_DEVICE_MISMATCH" - CodeBackfillOrderConflict = "PURCHASE_BACKFILL_ORDER_CONFLICT" - CodeBackfillBatchConflict = "PURCHASE_BACKFILL_BATCH_CONFLICT" - CodeBackfillOrderUsed = "PURCHASE_BACKFILL_ORDER_ALREADY_USED" + MaxOrderBackfillItems = 50 + CodeBackfillSuffix = "PURCHASE_BACKFILL_SUFFIX_INVALID" + CodeBackfillDevice = "PURCHASE_BACKFILL_DEVICE_MISMATCH" + CodeBackfillOrderConflict = "PURCHASE_BACKFILL_ORDER_CONFLICT" + CodeBackfillBatchConflict = "PURCHASE_BACKFILL_BATCH_CONFLICT" + CodeBackfillOrderUsed = "PURCHASE_BACKFILL_ORDER_ALREADY_USED" + CodeBackfillAmountConflict = "PURCHASE_BACKFILL_AMOUNT_CONFLICT" ) type OrderBackfillRequest struct { @@ -35,20 +36,24 @@ type OrderBackfillItem struct { AddressSuffix string `json:"addressSuffix"` PDDOrderNo string `json:"pddOrderNo"` // A string keeps an invalid page timestamp local to this item. - OrderSubmittedAt *string `json:"orderSubmittedAt,omitempty"` + OrderSubmittedAt *string `json:"orderSubmittedAt,omitempty"` + PDDOrderAmountCent *int64 `json:"pddOrderAmountCent,omitempty"` } type OrderBackfillResult struct { - Index int `json:"index"` - TaskID uint64 `json:"taskId,omitempty"` - Result string `json:"result"` - Code string `json:"code"` - Status string `json:"status,omitempty"` - StatusVersion uint64 `json:"statusVersion,omitempty"` - PDDOrderNo *string `json:"pddOrderNo,omitempty"` - OrderSubmittedAt *time.Time `json:"orderSubmittedAt,omitempty"` - TimeSource string `json:"timeSource,omitempty"` - Retryable bool `json:"retryable"` + Index int `json:"index"` + TaskID uint64 `json:"taskId,omitempty"` + Result string `json:"result"` + Code string `json:"code"` + Status string `json:"status,omitempty"` + StatusVersion uint64 `json:"statusVersion,omitempty"` + PDDOrderNo *string `json:"pddOrderNo,omitempty"` + OrderSubmittedAt *time.Time `json:"orderSubmittedAt,omitempty"` + TimeSource string `json:"timeSource,omitempty"` + PDDOrderAmountCent *int64 `json:"pddOrderAmountCent,omitempty"` + WarningCode string `json:"warningCode,omitempty"` + WarningMessage string `json:"warningMessage,omitempty"` + Retryable bool `json:"retryable"` } type OrderBackfillResponse struct { @@ -121,10 +126,25 @@ func (s *Service) backfillOrder(ctx context.Context, deviceID, taskID uint64, re if err := models.CheckPurchaseOrderNumber(tx, taskID, item.PDDOrderNo); err != nil { return err } + if item.PDDOrderAmountCent != nil && *item.PDDOrderAmountCent < 0 { + item.PDDOrderAmountCent = nil + } if task.Status == models.PurchaseTaskStatusOrderCreated { if task.PDDOrderNo == nil || *task.PDDOrderNo != item.PDDOrderNo { return fail(CodeStateConflict, "已创建订单缺少匹配订单号") } + if item.PDDOrderAmountCent != nil { + if task.PDDOrderAmountCent == nil { + task.PDDOrderAmountCent = item.PDDOrderAmountCent + task.StatusVersion++ + task.StatusChangedAt = s.Now() + if err := tx.Save(&task).Error; err != nil { + return err + } + } else if *task.PDDOrderAmountCent != *item.PDDOrderAmountCent { + r.WarningCode, r.WarningMessage = CodeBackfillAmountConflict, "已有不同实付金额,本次未覆盖" + } + } r.Result, r.Code = "already_backfilled", "ALREADY_BACKFILLED" return nil } @@ -144,6 +164,13 @@ func (s *Service) backfillOrder(ctx context.Context, deviceID, taskID uint64, re } submitted = submitted.UTC() task.PDDOrderNo, task.OrderSubmittedAt = &item.PDDOrderNo, &submitted + if item.PDDOrderAmountCent != nil { + if task.PDDOrderAmountCent == nil { + task.PDDOrderAmountCent = item.PDDOrderAmountCent + } else if *task.PDDOrderAmountCent != *item.PDDOrderAmountCent { + r.WarningCode, r.WarningMessage = CodeBackfillAmountConflict, "已有不同实付金额,本次未覆盖" + } + } if err := task.SetStatus(models.PurchaseTaskStatusOrderCreated); err != nil { return internal(err) } @@ -185,7 +212,7 @@ func (s *Service) backfillOrder(ctx context.Context, deviceID, taskID uint64, re } if readable { r.Status, r.StatusVersion = saved.Status, saved.StatusVersion - r.PDDOrderNo, r.OrderSubmittedAt = saved.PDDOrderNo, saved.OrderSubmittedAt + r.PDDOrderNo, r.OrderSubmittedAt, r.PDDOrderAmountCent = saved.PDDOrderNo, saved.OrderSubmittedAt, saved.PDDOrderAmountCent if saved.OrderSubmittedAt != nil { r.TimeSource = "existing_unknown" if saved.UnknownResolveRequestID != nil { diff --git a/server/app/goauto/purchase/order_backfill_test.go b/server/app/goauto/purchase/order_backfill_test.go index 0f4f8ec..016d62f 100644 --- a/server/app/goauto/purchase/order_backfill_test.go +++ b/server/app/goauto/purchase/order_backfill_test.go @@ -115,6 +115,78 @@ func TestOrderBackfillMixedBatchAndReplay(t *testing.T) { } } +func TestOrderBackfillAmountFillIdempotencyAndConflictWarning(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), true) + s := testService(db) + task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown) + amount := int64(2298) + item := backfillItem(task.ID, "AMOUNT-ORDER") + item.PDDOrderAmountCent = &amount + first := runBackfill(t, s, f.token, uuid.NewString(), item)[0] + if first.Code != "BACKFILLED" || first.PDDOrderAmountCent == nil || *first.PDDOrderAmountCent != amount { + t.Fatalf("first: %+v", first) + } + saved := loadBackfillTask(t, db, task.ID) + version := saved.StatusVersion + + same := runBackfill(t, s, f.token, uuid.NewString(), item)[0] + if same.Result != "already_backfilled" || same.WarningCode != "" { + t.Fatalf("same: %+v", same) + } + if got := loadBackfillTask(t, db, task.ID); got.StatusVersion != version { + t.Fatal("same amount changed version") + } + + different := int64(2300) + item.PDDOrderAmountCent = &different + conflict := runBackfill(t, s, f.token, uuid.NewString(), item)[0] + if conflict.Result != "already_backfilled" || conflict.WarningCode != CodeBackfillAmountConflict || conflict.PDDOrderAmountCent == nil || *conflict.PDDOrderAmountCent != amount { + t.Fatalf("conflict: %+v", conflict) + } + got := loadBackfillTask(t, db, task.ID) + if got.StatusVersion != version || got.PDDOrderAmountCent == nil || *got.PDDOrderAmountCent != amount || got.PDDOrderNo == nil || *got.PDDOrderNo != "AMOUNT-ORDER" { + t.Fatalf("conflict changed facts: %+v", got) + } +} + +func TestOrderBackfillAddsMissingAmountToExistingOrder(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), true) + s := testService(db) + task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown) + base := backfillItem(task.ID, "LATE-AMOUNT") + runBackfill(t, s, f.token, uuid.NewString(), base) + before := loadBackfillTask(t, db, task.ID) + amount := int64(100) + base.PDDOrderAmountCent = &amount + result := runBackfill(t, s, f.token, uuid.NewString(), base)[0] + if result.Result != "already_backfilled" || result.WarningCode != "" || result.PDDOrderAmountCent == nil || *result.PDDOrderAmountCent != amount { + t.Fatalf("late fill: %+v", result) + } + after := loadBackfillTask(t, db, task.ID) + if after.StatusVersion != before.StatusVersion+1 || after.PDDOrderAmountCent == nil || *after.PDDOrderAmountCent != amount || !after.OrderSubmittedAt.Equal(*before.OrderSubmittedAt) { + t.Fatalf("late fill changed wrong facts: %+v", after) + } +} + +func TestOrderBackfillIgnoresNegativeOptionalAmount(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), true) + task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown) + negative := int64(-1) + item := backfillItem(task.ID, "NEGATIVE-AMOUNT") + item.PDDOrderAmountCent = &negative + result := runBackfill(t, testService(db), f.token, uuid.NewString(), item)[0] + if result.Code != "BACKFILLED" || result.PDDOrderAmountCent != nil { + t.Fatalf("negative amount affected order backfill: %+v", result) + } + got := loadBackfillTask(t, db, task.ID) + if got.PDDOrderNo == nil || *got.PDDOrderNo != "NEGATIVE-AMOUNT" || got.PDDOrderAmountCent != nil { + t.Fatalf("unexpected persisted facts: %+v", got) + } +} + func TestOrderBackfillRejectsOwnershipStatesAndInvalidTime(t *testing.T) { db := testDB(t) f := seed(t, db, liveCaps(), true) diff --git a/server/cmd/migrate/migration/version-local/1789800100000_purchase_order_amount.go b/server/cmd/migrate/migration/version-local/1789800100000_purchase_order_amount.go new file mode 100644 index 0000000..d28c33a --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1789800100000_purchase_order_amount.go @@ -0,0 +1,33 @@ +package version_local + +import ( + "runtime" + + "go-admin/cmd/migrate/migration" + common "go-admin/common/models" + + "gorm.io/gorm" +) + +type purchaseOrderAmountMigration struct { + ID uint64 `gorm:"primaryKey"` + PDDOrderAmountCent *int64 `gorm:"check:ck_purchase_task_order_amount,pdd_order_amount_cent IS NULL OR pdd_order_amount_cent >= 0"` +} + +func (purchaseOrderAmountMigration) TableName() string { return "purchase_task" } + +// #306 adds the nullable PDD order total observed from the explicit 实付 label. +// Existing tasks remain NULL; no amount is inferred from quantity or unit price. +func init() { + _, fileName, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(fileName), migratePurchaseOrderAmount) +} + +func migratePurchaseOrderAmount(db *gorm.DB, version string) error { + return db.Transaction(func(tx *gorm.DB) error { + if err := tx.AutoMigrate(&purchaseOrderAmountMigration{}); err != nil { + return err + } + return tx.Create(&common.Migration{Version: version}).Error + }) +} diff --git a/server/cmd/migrate/migration/version-local/1789800100000_purchase_order_amount_test.go b/server/cmd/migrate/migration/version-local/1789800100000_purchase_order_amount_test.go new file mode 100644 index 0000000..249d7a1 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1789800100000_purchase_order_amount_test.go @@ -0,0 +1,57 @@ +package version_local + +import ( + "testing" + + common "go-admin/common/models" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestMigratePurchaseOrderAmountAddsNullableColumnAndRecordsVersion(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:purchase-order-amount?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&common.Migration{}); err != nil { + t.Fatal(err) + } + if err := db.Exec("CREATE TABLE purchase_task (id integer primary key, status text not null)").Error; err != nil { + t.Fatal(err) + } + if err := db.Exec("INSERT INTO purchase_task (id, status) VALUES (1, 'order_created')").Error; err != nil { + t.Fatal(err) + } + if err := migratePurchaseOrderAmount(db, "1789800100000"); err != nil { + t.Fatal(err) + } + if !db.Migrator().HasColumn(&purchaseOrderAmountMigration{}, "PDDOrderAmountCent") { + t.Fatal("pdd_order_amount_cent was not added") + } + var count int64 + if err := db.Model(&common.Migration{}).Where("version = ?", "1789800100000").Count(&count).Error; err != nil || count != 1 { + t.Fatalf("migration row count=%d err=%v", count, err) + } + var nullCount int64 + if err := db.Table("purchase_task").Where("pdd_order_amount_cent IS NOT NULL").Count(&nullCount).Error; err != nil || nullCount != 0 { + t.Fatalf("existing rows should remain null: count=%d err=%v", nullCount, err) + } + if err := db.Exec("UPDATE purchase_task SET pdd_order_amount_cent = 2298 WHERE id = 1").Error; err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&purchaseOrderAmountMigration{}); err != nil { + t.Fatal(err) + } + var status string + var amount int64 + if err := db.Table("purchase_task").Select("status, pdd_order_amount_cent").Where("id = 1").Row().Scan(&status, &amount); err != nil { + t.Fatal(err) + } + if status != "order_created" || amount != 2298 { + t.Fatalf("repeat schema migration changed business facts: status=%s amount=%d", status, amount) + } + if err := db.Exec("UPDATE purchase_task SET pdd_order_amount_cent = -1 WHERE id = 1").Error; err == nil { + t.Fatal("negative amount must violate database constraint") + } +} diff --git a/web/src/views/goauto/purchase-tasks/index.vue b/web/src/views/goauto/purchase-tasks/index.vue index cfefe28..5b0dd6c 100644 --- a/web/src/views/goauto/purchase-tasks/index.vue +++ b/web/src/views/goauto/purchase-tasks/index.vue @@ -56,7 +56,7 @@
重新尝试 AI人工选择规格

订单与人工处理

-
订单{{ detail.task.pddOrderNo || '尚未取得订单号' }}{{ detail.task.orderSubmittedAt ? `下单时间 ${formatTime(detail.task.orderSubmittedAt)}` : '获取订单号失败即采购失败' }}
+
订单{{ detail.task.pddOrderNo || '尚未取得订单号' }}{{ detail.task.orderSubmittedAt ? `下单时间 ${formatTime(detail.task.orderSubmittedAt)}` : '获取订单号失败即采购失败' }}实付价格:{{ detail.task.pddOrderAmountCent == null ? '尚未获取' : priceText(detail.task.pddOrderAmountCent, 'CNY') }}
支付复核{{ paymentLabel(detail.task.paymentReviewStatus) }}{{ detail.task.paymentReviewedAt ? formatTime(detail.task.paymentReviewedAt) : '仅人工记录,系统不支付' }}
物流{{ logisticsLabel(detail.task.logisticsStatus) }}{{ detail.task.trackingNo || '尚未取得快递单号' }}
SYB 回填{{ writebackLabel(detail.task.writebackStatus) }}{{ detail.task.writebackAt ? formatTime(detail.task.writebackAt) : '由独立回填流程处理' }}