Compare commits

...
4 changed files with 148 additions and 37 deletions
+34 -4
View File
@@ -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: 5299f7358c54dfcd20ba2a1389946c11e0d8eedb
synchronized_at: 2026-10-13T03:19:48Z
wiki_revision: e937060afcf088930d8ec3cda537ef1199265e74
synchronized_at: 2026-10-13T10:02:11Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -721,6 +721,16 @@ SYB 回填与资料完整性独立:正式SYB任务已有单号且仍关联有
### 采购手机环境前提
执行采购的 Android 手机必须:PDD App 已登录、微信已登录、PDD 已设置默认收货地址、PDD 默认支付方式为微信支付。规格面板的地址与支付证据依赖该前提;不为支付宝等其他支付方式补充识别别名(有意决定)。条件不满足时 Agent 明确失败,不自动登录、不切换支付方式、不填写地址。
## 采购接受证据齐全的快捷确认面板(#388)
实现 `0d21ca9`,合并 `496bf80`(2026-10-13,Android 0.9.72/85);尚未装机,真机采购效果未验证。
- 背景:地址保存后返回的规格面板可能带「已选」摘要和底部「现在买,仅 ¥…」按钮,被面板解析归为 `QUICK_CONFIRMATION`。采购原先只接受 `NORMAL_SCROLLABLE`、`NON_SCROLLABLE_CONFIRMATION`、`ORDER_CONFIRMATION` 和 `REQUIRED_EVIDENCE`,因此地址已保存成功也会以 `PURCHASE_ADDRESS_SAVE_TIMEOUT`「地址保存后无法返回订单页面」失败(14 份失败快照离线回放证实)。
- 规则:采购侧把 `QUICK_CONFIRMATION` 视为可下单面板,**仅当**同时满足 #331 必需证据:收货地址入口、支付方式入口和唯一可用的数量输入框都在同一规格面板容器内(与 `REQUIRED_EVIDENCE` 同一门槛)。缺任何一项仍不接受。
- 该判断用于地址保存后识别面板、最终确认和点击下单前的按钮定位;保存后直接停在证据完整的快捷确认面板时,会按最终证据直接通过,不再按返回键。
- 不变:采集流程中 `QUICK_CONFIRMATION` 的含义和处理;地址后缀须唯一;下单目标仍只能是面板容器内最底部的可点击整行,含支付方式别名或支付字样即拒绝;规格、单价、数量复核和只点击一次下单均不放宽。
- 诊断:按返回键后仍无法确认时,失败提示末尾附 `[stage=address_save_back;type=…;<必需证据摘要>;saved=0|1;submitTargets=N]`,只含类型、计数和 0/1,不含页面文字。
## 管理端售后角色(#341)
- 管理端固定增加 `after_sales`(显示名“售后”)业务角色。售后账号拥有采购员当前权限,并额外可访问“yeeke 退货包裹”和“yeeke 同步记录”两个模块及其接口。
@@ -802,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)」)。
@@ -998,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、下单边界与状态机不变);已下单的已取消明细需人工售后。
@@ -1035,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 页面内滑动一定能展开顶部地址。安装及实际采购须另行授权。
+10 -3
View File
@@ -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。
+5 -4
View File
@@ -2,8 +2,9 @@ package sybimport
import "encoding/json"
// SYB returns numeric isCancel: 1 cancelled, 0 not cancelled. Do not infer
// anything from status/orderStatus, missing fields or undocumented values.
// 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.
func parseSYBIsCancelled(raw map[string]any) *bool {
var number float64
switch value := raw["isCancel"].(type) {
@@ -18,9 +19,9 @@ func parseSYBIsCancelled(raw map[string]any) *bool {
default:
return nil
}
if number != 0 && number != 1 {
if number != 0 && number != 1 && number != 2 {
return nil
}
cancelled := number == 1
cancelled := number == 1 || number == 2
return &cancelled
}
@@ -3,7 +3,11 @@ package sybimport
import (
"context"
"encoding/json"
"fmt"
"math"
"net/http/httptest"
"reflect"
"strings"
"testing"
"time"
@@ -14,23 +18,42 @@ import (
func TestParseSYBIsCancelled(t *testing.T) {
for _, tc := range []struct{ input, want string }{
{`{"isCancel":1}`, "true"}, {`{"isCancel":0}`, "false"},
{`{"isCancel":1}`, "true"}, {`{"isCancel":2}`, "true"}, {`{"isCancel":0}`, "false"},
{`{"isCancel":0.0}`, "false"}, {`{"isCancel":1.0}`, "true"}, {`{"isCancel":2.0}`, "true"},
{`{"isCancel":2e0}`, "true"},
{`{}`, "null"}, {`{"isCancel":null}`, "null"},
{`{"isCancel":2}`, "null"}, {`{"isCancel":-1}`, "null"},
{`{"isCancel":3}`, "null"}, {`{"isCancel":-1}`, "null"},
{`{"isCancel":0.5}`, "null"}, {`{"isCancel":"1"}`, "null"},
{`{"isCancel":true}`, "null"}, {`{"status":79,"orderStatus":"已取消"}`, "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"},
} {
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 _, 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)
}
}
}
if got := parseSYBIsCancelled(map[string]any{"isCancel": json.Number("1")}); got == nil || !*got {
t.Fatal("json.Number")
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)
}
}
}
@@ -41,15 +64,29 @@ 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
raw string
want *bool
valid bool
}{
{`{}`, nil}, {`{"isCancel":1}`, boolPointer(true)},
{`{"isCancel":null}`, boolPointer(true)}, {`{"isCancel":7}`, boolPointer(true)},
{`{"isCancel":0}`, boolPointer(false)}, {`{}`, boolPointer(false)},
{`{}`, 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},
} {
var values map[string]any
if err := json.Unmarshal([]byte(tc.raw), &values); err != nil {
@@ -59,17 +96,43 @@ 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.Order("id").Find(&records).Error; err != nil {
if err := db.Where("order_code = ?", row.Code).Order("id").Find(&records).Error; err != nil {
t.Fatal(err)
}
if len(records) != 2 {
t.Fatal("must keep both details")
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(&current, existingTask.ID).Error; err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(current, existingTask) {
t.Fatal("cancellation sync must leave the existing purchase task unchanged")
}
}
valid := parseSYBIsCancelled(values) != nil
for _, record := range records {
got, _ := json.Marshal(record.SYBIsCancelled)
want, _ := json.Marshal(tc.want)
@@ -82,15 +145,25 @@ func TestSyncCancellationDisplayAndListContract(t *testing.T) {
if tc.want != nil && record.SYBStatusSyncedAt == nil {
t.Fatal("known must have timestamp")
}
if !valid && previous != nil && !record.SYBStatusSyncedAt.Equal(*previous) {
if !tc.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")
}
}
if records[0].SYBStatusSyncedAt != nil && !records[0].SYBStatusSyncedAt.Equal(*records[1].SYBStatusSyncedAt) {
t.Fatal("same order must use same timestamp")
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")
}
previous = records[0].SYBStatusSyncedAt
}
@@ -116,7 +189,7 @@ func TestSyncCancellationDisplayAndListContract(t *testing.T) {
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if len(payload.Data.Items) != 2 {
if len(payload.Data.Items) != 4 {
t.Fatal("list count")
}
for _, item := range payload.Data.Items {
@@ -135,7 +208,7 @@ func TestSyncCancellationDisplayAndListContract(t *testing.T) {
}
}
response = httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequest("GET", "/products/1", nil))
router.ServeHTTP(response, httptest.NewRequest("GET", fmt.Sprintf("/products/%d", firstDetailID), nil))
if response.Code != 200 {
t.Fatalf("detail: %d", response.Code)
}