Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f3ce610b9 | ||
|
|
31ff045e8c | ||
|
|
b4186a9228 | ||
|
|
f9c48a018a | ||
|
|
bf06b2d500 | ||
|
|
fcb7dad878 | ||
|
|
6550b9fd45 | ||
|
|
7a2c2d1eac |
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "cn.ilapage.goauto.agent"
|
||||
minSdk = 23
|
||||
targetSdk = 34
|
||||
versionCode = 76
|
||||
versionName = "0.9.63"
|
||||
versionCode = 77
|
||||
versionName = "0.9.64"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
+13
-3
@@ -468,17 +468,27 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
}
|
||||
|
||||
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean {
|
||||
val root = rootInActiveWindow ?: return false
|
||||
return swipePurchaseWithEvidence(direction, durationMs).result == PurchaseSwipeResult.SUCCESS
|
||||
}
|
||||
|
||||
override fun swipePurchaseWithEvidence(direction: SwipeDirection, durationMs: Long): PurchaseSwipeOutcome {
|
||||
// Count and resolve against the same live root: a missing root or a
|
||||
// failed dispatch must never masquerade as an empty scrollable list.
|
||||
val root = rootInActiveWindow ?: return PurchaseSwipeOutcome(PurchaseSwipeResult.ROOT_UNAVAILABLE, 0)
|
||||
val candidates = mutableListOf<AccessibilityNodeInfo>()
|
||||
walk(root) { node -> if (node.isVisibleToUser && node.isScrollable) candidates += node }
|
||||
if (candidates.isEmpty()) return PurchaseSwipeOutcome(PurchaseSwipeResult.NO_SCROLLABLE_TARGET, 0)
|
||||
val horizontal = direction == SwipeDirection.LEFT || direction == SwipeDirection.RIGHT
|
||||
val directional = candidates.filter { candidate ->
|
||||
Rect().also(candidate::getBoundsInScreen).let { if (horizontal) it.width() > it.height() else it.height() >= it.width() }
|
||||
}
|
||||
val target = (directional.ifEmpty { candidates }).maxByOrNull { candidate ->
|
||||
Rect().also(candidate::getBoundsInScreen).let { it.width().toLong() * it.height() }
|
||||
} ?: return false
|
||||
return swipeNode(target, direction, durationMs, preferScrollAction = false)
|
||||
} ?: return PurchaseSwipeOutcome(PurchaseSwipeResult.FAILED, candidates.size)
|
||||
val result = if (swipeNode(target, direction, durationMs, preferScrollAction = false)) {
|
||||
PurchaseSwipeResult.SUCCESS
|
||||
} else PurchaseSwipeResult.FAILED
|
||||
return PurchaseSwipeOutcome(result, candidates.size)
|
||||
}
|
||||
|
||||
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
|
||||
|
||||
+43
-10
@@ -3,6 +3,10 @@ package cn.ilapage.goauto.agent.automation
|
||||
import java.net.URI
|
||||
import java.net.URLDecoder
|
||||
|
||||
enum class PurchaseSwipeResult { SUCCESS, NO_SCROLLABLE_TARGET, ROOT_UNAVAILABLE, FAILED }
|
||||
|
||||
data class PurchaseSwipeOutcome(val result: PurchaseSwipeResult, val visibleScrollableCount: Int)
|
||||
|
||||
interface PurchaseUiDriver {
|
||||
fun capture(): UiSnapshot
|
||||
fun clickFresh(target: SnapshotNode): FreshActionResult
|
||||
@@ -32,6 +36,18 @@ interface PurchaseUiDriver {
|
||||
fun specRowSwipeFailureReason(): String = "gestureFailed"
|
||||
fun inputFresh(target: SnapshotNode, value: String): FreshActionResult
|
||||
fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean
|
||||
/** Detailed result only for the rule's reversible spec-panel reveal swipe. */
|
||||
fun swipePurchaseWithEvidence(direction: SwipeDirection, durationMs: Long): PurchaseSwipeOutcome {
|
||||
val snapshot = capture()
|
||||
val count = snapshot.nodes.count { it.visible && it.scrollable }
|
||||
val result = when {
|
||||
snapshot.nodes.isEmpty() -> PurchaseSwipeResult.ROOT_UNAVAILABLE
|
||||
count == 0 -> PurchaseSwipeResult.NO_SCROLLABLE_TARGET
|
||||
swipePurchase(direction, durationMs) -> PurchaseSwipeResult.SUCCESS
|
||||
else -> PurchaseSwipeResult.FAILED
|
||||
}
|
||||
return PurchaseSwipeOutcome(result, count)
|
||||
}
|
||||
fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean
|
||||
fun pullDownGoodsPage(): Boolean = swipePurchase(SwipeDirection.DOWN, 550)
|
||||
fun backPurchase(): Boolean
|
||||
@@ -1150,17 +1166,34 @@ class PurchaseRehearsalExecutor(
|
||||
private fun applyPostAction(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? {
|
||||
if (action.waitAfterMs > 0) pause(action.waitAfterMs)
|
||||
action.swipeAfter?.let { swipe ->
|
||||
// The stock purchase rule asks to reveal additional selector rows after
|
||||
// opening the sheet. A fully-evidenced non-scrollable selector has no
|
||||
// scroll target, and treating that absence as an action failure blocks
|
||||
// an otherwise safe exact-spec flow. Keep all other configured swipes
|
||||
// mandatory; this exception is limited to that confirmed panel state.
|
||||
if (action.type == PurchaseActionType.OPEN_SPEC_PANEL &&
|
||||
currentScreen(input).specPanelType == SpecPanelType.NON_SCROLLABLE_CONFIRMATION
|
||||
) return null
|
||||
val reveal = action.type == PurchaseActionType.OPEN_SPEC_PANEL
|
||||
val initialScreen = if (reveal) currentScreen(input) else null
|
||||
if (initialScreen?.specPanelType == SpecPanelType.NON_SCROLLABLE_CONFIRMATION) {
|
||||
val count = initialScreen.sourceNodes.count { it.visible && it.scrollable }
|
||||
panelDiagnostic("ruleSwipe;outcome=skipped_confirmed_panel;panel=${initialScreen.specPanelType};scrollables=$count;reveal=true")
|
||||
return null
|
||||
}
|
||||
repeat(swipe.count) { index ->
|
||||
if (!driver.swipePurchase(swipe.direction, swipe.durationMs)) {
|
||||
return failure("RULE_ACTION_FAILED", "规则要求的有限滑动失败")
|
||||
// Keep other actions on the original Boolean path. Only the
|
||||
// post-open reveal may treat an absent scroll target as a no-op.
|
||||
val outcome = if (reveal) {
|
||||
driver.swipePurchaseWithEvidence(swipe.direction, swipe.durationMs)
|
||||
} else if (driver.swipePurchase(swipe.direction, swipe.durationMs)) {
|
||||
PurchaseSwipeOutcome(PurchaseSwipeResult.SUCCESS, 0)
|
||||
} else {
|
||||
val count = driver.capture().nodes.count { it.visible && it.scrollable }
|
||||
PurchaseSwipeOutcome(PurchaseSwipeResult.FAILED, count)
|
||||
}
|
||||
if (outcome.result != PurchaseSwipeResult.SUCCESS) {
|
||||
val screen = currentScreen(input)
|
||||
val evidence = "panel=${screen.specPanelType};scrollables=${outcome.visibleScrollableCount};" +
|
||||
"reveal=$reveal;reason=${outcome.result.name.lowercase()}"
|
||||
if (reveal && outcome.result == PurchaseSwipeResult.NO_SCROLLABLE_TARGET) {
|
||||
panelDiagnostic("ruleSwipe;outcome=skipped_no_scroll_target;$evidence")
|
||||
return null
|
||||
}
|
||||
panelDiagnostic("ruleSwipe;outcome=failed;$evidence")
|
||||
return failure("RULE_ACTION_FAILED", "规则要求的有限滑动失败 [$evidence]")
|
||||
}
|
||||
if (index < swipe.count - 1 && swipe.intervalMs > 0) pause(swipe.intervalMs)
|
||||
}
|
||||
|
||||
+120
-1
@@ -9,6 +9,8 @@ import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseRehearsalExecutor
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseRuleParser
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseSpecGesturePolicy
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseSwipeOutcome
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseSwipeResult
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseUiDriver
|
||||
import cn.ilapage.goauto.agent.automation.RuleValidationException
|
||||
import cn.ilapage.goauto.agent.automation.SnapshotNode
|
||||
@@ -766,11 +768,118 @@ class PurchaseRehearsalExecutorTest {
|
||||
@Test
|
||||
fun `open spec panel skips required follow-up swipe only for confirmed non-scrollable panel`() {
|
||||
val driver = FakePurchaseDriver(nonScrollablePanel = true, purchaseSwipeSucceeds = false)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val diagnostics = mutableListOf<String>()
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}, panelDiagnostic = diagnostics::add)
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertEquals(0, driver.swipeCount)
|
||||
assertTrue(diagnostics.any { it.contains("skipped_confirmed_panel;panel=NON_SCROLLABLE_CONFIRMATION;scrollables=0;reveal=true") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `open spec reveal skips missing scroll target regardless of panel classification`() {
|
||||
val driver = FakePurchaseDriver(
|
||||
panelOverride = SpecPanelFixtures.liveShapedSheet(),
|
||||
purchaseSwipeSucceeds = false,
|
||||
)
|
||||
var probes = 0
|
||||
val diagnostics = mutableListOf<String>()
|
||||
val outcome = PurchaseRehearsalExecutor(
|
||||
driver, { driver.browser = true; true }, { probes++; "{}" },
|
||||
pause = {}, panelDiagnostic = diagnostics::add,
|
||||
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals(outcome.message, "spec_probe_completed", outcome.resultType)
|
||||
assertEquals(1, probes)
|
||||
assertEquals(0, driver.swipeCount)
|
||||
assertTrue(diagnostics.any { it.contains("panel=REQUIRED_EVIDENCE;scrollables=0;reveal=true") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `open spec reveal with a scroll target still fails on gesture failure`() {
|
||||
val driver = FakePurchaseDriver(purchaseSwipeSucceeds = false)
|
||||
var probes = 0
|
||||
val diagnostics = mutableListOf<String>()
|
||||
val outcome = PurchaseRehearsalExecutor(
|
||||
driver, { driver.browser = true; true }, { probes++; "{}" },
|
||||
pause = {}, panelDiagnostic = diagnostics::add,
|
||||
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
|
||||
assertEquals(0, probes)
|
||||
assertEquals(1, driver.swipeCount)
|
||||
assertTrue(outcome.message.contains("panel=NORMAL_SCROLLABLE;scrollables=1;reveal=true;reason=failed"))
|
||||
assertTrue(diagnostics.any { it.startsWith("ruleSwipe;outcome=failed;") })
|
||||
assertFalse(outcome.message.contains("黑色"))
|
||||
assertFalse(outcome.message.contains(input().goodsId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `open spec reveal does not skip a missing accessibility root`() {
|
||||
val driver = FakePurchaseDriver(revealSwipeOutcomes = mutableListOf(
|
||||
PurchaseSwipeOutcome(PurchaseSwipeResult.ROOT_UNAVAILABLE, 0),
|
||||
))
|
||||
var probes = 0
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {})
|
||||
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
|
||||
assertEquals(0, probes)
|
||||
assertTrue(outcome.message.contains("scrollables=0;reveal=true;reason=root_unavailable"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `open spec reveal stops remaining swipes after target disappears`() {
|
||||
val driver = FakePurchaseDriver(revealSwipeOutcomes = mutableListOf(
|
||||
PurchaseSwipeOutcome(PurchaseSwipeResult.SUCCESS, 1),
|
||||
PurchaseSwipeOutcome(PurchaseSwipeResult.NO_SCROLLABLE_TARGET, 0),
|
||||
))
|
||||
val pauses = mutableListOf<Long>()
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
|
||||
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("spec_probe_completed", outcome.resultType)
|
||||
assertEquals(1, driver.swipeCount)
|
||||
assertEquals(1, pauses.count { it == 1000L })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scrollable spec reveal preserves configured swipe count and interval`() {
|
||||
val driver = FakePurchaseDriver()
|
||||
val pauses = mutableListOf<Long>()
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
|
||||
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("spec_probe_completed", outcome.resultType)
|
||||
assertEquals(2, driver.swipeCount)
|
||||
assertEquals(1, pauses.count { it == 1000L })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing scroll target outside spec reveal remains a mandatory action failure`() {
|
||||
val driver = FakePurchaseDriver(purchaseSwipeSucceeds = false)
|
||||
val configured = rule().replace(
|
||||
"\"waitAfterMs\":700}",
|
||||
"\"waitAfterMs\":700,\"swipeAfter\":{\"direction\":\"up\",\"count\":1,\"durationMs\":500,\"intervalMs\":0}}",
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = {})
|
||||
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(configured), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
|
||||
assertTrue(outcome.message.contains("scrollables=0;reveal=false"))
|
||||
assertFalse(driver.panel)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `skipping a reveal does not skip a failed specification probe`() {
|
||||
val driver = FakePurchaseDriver(panelOverride = SpecPanelFixtures.liveShapedSheet())
|
||||
var probes = 0
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; null }, pause = {})
|
||||
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_NOT_MATCHED", outcome.errorCode)
|
||||
assertEquals(1, probes)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1249,6 +1358,8 @@ class PurchaseRehearsalExecutorTest {
|
||||
private val nonScrollablePanel: Boolean = false,
|
||||
private val unrecognizedPanel: Boolean = false,
|
||||
private val purchaseSwipeSucceeds: Boolean = true,
|
||||
private val panelOverride: UiSnapshot? = null,
|
||||
private val revealSwipeOutcomes: MutableList<PurchaseSwipeOutcome> = mutableListOf(),
|
||||
initiallyInAgent: Boolean = false,
|
||||
private val panelBecomesUnknownAfterSizeProof: Boolean = false,
|
||||
) : PurchaseUiDriver {
|
||||
@@ -1351,6 +1462,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
if (includeReviewEntry) nodes += node("review", "商品评价", 20, 1200, 900, 1300, clickable = true)
|
||||
return UiSnapshot(PDD, ACTIVITY, nodes)
|
||||
}
|
||||
panelOverride?.let { return it }
|
||||
if (unrecognizedPanel) {
|
||||
return UiSnapshot(PDD, ACTIVITY, listOf(
|
||||
node("content", "", 0, 0, 1080, 2200),
|
||||
@@ -1532,6 +1644,13 @@ class PurchaseRehearsalExecutorTest {
|
||||
return purchaseSwipeSucceeds
|
||||
}
|
||||
|
||||
override fun swipePurchaseWithEvidence(direction: SwipeDirection, durationMs: Long): PurchaseSwipeOutcome {
|
||||
if (revealSwipeOutcomes.isEmpty()) return super.swipePurchaseWithEvidence(direction, durationMs)
|
||||
val outcome = revealSwipeOutcomes.removeAt(0)
|
||||
if (outcome.result == PurchaseSwipeResult.SUCCESS) swipePurchase(direction, durationMs)
|
||||
return outcome
|
||||
}
|
||||
|
||||
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
|
||||
swipeInPaths += target.path
|
||||
if (restoreHiddenColorOnDownSwipe && quantity == 2L && direction == SwipeDirection.DOWN) {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Architecture-and-Code-Map
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
|
||||
wiki_revision: 4bf8482e49509812b41ee678f40cf3c7c0cf5474
|
||||
synchronized_at: 2026-09-29T08:16:37Z
|
||||
wiki_revision: 04a4bcf81f2f2912d101d1aa1c2d8eb802c5719e
|
||||
synchronized_at: 2026-09-30T01:50:27Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -137,6 +137,7 @@ Android Portal/Agent
|
||||
| 虾皮商品档案增量迁移 | `server/cmd/migrate/migration/version-local/1786700600000_shopee_product_archive.go` |
|
||||
| SYB 商品明细导入、解析与虾皮档案合并 | `server/app/goauto/sybimport/`(解析、幂等落库、同步编排、导入端点、管理端 API 和 Admin 页面均已实现) |
|
||||
| 顺云宝(SYB)ERP HTTP 客户端与登录会话 | `server/app/goauto/sybclient/`(登录、OCR 验证码、会话缓存、列表与明细读取;见 [SYB-ERP-Interface-Contract](SYB-ERP-Interface-Contract)) |
|
||||
| SYB 来源货运单创建时间(#352,待迁移与发布) | `syb_product.syb_stock_created_at` 为可空且带索引的 `t_stock.created`,由 `sybimport/sync.go` 从列表取值,经 `apply.go` 写入;`service.go` 的日期筛选使用该字段。迁移 `server/cmd/migrate/migration/version-local/1789810000000_syb_stock_created_at.go` 仅加列和索引,不改历史行;历史由有界 SYB 重同步补齐。 |
|
||||
| SYB 商品明细增量迁移 | `server/cmd/migrate/migration/version-local/1786700700000_syb_product_import.go` |
|
||||
| SYB 店铺准入、发现与过滤 | `server/app/goauto/sybshop/`、`server/app/goauto/sybimport/`;迁移 `server/cmd/migrate/migration/version-local/1786700900000_syb_shop.go` |
|
||||
| SYB 后台导入任务、进度、单任务互斥与启动恢复 | `server/app/goauto/sybimport/sync_run.go`、`sync_run_handler.go`;表 `syb_sync_run`,迁移 `server/cmd/migrate/migration/version-local/1786701000000_syb_sync_run.go` |
|
||||
@@ -365,6 +366,8 @@ PddProductDetailCollector
|
||||
|
||||
## 采购规格面板预滑动兼容(#238)
|
||||
|
||||
> 历史分支版本说明:本节绑定的 `58a6c1c` 不在本次核验的 main `7a2c2d1` 祖先中,不能据此宣称当前 main 或已安装 APK 已取消全部预滑动。#348 的窄范围实现与版本边界见本页对应补充。
|
||||
|
||||
代码基线 `58a6c1c`,Android 0.9.60 / versionCode 73(构建完成不等同于已安装/发布)。`PurchaseRehearsalExecutor.applyPostAction` 对 `openSpecPanel.swipeAfter` 只兼容解析、不执行机械预滑动,`waitAfterMs` 保留;首趟继续原 `probeSpecs` 遍历,第二趟继续原 `selectSpec` 精确查找与容器内有界滚动。其他动作的后置滑动仍沿用既有执行语义,失败不会被统一忽略。
|
||||
|
||||
`GoAutoAccessibilityService.swipePurchase` 的失败分类由 `PurchaseSwipeFailureReason` 枚举提供;共享 `swipeNode` 仅增加可选分类回调,不改变手势目标、轨迹、1500ms 回调等待或其他调用者行为。`GoAutoPurchasePanel` 日志经 `AgentForegroundService` 关联 task、attempt、device 与规则快照哈希,新增预滑动跳过/必需滑动失败标量;不记录节点文字、坐标、原始控件树、截图或凭据。
|
||||
@@ -587,3 +590,12 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
- 列表:`sybimport/service.go List` 的 `purchaseType`(`pdd` 默认 / `excluded` / `all`),与 `processStage`、`createdFrom/To` 为 AND。
|
||||
- 重算:`sybproductfilter/recompute.go`——`recomputeChanges` 规划(只读所需列);`recomputeFingerprint` 对排序后的(id、方向、规则 ID、类型、关键词)JSON 做 sha256;`writeRecomputeChanges` 逐行 `FOR UPDATE` 锁 `syb_product`,并以 `FOR SHARE` 加锁读复查 `purchase_task` / `return_match`(MySQL REPEATABLE-READ 下普通读看不到快照后的提交)。`recompute_mysql_integration_test.go` 在设置 `GOAUTO_IT_MYSQL_DSN` 时于临时库做双连接并发验证。
|
||||
- Web:`syb-products/index.vue`(采购类型筛选、阶段自动切换、规则显示、按钮计数)、`syb-product-filters/index.vue`(命中/标记口径、重算预览与执行)、`syb-sync-runs/index.vue`(「其中本次规则命中 N 条」)。
|
||||
|
||||
## 采购规格面板揭示滑动无目标兼容(#348)
|
||||
|
||||
实现绑定 `6550b9f`,Android 0.9.64 / versionCode 77;基于 main `7a2c2d1`,当前仅分支实现,未合并、未发布、未安装真机。
|
||||
|
||||
- `PurchaseUiDriver.swipePurchaseWithEvidence` 返回 `PurchaseSwipeOutcome`(结果枚举与可见可滚动节点数)。生产 `GoAutoAccessibilityService` 从同一个实时无障碍 root 统计并定位目标;SUCCESS、NO_SCROLLABLE_TARGET、ROOT_UNAVAILABLE、FAILED 明确区分。原 Boolean swipePurchase 返回语义、目标选择与 swipeNode 不变,不修改采集器。
|
||||
- `PurchaseRehearsalExecutor.applyPostAction` 仅在 openSpecPanel 的揭示滑动遇到 NO_SCROLLABLE_TARGET 时跳过剩余次数;保留 NON_SCROLLABLE_CONFIRMATION 原例外。其他动作仍走原 Boolean 必需滑动路径;root 不可用或有目标而手势失败仍 RULE_ACTION_FAILED。
|
||||
- 失败/跳过通过既有 panelDiagnostic 回调写 GoAutoPurchasePanel 结构日志,仅含面板枚举、scrollables 计数、reveal 布尔与固定结果原因;失败摘要带同样标量并沿原任务结果提交。没有节点文字、坐标、树或截图。
|
||||
- probeSpecs、selectSpec、最终复核、规则 JSON、能力、服务端、Admin、数据库、任务租约及下单/支付边界不变。无滑动不代表规格探测成功。
|
||||
|
||||
@@ -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: c0b1a842c79b4d1dba4c4435df106349e65e5eb1
|
||||
synchronized_at: 2026-09-29T08:16:48Z
|
||||
wiki_revision: 76037bfdf5e986c478055e7338c21ee1ba561bad
|
||||
synchronized_at: 2026-09-30T01:50:31Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -57,6 +57,10 @@ synchronized_at: 2026-09-22T02:56:27Z
|
||||
|
||||
## SYB 商品明细
|
||||
|
||||
### 来源创建时间(#352 目标契约,待迁移与发布)
|
||||
|
||||
Admin 的「创建时间」范围按 SYB 货运单列表的 `t_stock.created`(UTC+8 本地日期)筛选关联商品明细,起止日均包含;这不是商品明细独立创建时间。同步新增或重同步读取列表 `created` 并存入 `syb_product.syb_stock_created_at`;`syb_product.created_at` 保留 GoAuto 首次入库审计时间。来源值缺失或格式异常时明确报错,不能用订单号或入库时间推断。历史空值不命中日期筛选;迁移后按不超过 31 天且受货运单数量上限约束的区间重同步补齐,先后统计空值数量,失败区间可幂等重跑,仍无法取得来源值的记录保持空值并报告。
|
||||
|
||||
- 一行对应 SYB 一条明确的商品/颜色/尺码/数量明细;唯一键为「蝦皮订单号 `code` + 来源明细 `id`」组合,不是全局唯一 ID(未在多货运单样本中验证过全局唯一性)。
|
||||
- `productSpec` 自由文本解析:按最后一个逗号切分为颜色/尺码 → 剥离【】备注 → 判断残留分隔符;解析状态分 `success`/`uncertain`/`failed`,永不猜测原文中不存在的颜色或尺码。
|
||||
|
||||
@@ -489,6 +493,8 @@ synchronized_at: 2026-09-22T02:56:27Z
|
||||
|
||||
## 打开采购规格面板后按需滚动(#238)
|
||||
|
||||
> 历史分支版本说明:本节绑定的 `58a6c1c` 不在本次核验的 main `7a2c2d1` 祖先中,不能据此宣称当前 main 或已安装 APK 已取消全部预滑动。#348 的窄范围实现与版本边界见本页对应补充。
|
||||
|
||||
- Android 0.9.60 / versionCode 73,代码 `58a6c1c` 起,规则 `openSpecPanel.swipeAfter` 保留格式校验与旧快照兼容,但不执行打开面板后的固定次数预滑动;不以“必须滑两次成功”作为进入规格探测/选择的条件。动作后的 `waitAfterMs` 仍生效。
|
||||
- 首趟规格探测和第二趟精确选择仍使用各自既有的按需横向/纵向、有界与稳定终止策略。取消预滑动不等于不探测隐藏规格,也不等于只看首屏。目标不存在、歧义、页面证据不足或必要的有界查找失败时仍明确失败。
|
||||
- 此调整覆盖所有已经安全识别打开的面板,不再仅特判 NON_SCROLLABLE_CONFIRMATION;不弱化面板验证、精确选中、地址、价格、任务租约、创建订单边界或禁止支付规则。
|
||||
@@ -760,3 +766,9 @@ SYB 回填与资料完整性独立:正式SYB任务已有单号且仍关联有
|
||||
- SYB 商品页新增「采购类型」筛选:需 PDD 采购(默认、重置后同)/ 无需 PDD 采购 / 全部,与处理阶段、创建时间等筛选同时生效;选择「退货待确认」「已用退货」「无需采购」阶段时自动切到「全部」,可手动改回。无需采购行显示命中规则,可勾选但只计入「匹配退货」,不显示单条「采购」。
|
||||
- 管理员「按当前规则重算」:先预览(两个方向的变更数、因已有采购任务 / 有效退货匹配跳过的数量、最多 20 条样例)再确认执行。执行必须携带预览指纹,数据、规则或其证据变化时拒绝并提示重新预览,不静默执行另一批。执行时逐行加锁并以加锁读复查:该商品有任何采购任务或有效退货匹配则跳过;审计日志与返回数量按实际写入/跳过计。规则变更从不自动重算。
|
||||
- 统计口径:同步记录与规则列表的「命中」是本次同步命中规则的次数(含已存在明细);「当前带此规则标记的明细 N 条」按数据库实际统计。两者不得混用。
|
||||
|
||||
## 采购规格面板揭示滑动无目标兼容(#348)
|
||||
|
||||
Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装/发布)。已安全打开规格面板后,规则 openSpecPanel.swipeAfter 的某次揭示滑动若没有任何可见可滚动目标,则无需滑动,跳过本动作剩余次数并记录诊断;不再因缺少滚动节点而失败。保留 NON_SCROLLABLE_CONFIRMATION 原跳过行为。
|
||||
|
||||
有可滚动目标却派发失败、被取消或等待失败,以及无障碍 root 不可用,仍明确失败;其他动作的必需滑动不放宽。有可滚动目标时保留规则 count、direction、durationMs、intervalMs 与 waitAfterMs。后续规格探测、精确选择与最终复核全部照常,缺规格或探测无结果仍失败,不猜测、不新增点击目标或支付。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Android-Agent-API-Contract
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
|
||||
wiki_revision: 92e9f8568c9c0da1d78159391c1545f09c4cec14
|
||||
synchronized_at: 2026-09-29T08:17:45Z
|
||||
wiki_revision: f92513e7184fe704d2b9d1c6606b16a86d8d3fb8
|
||||
synchronized_at: 2026-09-30T01:58:59Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -89,6 +89,7 @@ PATCH /api/admin/v1/syb-products/{productId}/correction
|
||||
|
||||
列表返回结构化字段(`orderCode`、`shopeeItemId`、`productTitle`、`targetColor`、`targetSize`、`quantity`、`unitPriceCent`、`imageUrl`、`parseStatus`、`parseNote`、`manuallyConfirmed`,#340 起另有 `pddPurchaseExcluded`、`excludedRuleId`、`excludedRuleKind`、`excludedRuleKeyword`、`excludedAt`),不含原始 JSON;`purchaseType`(#340)取 `pdd`(默认)/ `excluded` / `all`,其他值 422,与 `processStage`、`createdFrom`/`createdTo` 同时生效;`keyword` 匹配订单号、虾皮商品ID 或商品标题,`parseStatus` 筛选 `success`/`uncertain`/`failed`。详情额外返回 `rawJson`(原始 `details[]` 元素,未做任何改写)。
|
||||
|
||||
#352 目标契约(待迁移与发布):SYB 商品列表沿用 `createdFrom` / `createdTo` 的 `YYYY-MM-DD` 入参和 Admin「创建时间」标签,但条件改为所属货运单 `t_stock.created` 的 UTC+8 日期,使用左闭右开区间并包含结束日。该条件与店铺、订单号、解析状态、处理阶段、采购类型及分页组合;`sybStockCreatedAt` 是新增可空响应字段,来自 `syb_product.syb_stock_created_at`,不等于 GoAuto 审计字段 `createdAt`。历史来源时间为空者在设置日期条件时不返回,不得回退到 `createdAt`;无日期条件仍可返回。
|
||||
`reparse` 与 `reparse-batch` 只读取已保存的 `rawJson` 重新执行解析规则,不请求 SYB 接口;请求体 `force` 为 `true` 时才覆盖 `manuallyConfirmed` 的行,默认跳过并在批量结果中标记 `skipped_manual`。批量结果逐条返回 `outcome`(`reparsed` / `skipped_manual` / `unchanged`)与解析状态变化,`ids` 中任何一个不存在都会使整个请求返回错误(与虾皮商品批量删除的"部分成功"语义不同:ID 不存在通常是操作员选错了页面)。
|
||||
|
||||
`correction` 提交 `targetColor`、`targetSize`,覆盖解析结果并标记 `manuallyConfirmed=true`;非空的人工修正与解析成功同等可信,会合并进已关联的虾皮商品档案。
|
||||
@@ -1179,6 +1180,8 @@ Agent 携带既有 Token(可已失效)及恢复码重新调用注册接口
|
||||
|
||||
### openSpecPanel 后置滑动兼容与诊断(#238)
|
||||
|
||||
> 历史分支版本说明:本节绑定的 `58a6c1c` 不在本次核验的 main `7a2c2d1` 祖先中,不能据此宣称当前 main 或已安装 APK 已取消全部预滑动。#348 的窄范围实现与版本边界见本页对应补充。
|
||||
|
||||
版本边界:Android 0.9.60 / versionCode 73,代码 `58a6c1c`。不修改 JSON schema、能力标识、任务接口或已有快照哈希。`openSpecPanel.swipeAfter` 仍按 direction/count/durationMs/intervalMs 原约束校验;解析成功后不执行该准备性滑动,`waitAfterMs` 保留。其他动作后置滑动沿用旧执行语义。旧 Server 可继续下发原快照;旧 APK 仍按原策略执行,不能将本契约描述当作旧设备已获得兼容。
|
||||
|
||||
规格探测与精确选择自行负责按需有界滚动,原始候选、精确点击和选中复核不变。跳过预滑动不作为规格探测成功或订单创建证据。
|
||||
@@ -1438,3 +1441,11 @@ Web 批量入口冻结原匹配 ID 并顺序调用;HTTP 409 状态冲突跳过
|
||||
- batch-delete 在既有阻塞状态基础上增加 matching,409 整批拒绝;避免删除正在匹配的记录。Android/采购/付款流程无变化。
|
||||
|
||||
实现绑定:`fix/350-inner-code-matching`,源码提交 `9c5e20e`;未合并 main、未部署,正式环境仍需发布后启用。
|
||||
|
||||
### openSpecPanel 无滚动目标兼容与诊断(#348)
|
||||
|
||||
版本绑定 `6550b9f`,Android 0.9.64 / versionCode 77(分支实现,未合并、安装或发布)。本次没有 JSON schema、动作参数、能力标识、任务接口或规则快照哈希变化。旧 Server 继续下发相同快照;旧 APK 不因本文更新而改变行为。
|
||||
|
||||
仅 openSpecPanel.swipeAfter 的 NO_SCROLLABLE_TARGET 可跳过剩余揭示滑动;NON_SCROLLABLE_CONFIRMATION 原例外保持。存在目标而手势失败、窗口 root 不可用,以及其他动作的必需滑动失败,仍返回 RULE_ACTION_FAILED;后续 probeSpecs/selectSpec/最终复核不跳过。
|
||||
|
||||
既有本地 GoAutoPurchasePanel 回调增加 ruleSwipe 事件:outcome=skipped_confirmed_panel|skipped_no_scroll_target|failed;panel 为面板枚举,scrollables 为非负计数,reveal 为布尔,失败/无目标 reason=success|no_scrollable_target|root_unavailable|failed 中实际适用的固定值。失败 error message 增加这些标量,原 task/attempt/device/规则快照绑定与结果提交字段不变。不记录节点文本、规格、地址、订单、坐标、树、整屏截图或凭据。
|
||||
|
||||
@@ -597,6 +597,8 @@ type SYBProduct struct {
|
||||
ExcludedRuleKind string `json:"excludedRuleKind,omitempty" gorm:"size:16;not null;default:''"`
|
||||
ExcludedRuleKeyword string `json:"excludedRuleKeyword,omitempty" gorm:"size:200;not null;default:''"`
|
||||
ExcludedAt *time.Time `json:"excludedAt,omitempty"`
|
||||
// SYBStockCreatedAt is t_stock.created, not GoAuto's first-import CreatedAt.
|
||||
SYBStockCreatedAt *time.Time `json:"sybStockCreatedAt,omitempty" gorm:"index"`
|
||||
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
|
||||
@@ -22,9 +22,10 @@ import (
|
||||
// exist in this repo yet — see the package doc), so callers decode SYB's
|
||||
// response themselves and hand in only what a detail line requires.
|
||||
type OrderInput struct {
|
||||
Code string // SYB stock/list `code`, e.g. 260728TB95MJTQ
|
||||
StockID uint64 // SYB stock/list internal `id`, e.g. 75104587
|
||||
ShopName string
|
||||
Code string // SYB stock/list `code`, e.g. 260728TB95MJTQ
|
||||
StockID uint64 // SYB stock/list internal `id`, e.g. 75104587
|
||||
ShopName string
|
||||
SYBStockCreatedAt *time.Time // SYB stock/list `created`; nil for legacy/manual test inputs
|
||||
}
|
||||
|
||||
// DetailInput mirrors one element of SYB's `details[]` array from
|
||||
@@ -137,7 +138,8 @@ func ApplyDetail(ctx context.Context, db *gorm.DB, order OrderInput, detail Deta
|
||||
|
||||
record := models.SYBProduct{
|
||||
OrderCode: order.Code, DetailID: detail.ID, StockID: order.StockID, ShopName: order.ShopName,
|
||||
ShopeeItemID: shopeeItemID, ProductTitle: detail.ProductTitle,
|
||||
SYBStockCreatedAt: order.SYBStockCreatedAt,
|
||||
ShopeeItemID: shopeeItemID, ProductTitle: detail.ProductTitle,
|
||||
TargetColor: parsed.Color, TargetSize: parsed.Size,
|
||||
Quantity: detail.ProductQty, UnitPriceCent: roundCents(detail.ProductPrice),
|
||||
ImageURL: buildImageURL(detail.ProductThumb),
|
||||
@@ -191,6 +193,11 @@ func ApplyDetail(ctx context.Context, db *gorm.DB, order OrderInput, detail Deta
|
||||
"quantity": record.Quantity, "unit_price_cent": record.UnitPriceCent, "image_url": record.ImageURL,
|
||||
"parse_status": record.ParseStatus, "parse_note": record.ParseNote, "raw_json": record.RawJSON,
|
||||
}
|
||||
if order.SYBStockCreatedAt != nil {
|
||||
updates["syb_stock_created_at"] = *order.SYBStockCreatedAt
|
||||
} else {
|
||||
record.SYBStockCreatedAt = existing.SYBStockCreatedAt
|
||||
}
|
||||
// An identical re-import keeps a valid AI decision. Changed source,
|
||||
// link, or a newly deterministic parse invalidates it atomically.
|
||||
preserveAI := existing.AIConfirmed && !existing.ManuallyConfirmed && parsed.Status != models.SYBParseStatusSuccess &&
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/migrations"
|
||||
"go-admin/app/goauto/models"
|
||||
@@ -128,11 +129,15 @@ func TestApplyDetailKeepsMultiUnitQuantityAsOneRow(t *testing.T) {
|
||||
func TestApplyDetailIsIdempotentOnOrderCodeAndDetailID(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
order, detail := realOrder(), realDetailA()
|
||||
firstSource := time.Date(2026, 9, 27, 23, 59, 59, 0, time.FixedZone("SYB", 8*3600))
|
||||
order.SYBStockCreatedAt = &firstSource
|
||||
first, err := sybimport.ApplyDetail(context.Background(), db, order, detail)
|
||||
if err != nil {
|
||||
t.Fatalf("first apply: %v", err)
|
||||
}
|
||||
detail.ProductQty = 2 // simulate the source value changing between imports
|
||||
newSource := firstSource.Add(time.Second)
|
||||
order.SYBStockCreatedAt = &newSource
|
||||
second, err := sybimport.ApplyDetail(context.Background(), db, order, detail)
|
||||
if err != nil {
|
||||
t.Fatalf("second apply: %v", err)
|
||||
@@ -143,6 +148,13 @@ func TestApplyDetailIsIdempotentOnOrderCodeAndDetailID(t *testing.T) {
|
||||
if second.SYBProduct.ID != first.SYBProduct.ID {
|
||||
t.Fatal("re-import must update the same row, not create a new one")
|
||||
}
|
||||
var stored models.SYBProduct
|
||||
if err := db.First(&stored, first.SYBProduct.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.SYBStockCreatedAt == nil || !stored.SYBStockCreatedAt.Equal(newSource) || !stored.CreatedAt.Equal(first.SYBProduct.CreatedAt) {
|
||||
t.Fatalf("re-import must update source time, preserve GoAuto creation: %+v", stored)
|
||||
}
|
||||
var count int64
|
||||
db.Model(&models.SYBProduct{}).Where("order_code = ? AND detail_id = ?", order.Code, detail.ID).Count(&count)
|
||||
if count != 1 {
|
||||
|
||||
@@ -90,10 +90,10 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp
|
||||
return ListResponse{}, err
|
||||
}
|
||||
if createdFrom != nil {
|
||||
query = query.Where("created_at >= ?", *createdFrom)
|
||||
query = query.Where("syb_stock_created_at >= ?", *createdFrom)
|
||||
}
|
||||
if createdTo != nil {
|
||||
query = query.Where("created_at < ?", *createdTo)
|
||||
query = query.Where("syb_stock_created_at < ?", *createdTo)
|
||||
}
|
||||
if request.ShopName = strings.TrimSpace(request.ShopName); request.ShopName != "" {
|
||||
if len([]rune(request.ShopName)) > 255 {
|
||||
@@ -179,8 +179,8 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp
|
||||
}
|
||||
|
||||
// createdAtRange turns inclusive YYYY-MM-DD bounds into a half-open time
|
||||
// range. The bounds are interpreted in the server's local timezone, matching
|
||||
// the timestamps written by GORM for this service.
|
||||
// range. SYB stock.created is an Asia/Shanghai wall-clock timestamp; use that
|
||||
// timezone explicitly rather than depending on the server process timezone.
|
||||
func createdAtRange(from, to string) (*time.Time, *time.Time, error) {
|
||||
from = strings.TrimSpace(from)
|
||||
to = strings.TrimSpace(to)
|
||||
@@ -191,7 +191,7 @@ func createdAtRange(from, to string) (*time.Time, *time.Time, error) {
|
||||
if value == "" {
|
||||
return nil, nil
|
||||
}
|
||||
parsed, err := time.ParseInLocation("2006-01-02", value, time.Local)
|
||||
parsed, err := time.ParseInLocation("2006-01-02", value, sybStockLocation)
|
||||
if err != nil {
|
||||
return nil, invalidRequest(label + " 必须是 YYYY-MM-DD")
|
||||
}
|
||||
|
||||
@@ -127,39 +127,58 @@ func TestServiceListCapsPageSizeAt500(t *testing.T) {
|
||||
func TestServiceListFiltersByCreatedDateInclusive(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
order := realOrder()
|
||||
for i, day := range []string{"2026-09-01", "2026-09-02", "2026-09-03"} {
|
||||
for i, stamp := range []string{"2026-09-27 23:59:59", "2026-09-28 00:00:00", "2026-09-29 23:59:59", "2026-09-30 00:00:00"} {
|
||||
rowOrder := order
|
||||
rowOrder.Code = fmt.Sprintf("CREATED-%d", i)
|
||||
rowOrder.StockID += uint64(i)
|
||||
sourceCreated, err := time.ParseInLocation("2006-01-02 15:04:05", stamp, time.FixedZone("SYB", 8*60*60))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rowOrder.SYBStockCreatedAt = &sourceCreated
|
||||
detail := realDetailA()
|
||||
detail.ID += uint64(i)
|
||||
result, err := sybimport.ApplyDetail(context.Background(), db, rowOrder, detail)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
created, err := time.ParseInLocation("2006-01-02", day, time.Local)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", result.SYBProduct.ID).Update("created_at", created).Error; err != nil {
|
||||
// Import time must not determine whether the row matches source dates.
|
||||
imported := time.Date(2026, 9, 28, 0, 5, 0, 0, time.FixedZone("SYB", 8*60*60))
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", result.SYBProduct.ID).Update("created_at", imported).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
missing := realOrder()
|
||||
missing.Code = "CREATED-MISSING"
|
||||
missing.StockID += 100
|
||||
missingDetail := realDetailA()
|
||||
missingDetail.ID += 100
|
||||
if _, err := sybimport.ApplyDetail(context.Background(), db, missing, missingDetail); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := sybimport.NewService(db)
|
||||
|
||||
between, err := service.List(context.Background(), sybimport.ListRequest{CreatedFrom: "2026-09-01", CreatedTo: "2026-09-02"})
|
||||
between, err := service.List(context.Background(), sybimport.ListRequest{CreatedFrom: "2026-09-28", CreatedTo: "2026-09-29"})
|
||||
if err != nil || between.Total != 2 {
|
||||
t.Fatalf("inclusive created date range should return two rows, total=%d err=%v", between.Total, err)
|
||||
}
|
||||
fromOnly, err := service.List(context.Background(), sybimport.ListRequest{CreatedFrom: "2026-09-03"})
|
||||
combined, err := service.List(context.Background(), sybimport.ListRequest{
|
||||
CreatedFrom: "2026-09-28", CreatedTo: "2026-09-29", ShopName: order.ShopName,
|
||||
OrderCodes: []string{"CREATED-1", "CREATED-3"}, ParseStatus: models.SYBParseStatusSuccess,
|
||||
Page: 1, PageSize: 1,
|
||||
})
|
||||
if err != nil || combined.Total != 1 || len(combined.Items) != 1 || combined.Items[0].OrderCode != "CREATED-1" {
|
||||
t.Fatalf("date and existing filters must compose before pagination: %+v err=%v", combined, err)
|
||||
}
|
||||
fromOnly, err := service.List(context.Background(), sybimport.ListRequest{CreatedFrom: "2026-09-30"})
|
||||
if err != nil || fromOnly.Total != 1 {
|
||||
t.Fatalf("created-from filter should return one row, total=%d err=%v", fromOnly.Total, err)
|
||||
}
|
||||
toOnly, err := service.List(context.Background(), sybimport.ListRequest{CreatedTo: "2026-09-01"})
|
||||
toOnly, err := service.List(context.Background(), sybimport.ListRequest{CreatedTo: "2026-09-27"})
|
||||
if err != nil || toOnly.Total != 1 {
|
||||
t.Fatalf("created-to filter should return one row, total=%d err=%v", toOnly.Total, err)
|
||||
}
|
||||
if _, err := service.List(context.Background(), sybimport.ListRequest{CreatedFrom: "2026-09-04", CreatedTo: "2026-09-01"}); serviceErrCode(t, err) != sybimport.CodeInvalidRequest {
|
||||
if _, err := service.List(context.Background(), sybimport.ListRequest{CreatedFrom: "2026-09-30", CreatedTo: "2026-09-27"}); serviceErrCode(t, err) != sybimport.CodeInvalidRequest {
|
||||
t.Fatalf("reversed created date range should be rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,22 @@ const (
|
||||
maxSyncDays = 31
|
||||
)
|
||||
|
||||
var sybStockLocation = time.FixedZone("SYB UTC+8", 8*60*60)
|
||||
|
||||
// parseSYBStockCreatedAt reads the same t_stock.created value used by SYB's
|
||||
// date filter. Never derive it from the order code or local import timestamp.
|
||||
func parseSYBStockCreatedAt(raw map[string]any) (*time.Time, error) {
|
||||
value, ok := raw["created"].(string)
|
||||
if !ok || strings.TrimSpace(value) == "" {
|
||||
return nil, errors.New("SYB 货运单缺少来源创建时间")
|
||||
}
|
||||
created, err := time.ParseInLocation("2006-01-02 15:04:05", strings.TrimSpace(value), sybStockLocation)
|
||||
if err != nil || created.IsZero() {
|
||||
return nil, errors.New("SYB 货运单来源创建时间格式无效")
|
||||
}
|
||||
return &created, nil
|
||||
}
|
||||
|
||||
// SyncReport summarises one sync run.
|
||||
type SyncReport struct {
|
||||
From string `json:"from"`
|
||||
@@ -381,14 +397,22 @@ func validateDetailBatch(requested []int64, details []sybclient.StockDetail) err
|
||||
// ApplyDetail call and its own transaction — a several-thousand-row sync must
|
||||
// not sit in one long-held transaction.
|
||||
func applyStockDetail(ctx context.Context, db *gorm.DB, row sybclient.StockRow, detail sybclient.StockDetail, report *SyncReport, sets ...sybproductfilter.RuleSet) error {
|
||||
if row.ID != detail.ID {
|
||||
return fmt.Errorf("货运单列表与明细 ID 不一致: %d/%d", row.ID, detail.ID)
|
||||
}
|
||||
stockCreated, err := parseSYBStockCreatedAt(row.Raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("货运单 %d: %w", row.ID, err)
|
||||
}
|
||||
var filters sybproductfilter.RuleSet
|
||||
if len(sets) > 0 {
|
||||
filters = sets[0]
|
||||
}
|
||||
order := OrderInput{
|
||||
Code: detail.Code,
|
||||
StockID: uint64(detail.ID),
|
||||
ShopName: stringField(row.Raw, "shopName"),
|
||||
Code: detail.Code,
|
||||
StockID: uint64(detail.ID),
|
||||
ShopName: stringField(row.Raw, "shopName"),
|
||||
SYBStockCreatedAt: stockCreated,
|
||||
}
|
||||
if order.Code == "" {
|
||||
order.Code = row.Code
|
||||
|
||||
@@ -109,6 +109,7 @@ func (f *fakeSYB) server(t *testing.T) *httptest.Server {
|
||||
"id": float64(1000 + i),
|
||||
"code": fmt.Sprintf("ORD%s%03d", strings.ReplaceAll(date, "-", ""), i),
|
||||
"shopName": f.shopFor(i),
|
||||
"created": date + " 10:00:00",
|
||||
})
|
||||
}
|
||||
if f.shortPageAtIndex > 0 && start/pageSize+1 == f.shortPageAtIndex && len(rows) > 0 {
|
||||
@@ -118,7 +119,7 @@ func (f *fakeSYB) server(t *testing.T) *httptest.Server {
|
||||
if ids := f.pageIDs(date, start, len(f.listCalls)); ids != nil {
|
||||
rows = nil
|
||||
for _, id := range ids {
|
||||
rows = append(rows, map[string]any{"id": id, "code": "TEST", "shopName": f.shopFor(0)})
|
||||
rows = append(rows, map[string]any{"id": id, "code": "TEST", "shopName": f.shopFor(0), "created": date + " 10:00:00"})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -239,6 +240,27 @@ func TestSyncIsIdempotentAcrossRuns(t *testing.T) {
|
||||
if count != 5 {
|
||||
t.Fatalf("重跑不应产生重复行,实际 %d 行", count)
|
||||
}
|
||||
var first models.SYBProduct
|
||||
if err := db.Order("id").First(&first).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.SYBStockCreatedAt == nil || first.SYBStockCreatedAt.In(sybStockLocation).Format("2006-01-02 15:04:05") != "2026-08-01 10:00:00" {
|
||||
t.Fatalf("source timestamp not saved from stock list: %+v", first.SYBStockCreatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSYBStockCreatedAtRequiresSourceProof(t *testing.T) {
|
||||
for _, raw := range []map[string]any{nil, {}, {"created": ""}, {"created": "2026/09/28"}, {"created": 123}} {
|
||||
if _, err := parseSYBStockCreatedAt(raw); err == nil {
|
||||
t.Fatalf("invalid source timestamp accepted: %v", raw)
|
||||
}
|
||||
}
|
||||
for _, stamp := range []string{"2026-09-27 23:59:59", "2026-09-28 00:00:00"} {
|
||||
got, err := parseSYBStockCreatedAt(map[string]any{"created": stamp})
|
||||
if err != nil || got.In(sybStockLocation).Format("2006-01-02 15:04:05") != stamp {
|
||||
t.Fatalf("parse %s: %v %v", stamp, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 超过上限必须在**写库之前**失败,否则会留下一半数据还报错。
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// #352 adds the nullable, indexed SYB source timestamp. Historical rows are
|
||||
// intentionally left NULL until a bounded SYB re-sync supplies source proof.
|
||||
func init() {
|
||||
_, file, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(file), migrateSYBStockCreatedAt)
|
||||
}
|
||||
|
||||
func migrateSYBStockCreatedAt(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if !tx.Migrator().HasColumn(&models.SYBProduct{}, "SYBStockCreatedAt") {
|
||||
if err := tx.Migrator().AddColumn(&models.SYBProduct{}, "SYBStockCreatedAt"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !tx.Migrator().HasIndex(&models.SYBProduct{}, "SYBStockCreatedAt") {
|
||||
if err := tx.Migrator().CreateIndex(&models.SYBProduct{}, "SYBStockCreatedAt"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
common "go-admin/common/models"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestMigrateSYBStockCreatedAtPreservesLegacyRows(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:syb_stock_created_migration?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Exec("CREATE TABLE syb_product (id integer primary key, created_at datetime)").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Exec("INSERT INTO syb_product (id, created_at) VALUES (1, '2026-09-28 00:05:00')").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&common.Migration{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := migrateSYBStockCreatedAt(db, "test_syb_stock_created_at"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !db.Migrator().HasColumn(&models.SYBProduct{}, "SYBStockCreatedAt") || !db.Migrator().HasIndex(&models.SYBProduct{}, "SYBStockCreatedAt") {
|
||||
t.Fatal("migration must add the source time column and index")
|
||||
}
|
||||
var row struct {
|
||||
CreatedAt time.Time
|
||||
SYBStockCreatedAt *string
|
||||
}
|
||||
if err := db.Table("syb_product").Where("id = 1").Take(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if row.CreatedAt.Format("2006-01-02 15:04:05") != "2026-09-28 00:05:00" || row.SYBStockCreatedAt != nil {
|
||||
t.Fatalf("migration must preserve import time and leave source time unknown: %+v", row)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user