feat: capture optional PDD order amount in manual backfill (#306)

This commit is contained in:
QiuSW
2026-09-18 09:22:12 +08:00
parent 9194fd664f
commit 8c01329f97
11 changed files with 308 additions and 27 deletions
@@ -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<String>()
private val orders = mutableSetOf<String>()
private val times = mutableSetOf<Long>()
private val amounts = mutableSetOf<Long>()
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])")
}
}
@@ -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<BackfillItem>): 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> {
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"))
} }
}
@@ -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)
}
@@ -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`() {
+4 -3
View File
@@ -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)。
+2 -1
View File
@@ -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,
+45 -18
View File
@@ -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 {
@@ -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)
@@ -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
})
}
@@ -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")
}
}
@@ -56,7 +56,7 @@
<div v-if="['manual_required','cancelled'].includes(detail.matching.status)" class="matching-actions"><el-button :loading="matchingAction.saving" @click="requeueMatching">重新尝试 AI</el-button><el-button v-if="detail.matching.status === 'manual_required'" type="primary" @click="openMatchingDialog">人工选择规格</el-button></div>
</section>
<section><h3>订单与人工处理</h3><div class="state-grid">
<div class="state-card"><span>订单</span><strong>{{ detail.task.pddOrderNo || '尚未取得订单号' }}</strong><small>{{ detail.task.orderSubmittedAt ? `下单时间 ${formatTime(detail.task.orderSubmittedAt)}` : '获取订单号失败即采购失败' }}</small></div>
<div class="state-card"><span>订单</span><strong>{{ detail.task.pddOrderNo || '尚未取得订单号' }}</strong><small>{{ detail.task.orderSubmittedAt ? `下单时间 ${formatTime(detail.task.orderSubmittedAt)}` : '获取订单号失败即采购失败' }}</small><small>实付价格:{{ detail.task.pddOrderAmountCent == null ? '尚未获取' : priceText(detail.task.pddOrderAmountCent, 'CNY') }}</small></div>
<div class="state-card"><span>支付复核</span><strong>{{ paymentLabel(detail.task.paymentReviewStatus) }}</strong><small>{{ detail.task.paymentReviewedAt ? formatTime(detail.task.paymentReviewedAt) : '仅人工记录,系统不支付' }}</small></div>
<div class="state-card"><span>物流</span><strong>{{ logisticsLabel(detail.task.logisticsStatus) }}</strong><small>{{ detail.task.trackingNo || '尚未取得快递单号' }}</small></div>
<div v-if="detail.task.taskType !== 'stock'" class="state-card"><span>SYB 回填</span><strong>{{ writebackLabel(detail.task.writebackStatus) }}</strong><small>{{ detail.task.writebackAt ? formatTime(detail.task.writebackAt) : '由独立回填流程处理' }}</small></div>