Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0dfe8406c4 | ||
|
|
a3c05a751b |
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "cn.ilapage.goauto.agent"
|
||||
minSdk = 23
|
||||
targetSdk = 34
|
||||
versionCode = 85
|
||||
versionName = "0.9.72"
|
||||
versionCode = 86
|
||||
versionName = "0.9.73"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
+3
-1
@@ -797,7 +797,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 +1153,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("收货地址", "修改", "默认地址", "默认")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: ad21f0e3df9c5ec953d7dac437a06a0d082c1df3
|
||||
synchronized_at: 2026-10-13T08:23:11Z
|
||||
wiki_revision: e937060afcf088930d8ec3cda537ef1199265e74
|
||||
synchronized_at: 2026-10-13T10:02:11Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -812,7 +812,7 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
|
||||
|
||||
实现绑定 `d6639a4`;2026-10-05 已合并 main `4567a24`,在线迁移并发布至 122.228.200.167。旧记录保持未获取,随后按既有正常同步范围更新;不新增采购拦截。
|
||||
|
||||
- “SYB状态”仅显示来源取消标记:`isCancel=1` 为“已取消”,`0` 为“未取消”,没有有效证据为“未获取”。“未取消”不等于可采购,不表达其他 SYB 业务状态,不由 status/orderStatus、规格解析或 PDD 排除标记推断。
|
||||
- “SYB状态”仅显示来源取消标记:`isCancel=1/2` 均为“已取消”(#389;仅包含该修复的服务端版本),`0` 为“未取消”,没有有效证据为“未获取”。“未取消”不等于可采购,不表达其他 SYB 业务状态,不由 status/orderStatus、规格解析或 PDD 排除标记推断。
|
||||
- 正常同步成功取得有效值后覆盖原值,允许由取消变回未取消;失败、字段缺失/null、未知值或空页不改变既有取消状态与取得时间。历史记录不默认未取消。
|
||||
- 仅更新同步范围内实际导入的明细;超出同步创建日期范围的旧订单保留上次状态,未经同步的显示未获取。此列不是实时核验,列表加载不会请求 SYB。
|
||||
- 本单严格只做展示:不修改创建、批量、重试、Claim/Start、下单边界、采购资格或任务状态,不自动拦截已取消订单,不影响已存在 PDD 订单或回填事实。后续采购拦截已由 #381 实现(见「SYB 已取消订单不进入 PDD 流程(#381)」)。
|
||||
@@ -1008,7 +1008,7 @@ Android同步诊断不改变#377的心跳→恢复→补传→必要的一次任
|
||||
|
||||
合并 main `c5709a0`,2026-10-12 随 `15bab9c` 发布到线上(Server + Web),待业务验收。
|
||||
|
||||
- `syb_product.syb_is_cancelled = true`(同步取得 `isCancel=1`)的明细:不能创建采购任务(`PURCHASE_SYB_CANCELLED`「SYB 订单已取消,无需采购」),批量采购预览、AI 规格匹配资格、批量采集资格给出同一原因,图搜采集批量逐项拒绝,不参与退货匹配。NULL(未获取)与 false 不拦截,不推断。
|
||||
- `syb_product.syb_is_cancelled = true`(包含 #389 的版本同步取得 `isCancel=1/2`;旧版只识别 1)的明细:不能创建采购任务(`PURCHASE_SYB_CANCELLED`「SYB 订单已取消,无需采购」),批量采购预览、AI 规格匹配资格、批量采集资格给出同一原因,图搜采集批量逐项拒绝,不参与退货匹配。NULL(未获取)与 false 不拦截,不推断。
|
||||
- 处理阶段新增 `syb_cancelled`「已取消」,优先级:已有任务或订单结果 → 退货匹配 → 已取消 → 无需采购(#340)→ 其余;同时被过滤规则标记的行显示「已取消」,创建接口仍拒绝(返回码可能是 `PURCHASE_PDD_EXCLUDED`)。
|
||||
- 同步照常导入并更新取消状态;订单恢复为未取消后阶段与资格自动恢复。
|
||||
- 不拦截已存在的采购任务(Claim/Start、下单边界与状态机不变);已下单的已取消明细需人工售后。
|
||||
@@ -1045,3 +1045,23 @@ Android同步诊断不改变#377的心跳→恢复→补传→必要的一次任
|
||||
- `release_activate`「设置当前 Agent 版本」:把已上传版本设为当前。设为当前后所有设备启动或手动检查时会提示更新,授权页对此显示风险提示。
|
||||
- 不开放 APK 下载给客户端 API。上传沿用 Admin 的 Manifest 解析、SHA-256、大小与 versionCode 唯一校验;创建人记为授权该密钥的账号。每次调用写客户端审计,不记录文件内容。
|
||||
- 设备端安装仍由 Android 系统人工确认;签名与已安装版本不一致的 APK 无法覆盖安装,设为当前前须核对目标设备签名。
|
||||
|
||||
|
||||
## SYB 数值取消标记兼容(#389)
|
||||
|
||||
实现绑定 `5f329d231157c62f92e2c1348e57ae7ca561fef3`,工单分支 `fix/389-syb-cancel-enum`;未合并 main、未发布,线上现有明细尚未因此修正。
|
||||
|
||||
- 来源货运单列表数值 `isCancel`:0「正常」→ false,1「取消」/2「已取消」→ true;同单明细共用该订单级标记。只接受原有 JSON 数字解码,不将其他非零值、字符串、布尔或缺失/null 当作取消。未知值保留已存状态及同步时间。
|
||||
- 按用户 2026-10-13 确认,以 SYB 取消标记为准,即使平台 `orderStatus` 仍是待出货、已发货或运送中,1/2 仍表示本系统的已取消;反之,只有平台“申请取消”文字而数值为 0 时不推断取消。前端枚举及整单取消动作可证明 2 的含义,不推测 1 必定由哪个平台生成。
|
||||
- 已知值随既有同步更新取消状态及取得时间,0 可恢复未取消。#381 的新建/展示期限制沿用,不新增领取/开始/下单边界拦截,不自动停止已有 pending/running 采购任务。日期范围和定时周期不变。
|
||||
- 线上有界重同步须单独授权;此前在受控环境保留受影响明细原值与时间的最小恢复依据,同步后只读报告新增已取消且仍有未完成任务的明细,由用户决定处理。上述生产数据不写入仓库、工单、Wiki 或消息。
|
||||
- 回退代码不会自动恢复已经同步的 true;后续纠正须有来源事实及独立授权,不通过一律映射 false 来掩盖错误。
|
||||
|
||||
## 采购规格面板近正方形滚动区域(#390)
|
||||
|
||||
实现绑定工单分支 `fix/390-panel-scroll-ratio`,Agent `0.9.73 / code86`;未合并、未安装或发布,真机展开顶部地址区域的效果未验证。
|
||||
|
||||
- 仅采购 `PurchaseLiveAutomation.purchasePanelScrollTargets` 的形状条件调整为“高度不小于宽度的 80%”,用于改地址前寻找入口、保存地址后恢复当前面板证据。不是所有纵向滚动容器的通用规则,普通采集器不改。
|
||||
- 继续要求节点可见、启用、可滚动,宽度至少为视口估算宽度的 60%、高度至少为视口估算高度的 20%,且滚动候选必须唯一。扁平横条、尺寸不足及多候选仍明确失败,不选择最大区域兜底。
|
||||
- 向下滑动的方向、距离、时长、最多五轮采样和视口不变停止规则保持原样;两个调用点的原有失败路径均保留。地址编辑/保存、规格、金额、提交按钮与一次性下单检查不改,不涉及付款。
|
||||
- 合成回归与失败现场的只读结构尺寸核对只能证明该几何候选不再被错误排除,不能证明在真实 PDD 页面内滑动一定能展开顶部地址。安装及实际采购须另行授权。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: SYB-ERP-Interface-Contract
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/SYB-ERP-Interface-Contract.-
|
||||
wiki_revision: 5979a14250c96c3c92348a947c2bc8d3a078c9f4
|
||||
synchronized_at: 2026-09-29T08:18:40Z
|
||||
wiki_revision: 3f44b3ed91ff19c592bba4e2dbbef28d971633da
|
||||
synchronized_at: 2026-10-13T10:03:50Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 12 顺云宝(SYB)ERP 接口契约
|
||||
@@ -272,7 +272,7 @@ ID,或某张货运单返回空商品明细,都视为不完整并停止当前
|
||||
| `shopName` | `<店铺名>` | 蝦皮店铺 |
|
||||
| `productName` | `純棉上衣` | **只有一个商品名**,一单多商品时不完整 |
|
||||
| `orderQty` / `detailQty` | `2` / `2` | 商品件数 |
|
||||
| `isCancel` | `0` | 是否取消 |
|
||||
| `isCancel` | `0` / `1` / `2` | 订单级取消标记:0 正常,1 取消,2 已取消。包含 #389 的服务端将 1/2 均映射 true、0 映射 false;未知值保留旧值,不从 status/orderStatus 推断。旧版只识别 0/1。 |
|
||||
| `expCompany` | `蝦皮店到店` | 物流方式 |
|
||||
| `receiver` / `receiverTel` / `receiverAddr` | — | **收件人信息,属个人数据** |
|
||||
|
||||
@@ -709,3 +709,10 @@ POST /am/stock/detail/updateDetailPurchaseCode,无JSON body;query:id=<stoc
|
||||
客户端每次处理最多一次写请求,并拒绝HTTP重定向重发;超时/连接中断/5xx/损坏响应作为结果未知。无论写响应成功或未知,都须按 stockId + detailId 唯一回读目标;相同单号+pdd才确认为成功,不同已有单号或其他非空平台记冲突且不覆盖。写前为空才写;目标缺失/重复/字段类型异常不得猜测选择。未知且未回读确认时持久化unknown,人工补偿前再次回读,不使用自动写重试。
|
||||
|
||||
GoAuto自身通过持久租约串行,但SYB没有CAS证据,无法原子隔离系统外客户端;不能保证任意迟到请求已终止。真实接口写入需另行明确授权;当前验证仅fake/httptest。
|
||||
|
||||
|
||||
### isCancel 枚举核验(#389,2026-10-13)
|
||||
|
||||
来源前端 `/sys/js/app.226509c4.js` 的 `cances` 定义 0「正常」、1「取消」、2「已取消」;`/sys/js/stock.b4a33aea.js` 用该枚举渲染状态,整单取消对选中的货运单 ID 设值 2,恢复设值 0,平台取消是另一项可选动作。此次只读核验,没有调用取消或恢复接口。该事实不构成本系统新增调用这些写接口的授权。
|
||||
|
||||
经用户确认,GoAuto 的取消判断以该数值为准,不受平台订单/物流文字影响;含 #389 的实现 `5f329d231157c62f92e2c1348e57ae7ca561fef3` 尚未合并发布。字段类型、同步日期范围、数据库及下游 API 不变;来源未知值不覆盖既有状态和时间,不能靠重新同步未知值撤销已写入的 true。
|
||||
|
||||
Reference in New Issue
Block a user