Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f62f071e63 | ||
|
|
ccd650f2e3 | ||
|
|
5282d65a45 | ||
|
|
0dfe8406c4 | ||
|
|
a3c05a751b |
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "cn.ilapage.goauto.agent"
|
||||
minSdk = 23
|
||||
targetSdk = 34
|
||||
versionCode = 85
|
||||
versionName = "0.9.72"
|
||||
versionCode = 87
|
||||
versionName = "0.9.74"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
+5
-1
@@ -4,7 +4,7 @@ package cn.ilapage.goauto.agent.automation
|
||||
internal object PurchaseFinalSpecVerifier {
|
||||
private fun normalize(value: String) = SpecValueNormalizer.normalizeFinalConfirmation(value)
|
||||
|
||||
fun verify(snapshot: UiSnapshot, input: PurchaseExecutionInput, diagnostic: (String) -> Unit) {
|
||||
fun verify(snapshot: UiSnapshot, input: PurchaseExecutionInput, diagnostic: (String) -> Unit): String {
|
||||
val config = PurchaseRehearsalExecutor.DEFAULT_COLLECTOR
|
||||
val byPath = snapshot.nodes.associateBy(SnapshotNode::path)
|
||||
fun within(node: SnapshotNode, container: SnapshotNode): Boolean {
|
||||
@@ -61,6 +61,9 @@ internal object PurchaseFinalSpecVerifier {
|
||||
val prefix = config.textAliases.selection.selectedPrefixes.firstOrNull(trimmed::startsWith)
|
||||
val value = if (prefix == null) trimmed else trimmed.removePrefix(prefix).trimStart().let {
|
||||
if (it.startsWith(':') || it.startsWith(':')) it.drop(1) else it
|
||||
}.trimStart().let {
|
||||
// #391: 采购1 的摘要写作「已选: #颜色 尺码」;只去掉开头这一个 '#',值内部的不动。
|
||||
if (it.startsWith('#') || it.startsWith('#')) it.drop(1) else it
|
||||
}
|
||||
normalize(value)
|
||||
}.distinct()
|
||||
@@ -99,6 +102,7 @@ internal object PurchaseFinalSpecVerifier {
|
||||
targets.keys.forEach { dimension ->
|
||||
if (dimension !in selectedProof && summary != expected) reject(dimension, "missing_proof")
|
||||
}
|
||||
return if (targets.keys.all { it in selectedProof }) PurchaseSpecProof.SELECTED_OPTION else PurchaseSpecProof.SELECTED_SUMMARY
|
||||
}
|
||||
|
||||
/** Existing #335 quantity-ancestor form, retaining identity as well as bounds for summary ownership. */
|
||||
|
||||
+13
-3
@@ -17,8 +17,15 @@ data class FinalConfirmationEvidence(
|
||||
val unitPriceCent: Long,
|
||||
val addressSuffix: String,
|
||||
val activityName: String,
|
||||
/** #391: 最终规格复核依据,取值见 [PurchaseSpecProof]。 */
|
||||
val specProof: String = PurchaseSpecProof.SELECTED_OPTION,
|
||||
)
|
||||
|
||||
object PurchaseSpecProof {
|
||||
const val SELECTED_SUMMARY = "selected_summary"
|
||||
const val SELECTED_OPTION = "selected_option"
|
||||
}
|
||||
|
||||
data class PurchaseOrderEvidence(val orderNo: String, val submittedAt: String, val pddOrderAmountCent: Long? = null)
|
||||
data class PurchaseOrderReadFailure(
|
||||
val code: String,
|
||||
@@ -321,12 +328,13 @@ class PurchaseLiveAutomation(
|
||||
val submit = finalSubmitTargets(snapshot)
|
||||
if (submit.size != 1) fail("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "创建订单按钮不是唯一目标,禁止创建订单")
|
||||
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, input.goodsId, null)
|
||||
PurchaseFinalSpecVerifier.verify(snapshot, input, panelDiagnostic)
|
||||
val specProof = PurchaseFinalSpecVerifier.verify(snapshot, input, panelDiagnostic)
|
||||
panelDiagnostic("finalSpecProof=$specProof")
|
||||
val price = screen.priceCent ?: fail("RULE_NOT_MATCHED", "创建订单前没有读取到商品单价")
|
||||
if (price !in input.minUnitPriceCent..input.maxUnitPriceCent) fail("PURCHASE_PRICE_OUT_OF_RANGE", "当前商品单价超出允许范围", price)
|
||||
val quantities = snapshot.nodes.filter { it.visible && it.enabled && it.className?.endsWith("EditText") == true }.mapNotNull { it.label.toLongOrNull() }
|
||||
if (quantities.singleOrNull() != input.quantity) fail("PURCHASE_QUANTITY_MISMATCH", "创建订单前数量复核失败")
|
||||
return FinalConfirmationEvidence(input.goodsId, input.mappedColor, input.mappedSize, input.quantity, price, address.suffix, snapshot.activityName.orEmpty())
|
||||
return FinalConfirmationEvidence(input.goodsId, input.mappedColor, input.mappedSize, input.quantity, price, address.suffix, snapshot.activityName.orEmpty(), specProof)
|
||||
}
|
||||
|
||||
fun submitOrderOnce() {
|
||||
@@ -797,7 +805,7 @@ class PurchaseLiveAutomation(
|
||||
val screenHeight = snapshot.nodes.maxOfOrNull { it.bounds.bottom }?.coerceAtLeast(1) ?: 1
|
||||
return snapshot.nodes.filter { node ->
|
||||
node.visible && node.enabled && node.scrollable &&
|
||||
node.bounds.height >= node.bounds.width &&
|
||||
node.bounds.height * 100 >= node.bounds.width * PANEL_SCROLL_MIN_HEIGHT_TO_WIDTH_PERCENT &&
|
||||
node.bounds.width * 100 >= screenWidth * 60 &&
|
||||
node.bounds.height * 100 >= screenHeight * 20
|
||||
}.distinctBy { it.path }
|
||||
@@ -1153,6 +1161,8 @@ class PurchaseLiveAutomation(
|
||||
const val SPEC_CONFIRMATION_MAX_SAMPLES = 20
|
||||
const val SPEC_CONFIRMATION_SAMPLE_INTERVAL_MS = 100L
|
||||
const val ADDRESS_ENTRY_STABLE_INTERVAL_MS = 200L
|
||||
// #390: near-square purchase lists can be shorter than their width; exclude flat strips.
|
||||
const val PANEL_SCROLL_MIN_HEIGHT_TO_WIDTH_PERCENT = 80
|
||||
const val ADDRESS_CARD_OVERLAP_PERCENT = 70
|
||||
const val ADDRESS_SEMANTIC_MIN_LENGTH = 4
|
||||
val ADDRESS_CARD_GENERIC_LABELS = setOf("收货地址", "修改", "默认地址", "默认")
|
||||
|
||||
@@ -538,8 +538,10 @@ class AgentApiClient(
|
||||
return purchaseTask(requireNotNull(request("POST", "/api/agent/v1/purchase-tasks/$taskId/start", payload, token)).getJSONObject("data"))
|
||||
}
|
||||
|
||||
fun markPurchaseOrderSubmitStarted(taskId: Long, requestId: String, token: String): PurchaseAgentTask {
|
||||
fun markPurchaseOrderSubmitStarted(taskId: Long, requestId: String, token: String, specProof: String? = null): PurchaseAgentTask {
|
||||
val payload = JSONObject().put("requestId", requestId)
|
||||
// #391: 可选依据;恢复路径拿不到时不带该字段,服务端存 NULL。
|
||||
if (!specProof.isNullOrBlank()) payload.put("specProof", specProof)
|
||||
return purchaseTask(requireNotNull(request("POST", "/api/agent/v1/purchase-tasks/$taskId/order-submit-started", payload, token)).getJSONObject("data"))
|
||||
}
|
||||
|
||||
|
||||
@@ -1074,9 +1074,10 @@ class AgentForegroundService : Service() {
|
||||
.put("unitPriceCent", evidence.unitPriceCent)
|
||||
.put("addressSuffix", evidence.addressSuffix)
|
||||
.put("activityName", evidence.activityName)
|
||||
.put("specProof", evidence.specProof)
|
||||
.toString()
|
||||
purchaseStore.markOrderSubmitStarted(task.taskId, task.taskAttemptId, boundaryRequestId, finalEvidence)
|
||||
api.markPurchaseOrderSubmitStarted(task.taskId, boundaryRequestId, token)
|
||||
api.markPurchaseOrderSubmitStarted(task.taskId, boundaryRequestId, token, evidence.specProof)
|
||||
},
|
||||
).execute(
|
||||
PurchaseExecutionInput(
|
||||
|
||||
@@ -1302,6 +1302,113 @@ class PurchaseLiveAutomationTest {
|
||||
assertEquals(0, driver.genericSwipes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `near square purchase panel reveals collapsed address with one bounded swipe`() {
|
||||
assertClippedPanelAddressRevealed(NodeBounds(0, 400, 1080, 1478))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `purchase panel accepts exactly eighty percent height to width`() {
|
||||
assertClippedPanelAddressRevealed(NodeBounds(0, 400, 1080, 1264))
|
||||
}
|
||||
|
||||
private fun assertClippedPanelAddressRevealed(bounds: NodeBounds) {
|
||||
val driver = LiveDriver(addressClipped = true, clippedPanelBounds = bounds)
|
||||
val initial = driver.capture()
|
||||
val collapsedAddress = initial.nodes.single { it.path == "panel/collapsed-address" }
|
||||
assertEquals(0, collapsedAddress.bounds.height)
|
||||
assertFalse(collapsedAddress.visible)
|
||||
assertEquals(2376, initial.nodes.maxOf { it.bounds.bottom })
|
||||
|
||||
val proof = PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg390")
|
||||
|
||||
assertTrue(proof.expectedAddress.endsWith("_cg390"))
|
||||
assertEquals(listOf(Triple(bounds, SwipeDirection.DOWN, 350L)), driver.scopedSwipeRequests)
|
||||
assertEquals(1, driver.addressPathClicks)
|
||||
assertEquals(1, driver.inputCount)
|
||||
assertEquals(0, driver.genericSwipes + driver.submitClicks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `purchase panel rejects height just below eighty percent and flat horizontal strip`() {
|
||||
for (height in listOf(863, 400)) {
|
||||
val driver = LiveDriver(addressClipped = true, clippedPanelBounds = NodeBounds(0, 400, 1080, 400 + height))
|
||||
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg390") }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", error.code)
|
||||
assertEquals(0, driver.scopedSwipes + driver.genericSwipes + driver.inputCount + driver.submitClicks)
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `purchase panel still requires sixty percent screen width and twenty percent screen height`() {
|
||||
for (bounds in listOf(NodeBounds(0, 400, 647, 1478), NodeBounds(0, 400, 648, 999))) {
|
||||
val driver = LiveDriver(addressClipped = true, clippedPanelBounds = bounds, clippedScreenHeight = 3000)
|
||||
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg390") }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", error.code)
|
||||
assertEquals(0, driver.scopedSwipes + driver.genericSwipes + driver.inputCount + driver.submitClicks)
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two near square purchase panels remain ambiguous without swiping`() {
|
||||
val driver = LiveDriver(addressClipped = true, duplicatePanels = true, clippedPanelBounds = NodeBounds(0, 400, 1080, 1478))
|
||||
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg390") }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", error.code)
|
||||
assertEquals(0, driver.scopedSwipes + driver.genericSwipes + driver.inputCount + driver.submitClicks)
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `near square purchase panel stops after unchanged viewport without address`() {
|
||||
assertClippedPanelTimeout(viewportChanges = false, expectedSwipes = 2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `near square purchase panel stops within five rounds without address`() {
|
||||
assertClippedPanelTimeout(viewportChanges = true, expectedSwipes = 4)
|
||||
}
|
||||
|
||||
private fun assertClippedPanelTimeout(viewportChanges: Boolean, expectedSwipes: Int) {
|
||||
val bounds = NodeBounds(0, 400, 1080, 1478)
|
||||
val driver = LiveDriver(
|
||||
addressClipped = true, clippedPanelBounds = bounds,
|
||||
keepAddressClipped = true, clippedViewportChanges = viewportChanges,
|
||||
)
|
||||
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg390") }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_ADDRESS_PANEL_TIMEOUT", error.code)
|
||||
assertEquals(List(expectedSwipes) { Triple(bounds, SwipeDirection.DOWN, 350L) }, driver.scopedSwipeRequests)
|
||||
assertEquals(0, driver.genericSwipes + driver.inputCount + driver.submitClicks)
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saved address evidence is restored through near square purchase panel`() {
|
||||
val bounds = NodeBounds(0, 400, 1080, 1478)
|
||||
val driver = LiveDriver(saveReturnsToSpecPanel = true, clippedPanelBounds = bounds)
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
|
||||
val proof = automation.updateShippingAddress("_cg390")
|
||||
val final = automation.finalConfirmation(input().copy(addressSuffix = "_cg390"), proof)
|
||||
|
||||
assertEquals("_cg390", final.addressSuffix)
|
||||
assertEquals(listOf(Triple(bounds, SwipeDirection.DOWN, 350L)), driver.scopedSwipeRequests)
|
||||
assertEquals(0, driver.backCount + driver.genericSwipes + driver.submitClicks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ambiguous purchase panels fail without swiping or clicking`() {
|
||||
val driver = LiveDriver(addressClipped = true, duplicatePanels = true)
|
||||
@@ -1448,6 +1555,10 @@ class PurchaseLiveAutomationTest {
|
||||
private class LiveDriver(
|
||||
private val addressClipped: Boolean = false,
|
||||
private val duplicatePanels: Boolean = false,
|
||||
private val clippedPanelBounds: NodeBounds? = null,
|
||||
private val clippedScreenHeight: Int = 2376,
|
||||
private val keepAddressClipped: Boolean = false,
|
||||
private val clippedViewportChanges: Boolean = false,
|
||||
private val chooserAfterSubmit: Boolean = false,
|
||||
private val trustedChooser: Boolean = true,
|
||||
private val splitConfirmationAddress: Boolean = false,
|
||||
@@ -1480,6 +1591,7 @@ class PurchaseLiveAutomationTest {
|
||||
val clicked = mutableListOf<String>()
|
||||
var submitClicks = 0
|
||||
var scopedSwipes = 0
|
||||
val scopedSwipeRequests = mutableListOf<Triple<NodeBounds, SwipeDirection, Long>>()
|
||||
var genericSwipes = 0
|
||||
var addressTaps = 0
|
||||
var addressPathClicks = 0
|
||||
@@ -1500,7 +1612,21 @@ class PurchaseLiveAutomationTest {
|
||||
postSubmitCaptureCount++
|
||||
if (pendingPostSubmitPages.isNotEmpty()) page = pendingPostSubmitPages.removeAt(0)
|
||||
}
|
||||
val snapshot = currentSnapshot()
|
||||
val snapshot = currentSnapshot().let { original ->
|
||||
if (clippedPanelBounds != null && ((page == "confirmation" && !addressVisible) || page == "post-save-spec")) {
|
||||
original.copy(nodes = original.nodes.map {
|
||||
when (it.path) {
|
||||
"root" -> it.copy(bounds = NodeBounds(0, 0, 1080, clippedScreenHeight))
|
||||
"panel", "panel2" -> it.copy(bounds = clippedPanelBounds)
|
||||
else -> it
|
||||
}
|
||||
} + node("panel/collapsed-address", "138****5678", parentPath = "panel",
|
||||
bounds = NodeBounds(20, 400, 400, 400)).copy(visible = false) +
|
||||
node("panel/viewport-marker", "", parentPath = "panel", bounds = NodeBounds(
|
||||
20, 800 + if (clippedViewportChanges) scopedSwipes else 0, 300, 850,
|
||||
)))
|
||||
} else original
|
||||
}
|
||||
if (page == "edit" && inputCount == 0) {
|
||||
initialEditCaptures++
|
||||
return initialEditFrame(snapshot, initialEditCaptures).also { lastInitialEditSnapshot = it }
|
||||
@@ -1691,7 +1817,8 @@ class PurchaseLiveAutomationTest {
|
||||
}
|
||||
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
|
||||
scopedSwipes++
|
||||
if (target.path == "panel" && direction == SwipeDirection.DOWN) addressVisible = true
|
||||
scopedSwipeRequests += Triple(target.bounds, direction, durationMs)
|
||||
if (target.path == "panel" && direction == SwipeDirection.DOWN && !keepAddressClipped) addressVisible = true
|
||||
if (page == "post-save-spec" && direction == SwipeDirection.DOWN && !savedSpecPanelRecoveryStuck) page = "confirmation"
|
||||
return true
|
||||
}
|
||||
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.*
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
/** #391: the final verifier reads "已选: #颜色 尺码" summaries. All specs and addresses are synthetic. */
|
||||
class PurchaseSelectedSummaryProofTest {
|
||||
private val offscreen = Pair(listOf("其他颜色"), listOf("其他尺码"))
|
||||
|
||||
private fun offscreenSheet(summary: String?) = sheet(summary, colors = offscreen.first, sizes = offscreen.second)
|
||||
|
||||
// 1. '#' before the value, off-screen options -> summary proof, one click.
|
||||
@Test fun `hash prefixed summary with offscreen options passes as selected_summary and submits once`() {
|
||||
val driver = Driver(offscreenSheet("已选: #黑色[九分]小个子 S 建议80-90斤"))
|
||||
val automation = automation(driver)
|
||||
val evidence = automation.finalConfirmation(input("黑色【九分】小个子", "S 建议80-90斤"), PROOF)
|
||||
assertEquals("selected_summary", evidence.specProof)
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
automation.submitOrderOnce()
|
||||
assertEquals(1, driver.clicked.size)
|
||||
val error = runCatching { automation.submitOrderOnce() }.exceptionOrNull() as PurchaseLiveException
|
||||
assertEquals("PURCHASE_SUBMIT_ALREADY_ATTEMPTED", error.code)
|
||||
assertEquals(1, driver.clicked.size)
|
||||
}
|
||||
|
||||
// 2. Full-width hash, colon and whitespace variants; summary without '#' unchanged.
|
||||
@Test fun `hash and colon variants pass and plain summary is unchanged`() {
|
||||
for (summary in listOf("已选: #黑色 XL", "已选:#黑色 XL", "已选:#黑色 XL", "已选 # 黑色 XL", "已选:黑色 XL", "已选 黑色 XL")) {
|
||||
assertEquals(summary, "selected_summary", check(offscreenSheet(summary), input("黑色", "XL")).specProof)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Only the leading '#' is removed.
|
||||
@Test fun `inner hash is preserved and double leading hash is not matched`() {
|
||||
assertEquals("selected_summary", check(offscreenSheet("已选:红色#2 XL"), input("红色#2", "XL")).specProof)
|
||||
assertEquals("selected_summary", check(offscreenSheet("已选: #红色#2 XL"), input("红色#2", "XL")).specProof)
|
||||
rejectsMissingProof(offscreenSheet("已选: ##黑色 XL"), input("黑色", "XL"))
|
||||
}
|
||||
|
||||
// 4. Longer color still rejected.
|
||||
@Test fun `hash summary with longer color is still rejected`() {
|
||||
rejectsMissingProof(offscreenSheet("已选: #黑色加绒 XL"), input("黑色", "XL"))
|
||||
}
|
||||
|
||||
// 5. Original protections still apply with a '#' summary.
|
||||
@Test fun `selected conflict and composition ambiguity still reject under hash summary`() {
|
||||
rejects(sheet("已选: #黑色 XL", colors = listOf("黑色", "白色")).selected("color/o1"), input("黑色", "XL"))
|
||||
rejects(
|
||||
sheet("已选: #黑色 加绒 XL", colors = listOf("黑色", "黑色 加绒"), sizes = listOf("加绒 XL", "XL")),
|
||||
input("黑色", "加绒 XL"),
|
||||
)
|
||||
}
|
||||
|
||||
// 6. Quantity and price are still checked, with no click.
|
||||
@Test fun `summary proof still enforces quantity and price without clicking`() {
|
||||
val snapshot = offscreenSheet("已选: #黑色 XL")
|
||||
for ((expected, task) in listOf(
|
||||
"PURCHASE_QUANTITY_MISMATCH" to input("黑色", "XL").copy(quantity = 2),
|
||||
"PURCHASE_PRICE_OUT_OF_RANGE" to input("黑色", "XL").copy(maxUnitPriceCent = 1),
|
||||
)) {
|
||||
val driver = Driver(snapshot)
|
||||
val error = runCatching { automation(driver).finalConfirmation(task, PROOF) }.exceptionOrNull() as PurchaseLiveException
|
||||
assertEquals(expected, error.code)
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Selected visible options -> selected_option.
|
||||
@Test fun `selected visible options pass as selected_option`() {
|
||||
val snapshot = sheet(null).selected("color/o0/img").selected("size/o0/t")
|
||||
val diagnostics = mutableListOf<String>()
|
||||
assertEquals("selected_option", check(snapshot, input("黑色", "XL"), diagnostics).specProof)
|
||||
assertTrue(diagnostics.toString(), diagnostics.contains("finalSpecProof=selected_option"))
|
||||
}
|
||||
|
||||
// 8. The order-submit-started request carries specProof only when provided.
|
||||
@Test fun `order submit started request body carries specProof`() {
|
||||
fun body(specProof: String?): String {
|
||||
val out = java.io.ByteArrayOutputStream()
|
||||
val connection = object : java.net.HttpURLConnection(java.net.URL("http://127.0.0.1")) {
|
||||
override fun connect() {}
|
||||
override fun disconnect() {}
|
||||
override fun usingProxy() = false
|
||||
override fun getOutputStream(): java.io.OutputStream = out
|
||||
override fun getResponseCode(): Int = 500
|
||||
override fun getErrorStream(): java.io.InputStream = "{}".byteInputStream()
|
||||
}
|
||||
val api = cn.ilapage.goauto.agent.network.AgentApiClient("http://127.0.0.1", connectionFactory = { connection })
|
||||
runCatching { api.markPurchaseOrderSubmitStarted(1, "req-1", "synthetic", specProof) }
|
||||
return out.toString(Charsets.UTF_8.name())
|
||||
}
|
||||
val withProof = org.json.JSONObject(body("selected_summary"))
|
||||
assertEquals("req-1", withProof.getString("requestId"))
|
||||
assertEquals("selected_summary", withProof.getString("specProof"))
|
||||
assertFalse(org.json.JSONObject(body(null)).has("specProof"))
|
||||
}
|
||||
|
||||
private fun automation(driver: PurchaseUiDriver, diagnostics: MutableList<String> = mutableListOf()) =
|
||||
PurchaseLiveAutomation(driver, pause = {}, panelDiagnostic = diagnostics::add)
|
||||
|
||||
private fun check(snapshot: UiSnapshot, input: PurchaseExecutionInput, diagnostics: MutableList<String> = mutableListOf()) =
|
||||
automation(Driver(snapshot), diagnostics).finalConfirmation(input, PROOF)
|
||||
|
||||
private fun rejects(snapshot: UiSnapshot, input: PurchaseExecutionInput): PurchaseLiveException {
|
||||
val driver = Driver(snapshot)
|
||||
val error = runCatching { automation(driver).finalConfirmation(input, PROOF) }.exceptionOrNull() as PurchaseLiveException
|
||||
assertEquals("PURCHASE_SPEC_NOT_MATCHED", error.code)
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
return error
|
||||
}
|
||||
|
||||
private fun rejectsMissingProof(snapshot: UiSnapshot, input: PurchaseExecutionInput) {
|
||||
assertTrue(rejects(snapshot, input).message.orEmpty().contains("stage=missing_proof"))
|
||||
}
|
||||
|
||||
private fun sheet(
|
||||
summary: String?, colors: List<String> = listOf("黑色"), sizes: List<String> = listOf("XL"),
|
||||
): UiSnapshot = SpecPanelFixtures.sheet(SpecPanelFixtures.Sheet(colorLabels = colors, sizeLabels = sizes))
|
||||
.mapNodes { when (it.path) {
|
||||
"r/sheet/body/summary" -> it.copy(text = summary, visible = summary != null)
|
||||
"r/sheet/body/addr/a/detail" -> it.copy(text = ADDRESS)
|
||||
else -> it
|
||||
} }
|
||||
|
||||
private fun UiSnapshot.mapNodes(transform: (SnapshotNode) -> SnapshotNode) = copy(nodes = nodes.map(transform))
|
||||
private fun UiSnapshot.selected(suffix: String) = mapNodes {
|
||||
if (it.path.endsWith(suffix)) it.copy(selected = true) else it
|
||||
}
|
||||
|
||||
private fun input(color: String, size: String) = PurchaseExecutionInput(
|
||||
taskId = 391, executionMode = "live", phase = "purchase", url = "https://mobile.yangkeduo.com/goods.html?goods_id=391",
|
||||
goodsId = "391", mappedColor = color, mappedSize = size, quantity = 1,
|
||||
minUnitPriceCent = 1, maxUnitPriceCent = 3000, addressSuffix = "_cg391",
|
||||
)
|
||||
|
||||
private class Driver(private val snapshot: UiSnapshot) : PurchaseUiDriver {
|
||||
val clicked = mutableListOf<String>()
|
||||
override fun capture() = snapshot
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult { clicked += target.path; return FreshActionResult.SUCCESS }
|
||||
override fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult = error("unexpected tap")
|
||||
override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult = error("unexpected input")
|
||||
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean = error("unexpected swipe")
|
||||
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean = error("unexpected scoped swipe")
|
||||
override fun backPurchase(): Boolean = error("unexpected back")
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val ADDRESS = "示例区示例路391号_cg391"
|
||||
val PROOF = ShippingAddressProof(ADDRESS, "_cg391")
|
||||
}
|
||||
}
|
||||
@@ -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: e937060afcf088930d8ec3cda537ef1199265e74
|
||||
synchronized_at: 2026-10-13T10:02:11Z
|
||||
wiki_revision: f7a7e0dd3842f203512f6dd2a7674af230e85209
|
||||
synchronized_at: 2026-10-14T01:36:13Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -1059,7 +1059,7 @@ Android同步诊断不改变#377的心跳→恢复→补传→必要的一次任
|
||||
|
||||
## 采购规格面板近正方形滚动区域(#390)
|
||||
|
||||
实现绑定工单分支 `fix/390-panel-scroll-ratio`,Agent `0.9.73 / code86`;未合并、未安装或发布,真机展开顶部地址区域的效果未验证。
|
||||
实现 `a3c05a7`,2026-10-14 合并 main `5282d65`(Agent `0.9.73 / code86`,包含 #388);未安装或发布,真机展开顶部地址区域的效果未验证。
|
||||
|
||||
- 仅采购 `PurchaseLiveAutomation.purchasePanelScrollTargets` 的形状条件调整为“高度不小于宽度的 80%”,用于改地址前寻找入口、保存地址后恢复当前面板证据。不是所有纵向滚动容器的通用规则,普通采集器不改。
|
||||
- 继续要求节点可见、启用、可滚动,宽度至少为视口估算宽度的 60%、高度至少为视口估算高度的 20%,且滚动候选必须唯一。扁平横条、尺寸不足及多候选仍明确失败,不选择最大区域兜底。
|
||||
|
||||
@@ -24,6 +24,9 @@ const (
|
||||
PurchaseTaskStatusFailed = "failed"
|
||||
PurchaseTaskStatusCancelled = "cancelled"
|
||||
|
||||
PurchaseOrderSpecProofSelectedSummary = "selected_summary"
|
||||
PurchaseOrderSpecProofSelectedOption = "selected_option"
|
||||
|
||||
PurchaseAttemptPhaseSpecProbe = "spec_probe"
|
||||
PurchaseAttemptPhasePurchase = "purchase"
|
||||
|
||||
@@ -135,8 +138,11 @@ type PurchaseTask struct {
|
||||
CreateRequestID string `json:"-" gorm:"size:36;not null;uniqueIndex:ux_purchase_task_create_request_id"`
|
||||
ClaimRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_purchase_task_claim_request_id"`
|
||||
OrderSubmitRequestID *string `json:"-" gorm:"size:64;uniqueIndex:ux_purchase_task_order_submit_request_id"`
|
||||
StatusVersion uint64 `json:"statusVersion" gorm:"not null;default:1"`
|
||||
StatusChangedAt time.Time `json:"statusChangedAt" gorm:"not null"`
|
||||
// OrderSpecProof 记录下单前最终规格复核的依据(#391):selected_summary=凭「已选」
|
||||
// 摘要,selected_option=原检查;NULL=旧 Agent 或未提供。仅供人工抽查,不进任何 JSON 输出。
|
||||
OrderSpecProof *string `json:"-" gorm:"size:32"`
|
||||
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"`
|
||||
|
||||
@@ -331,9 +331,28 @@ func (h Handler) AgentReset(c *gin.Context) {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.JSON(http.StatusOK, gin.H{"data": result})
|
||||
}
|
||||
func (h Handler) Claim(c *gin.Context) { h.action(c, (*Service).Claim) }
|
||||
func (h Handler) Start(c *gin.Context) { h.action(c, (*Service).Start) }
|
||||
func (h Handler) OrderSubmitStarted(c *gin.Context) { h.action(c, (*Service).MarkOrderSubmitStarted) }
|
||||
func (h Handler) Claim(c *gin.Context) { h.action(c, (*Service).Claim) }
|
||||
func (h Handler) Start(c *gin.Context) { h.action(c, (*Service).Start) }
|
||||
func (h Handler) OrderSubmitStarted(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req OrderSubmitStartedRequest
|
||||
if !decode(c, &req) {
|
||||
return
|
||||
}
|
||||
service, serviceOK := h.service(c)
|
||||
if !serviceOK {
|
||||
return
|
||||
}
|
||||
p, err := service.MarkOrderSubmitStartedWithProof(c.Request.Context(), id, req, bearer(c.GetHeader("Authorization")))
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": p})
|
||||
}
|
||||
func (h Handler) Result(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
|
||||
@@ -274,6 +274,12 @@ func purchaseRuleSnapshotHash(snapshot string) string {
|
||||
}
|
||||
|
||||
func (s *Service) MarkOrderSubmitStarted(ctx context.Context, taskID uint64, req ActionRequest, token string) (TaskPayload, error) {
|
||||
return s.MarkOrderSubmitStartedWithProof(ctx, taskID, OrderSubmitStartedRequest{RequestID: req.RequestID}, token)
|
||||
}
|
||||
|
||||
// MarkOrderSubmitStartedWithProof 在标记下单开始的同一次保存里记录最终规格依据(#391)。
|
||||
// 依据只认已知枚举,缺失或未知值存 NULL,不影响下单边界标记;重放不改已存值。
|
||||
func (s *Service) MarkOrderSubmitStartedWithProof(ctx context.Context, taskID uint64, req OrderSubmitStartedRequest, token string) (TaskPayload, error) {
|
||||
return s.withRunning(ctx, taskID, token, func(tx *gorm.DB, t *models.PurchaseTask, a *models.PurchaseTaskAttempt, d models.AgentDevice) (TaskPayload, error) {
|
||||
if t.ExecutionMode != models.PurchaseExecutionModeLive {
|
||||
return TaskPayload{}, fail(CodeStateConflict, "演练任务不能创建订单")
|
||||
@@ -295,6 +301,9 @@ func (s *Service) MarkOrderSubmitStarted(ctx context.Context, taskID uint64, req
|
||||
return TaskPayload{}, internal(e)
|
||||
}
|
||||
t.OrderSubmitRequestID = &req.RequestID
|
||||
if proof := req.SpecProof; proof == models.PurchaseOrderSpecProofSelectedSummary || proof == models.PurchaseOrderSpecProofSelectedOption {
|
||||
t.OrderSpecProof = &proof
|
||||
}
|
||||
t.IrreversibleAt = &now
|
||||
t.StatusVersion++
|
||||
t.StatusChangedAt = now
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestMarkOrderSubmitStartedRecordsSpecProof(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
proof string
|
||||
want *string
|
||||
}{
|
||||
{"summary", "selected_summary", ptrString("selected_summary")},
|
||||
{"option", "selected_option", ptrString("selected_option")},
|
||||
{"missing", "", nil},
|
||||
{"unknown", "something_else", nil},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
s := testService(db)
|
||||
task, err := createLive(t, s, f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
startLivePurchaseAfterProbe(t, s, f, task)
|
||||
reqID := uuid.NewString()
|
||||
if _, err = s.MarkOrderSubmitStartedWithProof(context.Background(), task.ID, OrderSubmitStartedRequest{RequestID: reqID, SpecProof: tc.proof}, f.token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertSpecProof(t, db, task.ID, tc.want)
|
||||
// Same requestId replay with a different proof must not change the stored value.
|
||||
replay, err := s.MarkOrderSubmitStartedWithProof(context.Background(), task.ID, OrderSubmitStartedRequest{RequestID: reqID, SpecProof: "selected_option"}, f.token)
|
||||
if err != nil || !replay.Replayed {
|
||||
t.Fatalf("replay failed: %+v %v", replay, err)
|
||||
}
|
||||
assertSpecProof(t, db, task.ID, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderSubmitStartedHandlerSpecProofAndStrictDecode(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
s := testService(db)
|
||||
task, err := createLive(t, s, f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
startLivePurchaseAfterProbe(t, s, f, task)
|
||||
// The handler builds its own service with the real clock; keep the lease valid.
|
||||
future := time.Now().Add(time.Hour)
|
||||
if err = db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", task.ID).Update("lease_expires_at", future).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := Handler{DB: db}
|
||||
post := func(fn gin.HandlerFunc, body string) *httptest.ResponseRecorder {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "taskId", Value: strconv.FormatUint(task.ID, 10)}}
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Authorization", "Bearer "+f.token)
|
||||
fn(c)
|
||||
return w
|
||||
}
|
||||
// Claim/Start keep rejecting specProof as an unknown field.
|
||||
for name, fn := range map[string]gin.HandlerFunc{"claim": h.Claim, "start": h.Start} {
|
||||
if w := post(fn, `{"requestId":"`+uuid.NewString()+`","specProof":"selected_summary"}`); w.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("%s accepted specProof: %d %s", name, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
// Unknown extra fields are still rejected on order-submit-started.
|
||||
if w := post(h.OrderSubmitStarted, `{"requestId":"`+uuid.NewString()+`","other":1}`); w.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("unknown field accepted: %d", w.Code)
|
||||
}
|
||||
w := post(h.OrderSubmitStarted, `{"requestId":"`+uuid.NewString()+`","specProof":"selected_summary"}`)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("order-submit-started failed: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
assertSpecProof(t, db, task.ID, ptrString("selected_summary"))
|
||||
if bytes.Contains(w.Body.Bytes(), []byte("orderSpecProof")) || bytes.Contains(w.Body.Bytes(), []byte("specProof")) {
|
||||
t.Fatalf("proof leaked into response: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func assertSpecProof(t *testing.T, db *gorm.DB, id uint64, want *string) {
|
||||
t.Helper()
|
||||
var saved models.PurchaseTask
|
||||
if err := db.First(&saved, id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if (saved.OrderSpecProof == nil) != (want == nil) || (want != nil && *saved.OrderSpecProof != *want) {
|
||||
t.Fatalf("order_spec_proof=%v want %v", saved.OrderSpecProof, want)
|
||||
}
|
||||
}
|
||||
@@ -154,6 +154,13 @@ type ActionRequest struct {
|
||||
RequestID string `json:"requestId"`
|
||||
}
|
||||
|
||||
// OrderSubmitStartedRequest 只用于 order-submit-started;specProof 可选(#391),
|
||||
// 领取、开始接口仍用 ActionRequest,不接受该字段。
|
||||
type OrderSubmitStartedRequest struct {
|
||||
RequestID string `json:"requestId"`
|
||||
SpecProof string `json:"specProof,omitempty"`
|
||||
}
|
||||
|
||||
type TaskPayload struct {
|
||||
TaskID uint64 `json:"taskId"`
|
||||
TaskAttemptID string `json:"taskAttemptId,omitempty"`
|
||||
|
||||
@@ -2,9 +2,8 @@ package sybimport
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// SYB's numeric isCancel enum is 0 (正常), 1 (取消), 2 (已取消).
|
||||
// Both 1 and 2 mean cancelled; other nonzero values remain unknown. Do not
|
||||
// infer anything from status/orderStatus or missing fields.
|
||||
// SYB returns numeric isCancel: 1 cancelled, 0 not cancelled. Do not infer
|
||||
// anything from status/orderStatus, missing fields or undocumented values.
|
||||
func parseSYBIsCancelled(raw map[string]any) *bool {
|
||||
var number float64
|
||||
switch value := raw["isCancel"].(type) {
|
||||
@@ -19,9 +18,9 @@ func parseSYBIsCancelled(raw map[string]any) *bool {
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
if number != 0 && number != 1 && number != 2 {
|
||||
if number != 0 && number != 1 {
|
||||
return nil
|
||||
}
|
||||
cancelled := number == 1 || number == 2
|
||||
cancelled := number == 1
|
||||
return &cancelled
|
||||
}
|
||||
|
||||
@@ -3,11 +3,7 @@ package sybimport
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -18,42 +14,23 @@ import (
|
||||
|
||||
func TestParseSYBIsCancelled(t *testing.T) {
|
||||
for _, tc := range []struct{ input, want string }{
|
||||
{`{"isCancel":1}`, "true"}, {`{"isCancel":2}`, "true"}, {`{"isCancel":0}`, "false"},
|
||||
{`{"isCancel":0.0}`, "false"}, {`{"isCancel":1.0}`, "true"}, {`{"isCancel":2.0}`, "true"},
|
||||
{`{"isCancel":2e0}`, "true"},
|
||||
{`{"isCancel":1}`, "true"}, {`{"isCancel":0}`, "false"},
|
||||
{`{}`, "null"}, {`{"isCancel":null}`, "null"},
|
||||
{`{"isCancel":3}`, "null"}, {`{"isCancel":-1}`, "null"},
|
||||
{`{"isCancel":2}`, "null"}, {`{"isCancel":-1}`, "null"},
|
||||
{`{"isCancel":0.5}`, "null"}, {`{"isCancel":"1"}`, "null"},
|
||||
{`{"isCancel":1.5}`, "null"}, {`{"isCancel":2.5}`, "null"},
|
||||
{`{"isCancel":"2"}`, "null"}, {`{"isCancel":"0"}`, "null"}, {`{"isCancel":""}`, "null"},
|
||||
{`{"isCancel":true}`, "null"}, {`{"isCancel":false}`, "null"},
|
||||
{`{"status":79,"orderStatus":"已取消","platformorderStatus":"CANCELLED"}`, "null"},
|
||||
{`{"isCancel":0,"status":79,"orderStatus":"已取消","platformorderStatus":"CANCELLED"}`, "false"},
|
||||
{`{"isCancel":0,"orderStatus":"申请取消"}`, "false"},
|
||||
{`{"isCancel":1,"orderStatus":"已发货"}`, "true"},
|
||||
{`{"isCancel":2,"orderStatus":"已发货"}`, "true"},
|
||||
{`{"isCancel":2,"orderStatus":"运送中"}`, "true"},
|
||||
{`{"isCancel":2,"orderStatus":"待出货"}`, "true"},
|
||||
{`{"isCancel":true}`, "null"}, {`{"status":79,"orderStatus":"已取消"}`, "null"},
|
||||
} {
|
||||
for _, useNumber := range []bool{false, true} {
|
||||
var raw map[string]any
|
||||
decoder := json.NewDecoder(strings.NewReader(tc.input))
|
||||
if useNumber {
|
||||
decoder.UseNumber()
|
||||
}
|
||||
if err := decoder.Decode(&raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := json.Marshal(parseSYBIsCancelled(raw))
|
||||
if string(got) != tc.want {
|
||||
t.Errorf("UseNumber=%t %s: %s, want %s", useNumber, tc.input, got, tc.want)
|
||||
}
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal([]byte(tc.input), &raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := json.Marshal(parseSYBIsCancelled(raw))
|
||||
if string(got) != tc.want {
|
||||
t.Errorf("%s: %s, want %s", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
for _, value := range []any{json.Number("invalid"), json.Number("1e999"), json.Number("NaN"), math.NaN(), math.Inf(1)} {
|
||||
if got := parseSYBIsCancelled(map[string]any{"isCancel": value}); got != nil {
|
||||
t.Errorf("invalid numeric value %v must remain unknown", value)
|
||||
}
|
||||
if got := parseSYBIsCancelled(map[string]any{"isCancel": json.Number("1")}); got == nil || !*got {
|
||||
t.Fatal("json.Number")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,29 +41,15 @@ func TestSyncCancellationDisplayAndListContract(t *testing.T) {
|
||||
detail := sybclient.StockDetail{ID: row.ID, Code: row.Code, Details: []sybclient.DetailItem{
|
||||
{ID: 711, ProductID: 701, ProductQty: 1, ProductSpec: "黑色,XL"},
|
||||
{ID: 712, ProductID: 702, ProductQty: 1, ProductSpec: "白色,L"},
|
||||
{ID: 713, ProductID: 703, ProductQty: 1, ProductSpec: "蓝色,M"},
|
||||
}}
|
||||
untouchedTime := time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC)
|
||||
unrelated := models.SYBProduct{OrderCode: "MOCK-UNRELATED", DetailID: 721, StockID: 72,
|
||||
Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{}`,
|
||||
SYBIsCancelled: boolPointer(false), SYBStatusSyncedAt: &untouchedTime}
|
||||
if err := db.Create(&unrelated).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var previous *time.Time
|
||||
var existingTask models.PurchaseTask
|
||||
var firstDetailID uint64
|
||||
for _, tc := range []struct {
|
||||
raw string
|
||||
want *bool
|
||||
valid bool
|
||||
raw string
|
||||
want *bool
|
||||
}{
|
||||
{`{}`, nil, false}, {`{"isCancel":0}`, boolPointer(false), true},
|
||||
{`{"isCancel":2,"orderStatus":"已发货"}`, boolPointer(true), true},
|
||||
{`{"isCancel":2}`, boolPointer(true), true},
|
||||
{`{"isCancel":null}`, boolPointer(true), false}, {`{"isCancel":7}`, boolPointer(true), false},
|
||||
{`{"isCancel":"2"}`, boolPointer(true), false}, {`{}`, boolPointer(true), false},
|
||||
{`{"isCancel":0}`, boolPointer(false), true}, {`{}`, boolPointer(false), false},
|
||||
{`{}`, nil}, {`{"isCancel":1}`, boolPointer(true)},
|
||||
{`{"isCancel":null}`, boolPointer(true)}, {`{"isCancel":7}`, boolPointer(true)},
|
||||
{`{"isCancel":0}`, boolPointer(false)}, {`{}`, boolPointer(false)},
|
||||
} {
|
||||
var values map[string]any
|
||||
if err := json.Unmarshal([]byte(tc.raw), &values); err != nil {
|
||||
@@ -96,43 +59,17 @@ func TestSyncCancellationDisplayAndListContract(t *testing.T) {
|
||||
for key, value := range values {
|
||||
row.Raw[key] = value
|
||||
}
|
||||
beforeSync := time.Now().UTC()
|
||||
if err := applyStockDetail(ctx, db, row, detail, &SyncReport{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
afterSync := time.Now().UTC()
|
||||
var records []models.SYBProduct
|
||||
if err := db.Where("order_code = ?", row.Code).Order("id").Find(&records).Error; err != nil {
|
||||
if err := db.Order("id").Find(&records).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(records) != 3 {
|
||||
t.Fatal("must keep all three details without duplicates")
|
||||
}
|
||||
firstDetailID = records[0].ID
|
||||
if existingTask.ID == 0 {
|
||||
product := models.PDDProduct{GoodsID: "389001", URL: "https://example.invalid/389001", SpecsJSON: `{}`}
|
||||
if err := db.Create(&product).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
existingTask = models.PurchaseTask{SYBProductID: &records[0].ID, PDDProductID: product.ID,
|
||||
ExecutionMode: models.PurchaseExecutionModeLive, Status: models.PurchaseTaskStatusPending,
|
||||
Quantity: 1, Currency: "TWD", CreateRequestID: "mock-389-existing-task",
|
||||
SpecDecisionSnapshot: `{}`, RuleSnapshot: `{}`, RequiredCapabilitiesJSON: `[]`}
|
||||
if err := db.Create(&existingTask).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.First(&existingTask, existingTask.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
var current models.PurchaseTask
|
||||
if err := db.First(¤t, existingTask.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(current, existingTask) {
|
||||
t.Fatal("cancellation sync must leave the existing purchase task unchanged")
|
||||
}
|
||||
if len(records) != 2 {
|
||||
t.Fatal("must keep both details")
|
||||
}
|
||||
valid := parseSYBIsCancelled(values) != nil
|
||||
for _, record := range records {
|
||||
got, _ := json.Marshal(record.SYBIsCancelled)
|
||||
want, _ := json.Marshal(tc.want)
|
||||
@@ -145,25 +82,15 @@ func TestSyncCancellationDisplayAndListContract(t *testing.T) {
|
||||
if tc.want != nil && record.SYBStatusSyncedAt == nil {
|
||||
t.Fatal("known must have timestamp")
|
||||
}
|
||||
if !tc.valid && previous != nil && !record.SYBStatusSyncedAt.Equal(*previous) {
|
||||
if !valid && previous != nil && !record.SYBStatusSyncedAt.Equal(*previous) {
|
||||
t.Fatal("missing/invalid must preserve time")
|
||||
}
|
||||
if tc.valid && (record.SYBStatusSyncedAt.Before(beforeSync) || record.SYBStatusSyncedAt.After(afterSync)) {
|
||||
t.Fatal("valid values, including repeats, must refresh the sync timestamp")
|
||||
}
|
||||
if records[0].SYBStatusSyncedAt != nil && !record.SYBStatusSyncedAt.Equal(*records[0].SYBStatusSyncedAt) {
|
||||
t.Fatal("same order must use same timestamp for all details")
|
||||
}
|
||||
if record.PDDExcluded {
|
||||
t.Fatal("display status must not exclude procurement")
|
||||
}
|
||||
}
|
||||
var other models.SYBProduct
|
||||
if err := db.First(&other, unrelated.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if other.SYBIsCancelled == nil || *other.SYBIsCancelled || other.SYBStatusSyncedAt == nil || !other.SYBStatusSyncedAt.Equal(untouchedTime) {
|
||||
t.Fatal("sync must not alter another order's cancellation status or timestamp")
|
||||
if records[0].SYBStatusSyncedAt != nil && !records[0].SYBStatusSyncedAt.Equal(*records[1].SYBStatusSyncedAt) {
|
||||
t.Fatal("same order must use same timestamp")
|
||||
}
|
||||
previous = records[0].SYBStatusSyncedAt
|
||||
}
|
||||
@@ -189,7 +116,7 @@ func TestSyncCancellationDisplayAndListContract(t *testing.T) {
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(payload.Data.Items) != 4 {
|
||||
if len(payload.Data.Items) != 2 {
|
||||
t.Fatal("list count")
|
||||
}
|
||||
for _, item := range payload.Data.Items {
|
||||
@@ -208,7 +135,7 @@ func TestSyncCancellationDisplayAndListContract(t *testing.T) {
|
||||
}
|
||||
}
|
||||
response = httptest.NewRecorder()
|
||||
router.ServeHTTP(response, httptest.NewRequest("GET", fmt.Sprintf("/products/%d", firstDetailID), nil))
|
||||
router.ServeHTTP(response, httptest.NewRequest("GET", "/products/1", nil))
|
||||
if response.Code != 200 {
|
||||
t.Fatalf("detail: %d", response.Code)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, file, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(file), MigratePurchaseOrderSpecProof)
|
||||
}
|
||||
|
||||
// MigratePurchaseOrderSpecProof only adds the nullable #391 order_spec_proof
|
||||
// column to purchase_task. Existing rows are not rewritten. The step is
|
||||
// guarded so a partial run can be re-run safely.
|
||||
func MigratePurchaseOrderSpecProof(db *gorm.DB, version string) error {
|
||||
m := db.Migrator()
|
||||
if !m.HasColumn(&models.PurchaseTask{}, "OrderSpecProof") {
|
||||
if err := m.AddColumn(&models.PurchaseTask{}, "OrderSpecProof"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return db.Where("version = ?", version).FirstOrCreate(&common.Migration{Version: version}).Error
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
common "go-admin/common/models"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func TestMigratePurchaseOrderSpecProofAddsNullableColumnAndIsIdempotent(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn, _ := db.DB()
|
||||
defer conn.Close()
|
||||
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,'failed')").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
if err = MigratePurchaseOrderSpecProof(db, "test_391"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if !db.Migrator().HasColumn(&models.PurchaseTask{}, "OrderSpecProof") {
|
||||
t.Fatal("missing order_spec_proof column")
|
||||
}
|
||||
var row struct {
|
||||
Status string
|
||||
OrderSpecProof *string
|
||||
}
|
||||
if err = db.Table("purchase_task").Select("status, order_spec_proof").Where("id = 1").Scan(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if row.Status != "failed" || row.OrderSpecProof != nil {
|
||||
t.Fatalf("legacy row changed: %+v", row)
|
||||
}
|
||||
var count int64
|
||||
db.Model(&common.Migration{}).Where("version = ?", "test_391").Count(&count)
|
||||
if count != 1 {
|
||||
t.Fatal("version not idempotent")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user