Compare commits
18
Commits
ddd875d989
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abdf90775c | ||
|
|
934a7beabc | ||
|
|
24100f04d4 | ||
|
|
266418ac47 | ||
|
|
5e5fdd797e | ||
|
|
58c8c077ad | ||
|
|
9f679f6403 | ||
|
|
977ac235e2 | ||
|
|
573f5b8cf3 | ||
|
|
1491a9677a | ||
|
|
d4d32c6e89 | ||
|
|
b7038633f0 | ||
|
|
844f611d77 | ||
|
|
d415971268 | ||
|
|
6fe588d5e3 | ||
|
|
17cbfaec85 | ||
|
|
b72fa105a8 | ||
|
|
5ac8e4c8fd |
@@ -1,3 +1,19 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
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: ad4a5c3c7e4c2e3631856b3021e11560be2e702e
|
||||
synchronized_at: 2026-09-27T03:42:29Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
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: c021b5b8251fecaf1cfa4b96d731964f6b04ca52
|
||||
synchronized_at: 2026-09-27T02:47:22Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Architecture-and-Code-Map
|
||||
@@ -488,7 +504,7 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
|
||||
### 会话类失败自动重试(#330)
|
||||
|
||||
- purchase/order_writeback_worker.go:restoreOrderWritebackClient 在 ImportCookiesJSON 后调用 sybclient.CheckSession,UserID<=0 显式判不可用;finishSessionUnavailable 复用 lease_expires_at 作为退避到期时间(maxSessionRetryAttempts=6,sessionRetryBackoff 5/10/15/30/30m),领取条件增加 failed+SYB_SESSION_UNAVAILABLE+到期+未达上限。无迁移。
|
||||
- purchase/order_writeback_worker.go:回填通过 `sybclient.AcquireSession` 统一复用/刷新 SYB 会话;`syb_session_auth_lease` 保证同步与回填同时只有一个 worker 触发 OCR 登录,其他任务等待新会话。回填终态同步 `purchase_task.writeback_status/writeback_at`;迁移为 `1789801400000_syb_session_auth_lease.go`。会话类失败仍使用有界退避,冲突不覆盖。
|
||||
- purchase/order_writeback.go:会话类失败的 CanSubmit 不受退避租约限制;手工重新提交 attempt_count 置 0。
|
||||
- 验证:go test ./app/goauto/purchase(含 httptest 模拟 /am/user/get 与断言 syb_session 未删除)。
|
||||
|
||||
@@ -520,3 +536,14 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
- `automation/PurchaseRehearsalExecutor.kt`:`waitForSpecPanel` 对回退类型要求连续两次结构一致;`selectionFailureEvidence` 选中失败诊断。
|
||||
- `automation/PurchaseLiveAutomation.kt`:`waitForSettledPanelAfterAddressSave` 保存地址后稳定等待(不按返回键);`finalSubmitTargets` 拒绝零尺寸文字/容器;新增构造参数 `panelDiagnostic`(仅布尔/计数)。
|
||||
- 测试:`SpecPanelRecognitionTest`、`SpecOptionDedupTest`、`SpecPanelFixtures`(脱敏构造样本)。
|
||||
|
||||
## GoAuto 售后角色与退货模块权限(#341)
|
||||
|
||||
- 追加迁移 `1789801100000_after_sales_role.go` 创建固定角色 `after_sales`(显示名“售后”)。
|
||||
- 售后角色幂等复制当前采购员角色的菜单绑定与 Casbin API 策略,因此包含采购业务权限以及“yeeke 退货包裹”“yeeke 同步记录”两个退货模块;初始密码仅通过迁移时环境变量注入,不进入代码、日志或文档。
|
||||
- 迁移创建四个内部账号并绑定售后角色;已存在账号不会重置密码或静默改绑角色。
|
||||
|
||||
|
||||
## #338 退货匹配(2026-09-27)
|
||||
|
||||
新增 `server/app/goauto/returnmatch` 模块及 `/api/admin/v1/return-matches` 路由,支持批量匹配、列表/详情、确认、取消和备注;相关表由版本化迁移创建,采购创建在服务端拦截有效退货匹配,售后角色可执行这些接口。
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
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: 57af37bf6acf16f86785c989e5c37dce85a3c588
|
||||
synchronized_at: 2026-09-27T03:42:33Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
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: 25e61fdbdfe1b3e70fec6d353620170ce14eb31f
|
||||
synchronized_at: 2026-09-27T02:47:26Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Business-Rules-and-Glossary
|
||||
@@ -632,7 +648,7 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
采购管理增加独立状态列、批量回填和详情补偿;复用既有访问权限,不增支付确认或审批。批量受理与最终成功分开展示;重试采购和回填分别筛选勾选项。远端无原子CAS,对系统外人工并发修改/超长延迟请求不能承诺绝对互斥;有冲突应人工核对,禁止强制覆盖。
|
||||
### SYB 会话类失败的有界自动重试(#330)
|
||||
|
||||
实现 01510a8/08b7095(2026-09-21,已合并 main,未部署、未生产验证)。回填 worker 从缓存会话恢复客户端后调用 SYB 会话校验;会话缺失/过期、串号失效、校验网络错误等均记为 `SYB_SESSION_UNAVAILABLE`,error_message 只记录类别和“将自动重试;如持续失败请恢复登录后重试”,不含原始错误。该类失败发生在任何写入之前,最多自动重试 6 次,退避 5/10/15/30/30 分钟(约 90 分钟,大于一个整点同步周期),达上限保持 failed 等人工。会话仍只由每小时 SYB 同步刷新;回填不登录、不 OCR、不删除或写入会话。其他失败码仍不自动重试。退避期内可手工重新回填,手工提交重置尝试次数。历史失败记录不会被自动领取。
|
||||
实现 934a7be(#343,2026-09-27)。回填 worker 与 SYB 同步共用 `sybclient.AcquireSession`:有效缓存会话直接复用,明确失效时由 `syb_session_auth_lease` 保证单 worker OCR 登录,其他任务等待新会话后继续原回填。网络错误不清除有效会话;OCR 未配置或登录失败保留结构化失败并允许人工重试。回填创建时采购任务进入 `pending`,终态同步 `succeeded/failed` 与 `writeback_at`;冲突仍拒绝覆盖。会话类失败继续使用有界退避,成功须回读确认。
|
||||
|
||||
## Agent 回填订单入口兼容(#307)
|
||||
|
||||
@@ -680,3 +696,14 @@ SYB 回填与资料完整性独立:正式SYB任务已有单号且仍关联有
|
||||
|
||||
### 采购手机环境前提
|
||||
执行采购的 Android 手机必须:PDD App 已登录、微信已登录、PDD 已设置默认收货地址、PDD 默认支付方式为微信支付。规格面板的地址与支付证据依赖该前提;不为支付宝等其他支付方式补充识别别名(有意决定)。条件不满足时 Agent 明确失败,不自动登录、不切换支付方式、不填写地址。
|
||||
|
||||
## 管理端售后角色(#341)
|
||||
|
||||
- 管理端固定增加 `after_sales`(显示名“售后”)业务角色。售后账号拥有采购员当前权限,并额外可访问“yeeke 退货包裹”和“yeeke 同步记录”两个模块及其接口。
|
||||
- 售后角色与账号由版本化迁移幂等维护;创建初始账号时由部署环境临时提供密码,迁移不输出或持久化明文密码。已存在账号若绑定其他角色则迁移失败并要求人工处理。
|
||||
- 本角色不获得管理员专属的用户、角色、设备凭据、规则配置或 AI Provider 管理权限,也不新增支付能力。
|
||||
|
||||
|
||||
## 退货匹配(#338)
|
||||
|
||||
退货匹配只由人工勾选 SYB 商品后触发。按虾皮商品 ID 与归一化规格匹配,状态为“退货待确认”或“已用退货”时拦截新采购,取消后恢复可采购;已有采购任务不受影响。售后与采购员可操作匹配、确认、取消和备注。
|
||||
|
||||
@@ -525,6 +525,13 @@ POST /am/stock/detail/updateDetailCode?t=0&id={stockID}&detailId={detailID}&code
|
||||
它是反复启动的一次性脚本,进程间要传会话;GoAuto 服务端是常驻进程,没有这个需求,
|
||||
持久化只为重启后免登录,复用现有数据库即可。表见 `syb_session`。
|
||||
|
||||
`[必须]` 同步和采购订单回填共用统一会话获取器:先校验 `syb_session`,只有明确
|
||||
会话失效或不存在时才进入登录。登录由 `syb_session_auth_lease` 的单账号租约串行化,
|
||||
租约内其他任务等待新会话,不重复请求验证码;登录成功后原回填任务重新执行并回读确认。
|
||||
网络超时、5xx 或 OCR 不可用不得清除有效会话,必须保留结构化失败状态供人工重试。
|
||||
回填终态同时同步到 `purchase_task.writeback_status/writeback_at`,页面不得继续显示旧的
|
||||
`not_selected`。
|
||||
|
||||
`[决定已变更]` ~~不引入 OCR 服务。~~ 这条判断在上游工单 #47 里被推翻了,
|
||||
原文和推翻理由都留在这里,方便后来人知道这个决定变过、为什么变:
|
||||
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Deployment-and-Operations
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
|
||||
wiki_revision: 9c10d971fe1dde057b13972566444a2234dd7f0e
|
||||
synchronized_at: 2026-09-27T03:42:42Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Deployment-and-Operations
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
|
||||
wiki_revision: 1195a0fab799c064c7e279443dd57977da63c94b
|
||||
synchronized_at: 2026-09-27T02:47:34Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Deployment-and-Operations
|
||||
@@ -153,3 +169,14 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
|
||||
5. GoAuto Agent 已启动,无障碍服务已开启,Admin 设备列表显示在线并已归属对应采购员账号。
|
||||
|
||||
任一项不满足时采购会明确失败并提示缺失项(如“未找到支付入口:请确认 PDD 默认支付方式为微信支付”);Agent 不代为登录、切换支付方式或填写地址。安装新版 Agent 后需手动启动一次 App,心跳恢复后设备才显示在线。
|
||||
|
||||
## #341 售后角色与账号迁移
|
||||
|
||||
- 发布前需单独授权执行 `server/cmd/migrate/migration/version-local/1789801100000_after_sales_role.go`。迁移会创建/维护 `after_sales` 角色、复制采购员当前菜单/API 授权,并创建四个售后账号;不会重置已存在账号密码。
|
||||
- 首次创建账号时仅在当前迁移进程设置 `GOAUTO_AFTER_SALES_INITIAL_PASSWORD`,执行完成后立即清除;不得把密码写入仓库、日志、工单或 Wiki。
|
||||
- 本地和线上均执行迁移后回读角色、四个账号的角色绑定、退货接口策略及幂等结果,再按常规流程重启 GoAuto API。
|
||||
|
||||
|
||||
## #338 退货匹配发布
|
||||
|
||||
发布时执行退货匹配及售后权限迁移,切换 Server/Web release,重启 `goauto.service` 并 reload Nginx;发布后验证健康接口、Web 首页、售后登录及退货匹配只读接口,不用真实商品提交作为健康检查。
|
||||
|
||||
@@ -173,6 +173,8 @@ func moduleKeyForAPI(path string) string {
|
||||
return ModuleYeekeSyncRuns
|
||||
case strings.HasPrefix(path, "/api/admin/v1/yeeke-returns"):
|
||||
return ModuleYeekeReturns
|
||||
case strings.HasPrefix(path, "/api/admin/v1/return-matches"):
|
||||
return ModuleSYBProducts
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ package access
|
||||
// RolePurchaser is the fixed role key used by the GoAuto purchaser account.
|
||||
const RolePurchaser = "purchaser"
|
||||
|
||||
// RoleAfterSales is the fixed role key for the internal after-sales users.
|
||||
const RoleAfterSales = "after_sales"
|
||||
|
||||
// APIPermission describes one admin API known to GoAuto. Purchaser marks the
|
||||
// APIs that the purchaser role may call; every other API remains admin-only.
|
||||
type APIPermission struct {
|
||||
@@ -132,6 +135,15 @@ var AdminAPIs = []APIPermission{
|
||||
{"查看 yeeke 同步详情", "/api/admin/v1/yeeke-returns/sync-runs/:runId", "GET", true},
|
||||
{"手动触发 yeeke 同步", "/api/admin/v1/yeeke-returns/sync", "POST", true},
|
||||
|
||||
{"查看退货匹配", "/api/admin/v1/return-matches", "GET", true},
|
||||
{"查看退货匹配批次", "/api/admin/v1/return-matches/batches", "GET", true},
|
||||
{"查看退货匹配批次详情", "/api/admin/v1/return-matches/batches/:batchId", "GET", true},
|
||||
{"查看退货匹配详情", "/api/admin/v1/return-matches/:id", "GET", true},
|
||||
{"批量匹配退货", "/api/admin/v1/return-matches/batch-match", "POST", true},
|
||||
{"确认退货匹配", "/api/admin/v1/return-matches/:id/confirm", "POST", true},
|
||||
{"取消退货匹配", "/api/admin/v1/return-matches/:id/cancel", "POST", true},
|
||||
{"备注退货匹配", "/api/admin/v1/return-matches/:id/remark", "POST", true},
|
||||
|
||||
{"查看 AI 匹配状态", "/api/admin/v1/ai-matching-settings", "GET", true},
|
||||
{"保存 AI 匹配设置", "/api/admin/v1/ai-matching-settings", "PUT", false},
|
||||
{"测试 AI 服务连接", "/api/admin/v1/ai-matching-settings/test", "POST", false},
|
||||
|
||||
@@ -41,6 +41,7 @@ func MigratedModels() []any {
|
||||
&models.SYBSpecAIParseRun{},
|
||||
&models.SYBSpecAIParseWorkItem{},
|
||||
&models.SYBSession{},
|
||||
&models.SYBSessionAuthLease{},
|
||||
&models.SYBShop{},
|
||||
&models.SYBProductFilter{},
|
||||
&models.SYBSyncRun{},
|
||||
|
||||
@@ -504,6 +504,17 @@ type SYBSession struct {
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// SYBSessionAuthLease serializes re-authentication across sync and writeback
|
||||
// workers. Cookies and passwords never live in this table.
|
||||
type SYBSessionAuthLease struct {
|
||||
ID uint64 `gorm:"primaryKey"`
|
||||
Username string `gorm:"size:128;not null;uniqueIndex"`
|
||||
Owner string `gorm:"size:36;not null"`
|
||||
ExpiresAt time.Time `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (SYBSessionAuthLease) TableName() string { return "syb_session_auth_lease" }
|
||||
|
||||
// SYBProduct is one SYB (顺云宝 ERP) shipment detail line: one order can carry
|
||||
// several Shopee product lines, and the same Shopee product can appear more
|
||||
// than once within one order at different colors/sizes/quantities — each such
|
||||
|
||||
@@ -74,24 +74,27 @@ type YeekeReturnItem struct {
|
||||
func (YeekeReturnItem) TableName() string { return "yeeke_return_item" }
|
||||
|
||||
type YeekeSyncRun struct {
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement"`
|
||||
Status string `gorm:"size:32;not null;index"`
|
||||
Trigger string `gorm:"size:32;not null;index"`
|
||||
TotalPages int `gorm:"not null;default:0"`
|
||||
ReadCount int `gorm:"not null;default:0"`
|
||||
CreatedCount int `gorm:"not null;default:0"`
|
||||
UpdatedCount int `gorm:"not null;default:0"`
|
||||
SkippedCount int `gorm:"not null;default:0"`
|
||||
FailedCount int `gorm:"not null;default:0"`
|
||||
ErrorMessage string `gorm:"size:1000;not null;default:''"`
|
||||
StartedAt time.Time `gorm:"not null"`
|
||||
FinishedAt *time.Time
|
||||
LastSuccessAt *time.Time
|
||||
ActiveSlot *uint8 `gorm:"uniqueIndex:ux_yeeke_sync_run_active_slot"`
|
||||
LeaseOwner string `gorm:"size:128;not null;default:''"`
|
||||
LeaseExpiresAt *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement"`
|
||||
Status string `gorm:"size:32;not null;index"`
|
||||
Trigger string `gorm:"size:32;not null;index"`
|
||||
TotalPages int `gorm:"not null;default:0"`
|
||||
ReadCount int `gorm:"not null;default:0"`
|
||||
CreatedCount int `gorm:"not null;default:0"`
|
||||
UpdatedCount int `gorm:"not null;default:0"`
|
||||
SkippedCount int `gorm:"not null;default:0"`
|
||||
FailedCount int `gorm:"not null;default:0"`
|
||||
// #338: items flipped to "missing" / back to "ok" by this run.
|
||||
MissingMarkedCount int `gorm:"not null;default:0"`
|
||||
RecoveredCount int `gorm:"not null;default:0"`
|
||||
ErrorMessage string `gorm:"size:1000;not null;default:''"`
|
||||
StartedAt time.Time `gorm:"not null"`
|
||||
FinishedAt *time.Time
|
||||
LastSuccessAt *time.Time
|
||||
ActiveSlot *uint8 `gorm:"uniqueIndex:ux_yeeke_sync_run_active_slot"`
|
||||
LeaseOwner string `gorm:"size:128;not null;default:''"`
|
||||
LeaseExpiresAt *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (YeekeSyncRun) TableName() string { return "yeeke_sync_run" }
|
||||
|
||||
@@ -475,10 +475,10 @@ func allowedOperator(c *gin.Context) bool {
|
||||
return true
|
||||
}
|
||||
role, _ := jwt.ExtractClaims(c)["rolekey"].(string)
|
||||
if role == "admin" || role == "purchaser" {
|
||||
if role == "admin" || role == "purchaser" || role == "after_sales" {
|
||||
return true
|
||||
}
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员或采购员可以操作采购任务"})
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员、采购员或售后可以操作采购任务"})
|
||||
c.Abort()
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
)
|
||||
|
||||
func TestAllowedOperatorIncludesAfterSales(t *testing.T) {
|
||||
for _, role := range []string{"admin", "purchaser", "after_sales", "other", ""} {
|
||||
t.Run(role, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": role})
|
||||
want := role == "admin" || role == "purchaser" || role == "after_sales"
|
||||
if got := allowedOperator(c); got != want {
|
||||
t.Fatalf("role %q allowed=%v want %v", role, got, want)
|
||||
}
|
||||
if !want && w.Code != 403 {
|
||||
t.Fatalf("denied role status=%d", w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -96,7 +96,7 @@ func TestOrderBackfillMixedBatchAndReplay(t *testing.T) {
|
||||
if saved.StatusVersion != a.StatusVersion+1 || saved.ErrorCode != nil || saved.ErrorMessage != nil || saved.DeviceRunSlot != nil || saved.AccountRunSlot != nil || saved.ActiveSlot == nil || saved.Status != models.PurchaseTaskStatusOrderCreated {
|
||||
t.Fatalf("state metadata: %+v", saved)
|
||||
}
|
||||
if saved.PaymentReviewStatus != a.PaymentReviewStatus || saved.LogisticsStatus != a.LogisticsStatus || saved.WritebackStatus != a.WritebackStatus || saved.RuleSnapshot != a.RuleSnapshot {
|
||||
if saved.PaymentReviewStatus != a.PaymentReviewStatus || saved.LogisticsStatus != a.LogisticsStatus || saved.WritebackStatus != models.PurchaseWritebackStatusPending || saved.RuleSnapshot != a.RuleSnapshot {
|
||||
t.Fatal("unrelated business facts changed")
|
||||
}
|
||||
for _, replayID := range []string{rid, uuid.NewString()} {
|
||||
|
||||
@@ -60,7 +60,10 @@ func ensureOrderWriteback(tx *gorm.DB, t models.PurchaseTask) error {
|
||||
return nil
|
||||
}
|
||||
row := models.PurchaseOrderWriteback{PurchaseTaskID: t.ID, StockID: int64(syb.StockID), DetailID: int64(syb.DetailID), OrderNo: *t.PDDOrderNo, Status: "pending"}
|
||||
return tx.Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)}).Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error
|
||||
if err := tx.Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)}).Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ? AND writeback_status = ?", t.ID, models.PurchaseWritebackStatusNotSelected).Update("writeback_status", models.PurchaseWritebackStatusPending).Error
|
||||
}
|
||||
|
||||
func (s *Service) OrderWritebackViews(ctx context.Context, tasks []models.PurchaseTask) (map[uint64]OrderWritebackView, error) {
|
||||
|
||||
@@ -116,7 +116,11 @@ func TestOrderWritebackRemoteOutcomes(t *testing.T) {
|
||||
t.Fatal("automatically repeated write")
|
||||
}
|
||||
after := loadBackfillTask(t, s.DB, task.ID)
|
||||
if after.PaymentReviewStatus != task.PaymentReviewStatus || after.WritebackStatus != task.WritebackStatus || after.StatusVersion != task.StatusVersion {
|
||||
wantTaskWriteback := models.PurchaseWritebackStatusFailed
|
||||
if tc.want == "succeeded" {
|
||||
wantTaskWriteback = models.PurchaseWritebackStatusSucceeded
|
||||
}
|
||||
if after.PaymentReviewStatus != task.PaymentReviewStatus || after.WritebackStatus != wantTaskWriteback || after.StatusVersion != task.StatusVersion {
|
||||
t.Fatal("changed purchase/payment/logistics facts")
|
||||
}
|
||||
})
|
||||
|
||||
@@ -84,38 +84,16 @@ func sessionUnavailableMessage(err error) string {
|
||||
return "SYB会话不可用(" + category + "),将自动重试;如持续失败请恢复登录后重试"
|
||||
}
|
||||
|
||||
// restoreOrderWritebackClient rebuilds a SYB client from the cached session
|
||||
// only. It never logs in, never triggers OCR and never deletes the cached
|
||||
// session (that stays the exclusive responsibility of sybimport.Connect's
|
||||
// login/refresh path) — it only reports whether the cached cookies still
|
||||
// work, via CheckSession, so the caller can classify the failure (#330).
|
||||
// restoreOrderWritebackClient uses the same session acquisition path as SYB
|
||||
// sync. A valid cached session is reused; an explicitly invalid session is
|
||||
// refreshed under the shared database auth lease so concurrent workers do not
|
||||
// request multiple captcha codes.
|
||||
func restoreOrderWritebackClient(ctx context.Context, db *gorm.DB) (OrderNumberClient, error) {
|
||||
cfg := config.ExtConfig.SYB.Resolved()
|
||||
session, err := sybclient.NewSessionStore(db).Load(ctx, cfg.Username, time.Now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if session.UserID <= 0 {
|
||||
return nil, errSessionUserIDMissing
|
||||
}
|
||||
c, err := sybclient.New(cfg.BaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = c.ImportCookiesJSON(session.CookiesJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Active probe (#330 修订1): without this, a remotely-expired cookie jar
|
||||
// imports cleanly and only fails later inside read(), which would record
|
||||
// it as SYB_READ_FAILED instead of the retryable session-class outcome.
|
||||
// Any error here — ErrSessionInvalid or network/format — is treated as
|
||||
// session-class; only ErrSessionInvalid is a confirmed logout, but a
|
||||
// network/format error is not confirmed-valid either, so it is still
|
||||
// retried rather than attempted as a write.
|
||||
if err = c.CheckSession(ctx, session.UserID, cfg.Username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
return sybclient.AcquireSession(ctx, db, sybclient.LoginConfig{
|
||||
BaseURL: cfg.BaseURL, Username: cfg.Username, Password: cfg.Password,
|
||||
OcrURL: cfg.OcrURL, OcrMaxAttempts: cfg.OcrMaxAttempts,
|
||||
})
|
||||
}
|
||||
|
||||
// One short-lived claim at a time across processes. No business writes occur
|
||||
@@ -184,7 +162,20 @@ func (w *OrderWritebackWorker) RunOnce(ctx context.Context) (bool, error) {
|
||||
if status == "succeeded" {
|
||||
updates["completed_at"] = w.Now()
|
||||
}
|
||||
return db.Model(&models.PurchaseOrderWriteback{}).Where("id = ? AND status = 'running' AND lease_owner = ?", item.ID, owner).Updates(updates).Error
|
||||
if err := db.Model(&models.PurchaseOrderWriteback{}).Where("id = ? AND status = 'running' AND lease_owner = ?", item.ID, owner).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// Keep the admin task state aligned with the authoritative writeback
|
||||
// record. Conflicts and unknown outcomes remain failed until a human
|
||||
// resolves them; they must never appear as successful.
|
||||
taskStatus := models.PurchaseWritebackStatusFailed
|
||||
if status == "succeeded" {
|
||||
taskStatus = models.PurchaseWritebackStatusSucceeded
|
||||
}
|
||||
return db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", item.PurchaseTaskID).Updates(map[string]any{
|
||||
"writeback_status": taskStatus,
|
||||
"writeback_at": gorm.Expr("CASE WHEN ? = 'succeeded' THEN ? ELSE writeback_at END", status, w.Now()),
|
||||
}).Error
|
||||
}
|
||||
// finishSessionUnavailable is the bounded-retry counterpart of finish for
|
||||
// SYB_SESSION_UNAVAILABLE: instead of clearing the lease, it schedules the
|
||||
@@ -201,7 +192,10 @@ func (w *OrderWritebackWorker) RunOnce(ctx context.Context) (bool, error) {
|
||||
} else {
|
||||
updates["lease_expires_at"] = nil
|
||||
}
|
||||
return db.Model(&models.PurchaseOrderWriteback{}).Where("id = ? AND status = 'running' AND lease_owner = ?", item.ID, owner).Updates(updates).Error
|
||||
if err := db.Model(&models.PurchaseOrderWriteback{}).Where("id = ? AND status = 'running' AND lease_owner = ?", item.ID, owner).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", item.PurchaseTaskID).Update("writeback_status", models.PurchaseWritebackStatusFailed).Error
|
||||
}
|
||||
var task models.PurchaseTask
|
||||
if err = db.First(&task, item.PurchaseTaskID).Error; err != nil {
|
||||
|
||||
@@ -44,14 +44,14 @@ func operatorFromContext(c *gin.Context) (string, string) {
|
||||
return role, username
|
||||
}
|
||||
|
||||
// requireCanPurchase mirrors the admin/purchaser write gate this codebase
|
||||
// requireCanPurchase mirrors the admin/purchaser/after-sales write gate this codebase
|
||||
// already uses for other manual-trigger actions (yeeke.Handler.TriggerSync,
|
||||
// sybimport.Handler.Import): trigger match, confirm, cancel and remark are
|
||||
// writes and require it; the two list/detail read endpoints do not.
|
||||
func requireCanPurchase(c *gin.Context) bool {
|
||||
role, _ := operatorFromContext(c)
|
||||
if role != "admin" && role != "purchaser" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员或采购员可以操作退货匹配"})
|
||||
if role != "admin" && role != "purchaser" && role != "after_sales" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员、采购员或售后可以操作退货匹配"})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
@@ -77,6 +77,10 @@ func (h Handler) BatchMatch(c *gin.Context) {
|
||||
}
|
||||
_, operator := operatorFromContext(c)
|
||||
resp, err := NewService(db).BatchMatch(c.Request.Context(), BatchMatchRequest{SYBProductIDs: body.SYBProductIDs, Operator: operator})
|
||||
if errors.Is(err, ErrTooManyItems) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": ErrTooManyItems.Error()})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
||||
return
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package returnmatch
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
)
|
||||
|
||||
func TestReturnMatchWriteRoles(t *testing.T) {
|
||||
for _, role := range []string{"admin", "purchaser", "after_sales", "other", ""} {
|
||||
t.Run(role, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": role})
|
||||
want := role == "admin" || role == "purchaser" || role == "after_sales"
|
||||
if got := requireCanPurchase(c); got != want {
|
||||
t.Fatalf("role %q allowed=%v want %v", role, got, want)
|
||||
}
|
||||
if !want && w.Code != 403 {
|
||||
t.Fatalf("denied role status=%d", w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
// InitRouter mounts the #338 return-matching admin surface. List/detail are
|
||||
// readable by any authenticated admin user; the write actions (batch match,
|
||||
// confirm, cancel, remark) additionally require admin/purchaser via
|
||||
// confirm, cancel, remark) additionally require admin/purchaser/after-sales via
|
||||
// requireCanPurchase, same gate as yeeke.Handler.TriggerSync.
|
||||
func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
|
||||
handler := Handler{}
|
||||
|
||||
@@ -68,10 +68,23 @@ const (
|
||||
ReasonConflict = "conflict"
|
||||
)
|
||||
|
||||
// maxBatchMatchItems caps a single「匹配退货」submission (#339: SYB list page
|
||||
// size can now go up to 500/page, so the button's selection can exceed the
|
||||
// previously-unbounded batch-match request size).
|
||||
const maxBatchMatchItems = 500
|
||||
|
||||
// ErrTooManyItems is returned by BatchMatch when the caller submits more than
|
||||
// maxBatchMatchItems SYB product ids; the handler turns this into an
|
||||
// INVALID_REQUEST response instead of a 500.
|
||||
var ErrTooManyItems = errors.New("sybProductIds 一次最多 500 条")
|
||||
|
||||
// BatchMatch implements issue #338's manual "匹配退货" trigger. It is only
|
||||
// ever called from the batch-match button (ticked rows) — no scheduler, no
|
||||
// yeeke-sync/SYB-import hook calls this (rule: 手动触发, 无定时任务).
|
||||
func (s *Service) BatchMatch(ctx context.Context, req BatchMatchRequest) (BatchMatchResponse, error) {
|
||||
if len(req.SYBProductIDs) > maxBatchMatchItems {
|
||||
return BatchMatchResponse{}, ErrTooManyItems
|
||||
}
|
||||
resp, err := s.batchMatch(ctx, req)
|
||||
if len(req.SYBProductIDs) > 0 {
|
||||
// The batch record is written after the per-row transactions have
|
||||
|
||||
@@ -111,6 +111,31 @@ func TestBatchMatch_EndToEnd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchMatch_RejectsMoreThan500Items(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := NewService(db)
|
||||
|
||||
ids := make([]uint64, maxBatchMatchItems+1)
|
||||
for i := range ids {
|
||||
ids[i] = uint64(i + 1)
|
||||
}
|
||||
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: ids, Operator: "tester"})
|
||||
if !errors.Is(err, ErrTooManyItems) {
|
||||
t.Fatalf("expected ErrTooManyItems, got %v", err)
|
||||
}
|
||||
if len(resp.Items) != 0 {
|
||||
t.Fatalf("expected empty response on rejection, got %+v", resp)
|
||||
}
|
||||
|
||||
var batchCount int64
|
||||
if err := db.Model(&models.ReturnMatchBatch{}).Count(&batchCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if batchCount != 0 {
|
||||
t.Fatalf("rejected oversized batch must not be recorded, got %d rows", batchCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchMatch_ExpiredDeadlineNotMatched(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := NewService(db)
|
||||
|
||||
@@ -30,6 +30,10 @@ type SessionStore struct{ db *gorm.DB }
|
||||
|
||||
func NewSessionStore(db *gorm.DB) *SessionStore { return &SessionStore{db: db} }
|
||||
|
||||
// DB exposes the store connection to the shared session manager; callers do
|
||||
// not receive any session data through this accessor.
|
||||
func (s *SessionStore) DB() *gorm.DB { return s.db }
|
||||
|
||||
// Session is one cached SYB login.
|
||||
type Session struct {
|
||||
Username string
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package sybclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go-admin/app/goauto/models"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// LoginConfig contains the non-secret connection settings and credentials
|
||||
// needed to acquire a SYB session. Password is used only during LoginWithOCR.
|
||||
type LoginConfig struct {
|
||||
BaseURL string
|
||||
Username string
|
||||
Password string
|
||||
OcrURL string
|
||||
OcrMaxAttempts int
|
||||
}
|
||||
|
||||
// AcquireSession reuses a valid cached session and performs at most one
|
||||
// re-login per account at a time. Callers waiting for another process to
|
||||
// refresh the session never request another captcha.
|
||||
func AcquireSession(ctx context.Context, db *gorm.DB, cfg LoginConfig) (*Client, error) {
|
||||
if cfg.Username == "" {
|
||||
return nil, fmt.Errorf("顺云宝账号未配置")
|
||||
}
|
||||
client, err := New(cfg.BaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
store := NewSessionStore(db)
|
||||
if cached, loadErr := store.Load(ctx, cfg.Username, time.Now()); loadErr == nil {
|
||||
if importErr := client.ImportCookiesJSON(cached.CookiesJSON); importErr != nil {
|
||||
return nil, importErr
|
||||
}
|
||||
checkErr := client.CheckSession(ctx, cached.UserID, cfg.Username)
|
||||
if checkErr == nil {
|
||||
return client, nil
|
||||
}
|
||||
if !errors.Is(checkErr, ErrSessionInvalid) {
|
||||
return nil, checkErr
|
||||
}
|
||||
if cfg.Password == "" {
|
||||
return nil, checkErr
|
||||
}
|
||||
} else if !errors.Is(loadErr, ErrNoSession) {
|
||||
return nil, loadErr
|
||||
} else if cfg.Password == "" {
|
||||
return nil, loadErr
|
||||
}
|
||||
|
||||
owner := uuid.NewString()
|
||||
if claimed, err := claimAuthLease(ctx, db, cfg.Username, owner, 2*time.Minute); err != nil {
|
||||
return nil, err
|
||||
} else if claimed {
|
||||
defer releaseAuthLease(context.Background(), db, cfg.Username, owner)
|
||||
// Another worker may have completed login between our first probe and
|
||||
// acquiring the lease; always re-check before requesting a captcha.
|
||||
if c, ok := validCachedSession(ctx, client, store, cfg.Username); ok {
|
||||
return c, nil
|
||||
}
|
||||
if err := refreshSession(ctx, db, client, store, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return validSessionOrError(ctx, client, store, cfg.Username)
|
||||
}
|
||||
|
||||
// A different worker owns the login lease. Wait for its session, bounded by
|
||||
// the caller's context; do not trigger a second login.
|
||||
for {
|
||||
if c, ok := validCachedSession(ctx, client, store, cfg.Username); ok {
|
||||
return c, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, fmt.Errorf("等待顺云宝会话刷新超时: %w", err)
|
||||
}
|
||||
select {
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validSessionOrError(ctx context.Context, client *Client, store *SessionStore, username string) (*Client, error) {
|
||||
s, err := store.Load(ctx, username, time.Now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := client.ImportCookiesJSON(s.CookiesJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := client.CheckSession(ctx, s.UserID, username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func validCachedSession(ctx context.Context, client *Client, store *SessionStore, username string) (*Client, bool) {
|
||||
s, err := store.Load(ctx, username, time.Now())
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if err := client.ImportCookiesJSON(s.CookiesJSON); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if err := client.CheckSession(ctx, s.UserID, username); err != nil {
|
||||
// Network failures are not treated as logout, but they also do not
|
||||
// authorize a write; the caller will retry through the normal worker.
|
||||
return nil, false
|
||||
}
|
||||
return client, true
|
||||
}
|
||||
|
||||
func refreshSession(ctx context.Context, db *gorm.DB, client *Client, store *SessionStore, cfg LoginConfig) error {
|
||||
ocr, err := NewOcrClient(cfg.OcrURL, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("顺云宝验证码识别服务不可用: %w", err)
|
||||
}
|
||||
result, reason := client.LoginWithOCR(ctx, ocr, cfg.Username, cfg.Password, cfg.OcrMaxAttempts)
|
||||
if result == nil {
|
||||
return fmt.Errorf("顺云宝自动登录失败,需要手工输入验证码: %s", reason)
|
||||
}
|
||||
cookies, err := client.ExportCookiesJSON()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return store.Save(ctx, Session{Username: cfg.Username, UserID: result.User.ID, CookiesJSON: cookies, ExpiresAt: result.ExpiresAt})
|
||||
}
|
||||
|
||||
func claimAuthLease(ctx context.Context, db *gorm.DB, username, owner string, ttl time.Duration) (bool, error) {
|
||||
now := time.Now().UTC()
|
||||
if err := db.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(&models.SYBSessionAuthLease{ID: 1, Username: username, Owner: "", ExpiresAt: now.Add(-time.Second)}).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
r := db.WithContext(ctx).Model(&models.SYBSessionAuthLease{}).Where("id = 1 AND username = ? AND (expires_at <= ? OR owner = '')", username, now).Updates(map[string]any{"owner": owner, "expires_at": now.Add(ttl)})
|
||||
return r.RowsAffected == 1, r.Error
|
||||
}
|
||||
|
||||
func releaseAuthLease(ctx context.Context, db *gorm.DB, username, owner string) {
|
||||
_ = db.WithContext(ctx).Model(&models.SYBSessionAuthLease{}).Where("id = 1 AND username = ? AND owner = ?", username, owner).Updates(map[string]any{"owner": "", "expires_at": time.Now().UTC().Add(-time.Second)})
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package sybclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestAuthLeaseAllowsOnlyOneRefreshOwner(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:syb-auth-lease?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.SYBSessionAuthLease{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
first, err := claimAuthLease(ctx, db, "operator", "first", time.Minute)
|
||||
if err != nil || !first {
|
||||
t.Fatalf("first owner should claim: %v %v", first, err)
|
||||
}
|
||||
second, err := claimAuthLease(ctx, db, "operator", "second", time.Minute)
|
||||
if err != nil || second {
|
||||
t.Fatalf("second owner must wait: %v %v", second, err)
|
||||
}
|
||||
releaseAuthLease(ctx, db, "operator", "first")
|
||||
second, err = claimAuthLease(ctx, db, "operator", "second", time.Minute)
|
||||
if err != nil || !second {
|
||||
t.Fatalf("lease should be reusable: %v %v", second, err)
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ func (handler Handler) List(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
response, err := service.List(c.Request.Context(), ListRequest{
|
||||
Page: page, PageSize: pageSize, ShopName: c.Query("shopName"), OrderCodes: []string{c.Query("orderCodes")}, ParseStatus: strings.TrimSpace(c.Query("parseStatus")), ProcessStage: strings.TrimSpace(c.Query("processStage")),
|
||||
Page: page, PageSize: pageSize, ShopName: c.Query("shopName"), OrderCodes: []string{c.Query("orderCodes")}, ParseStatus: strings.TrimSpace(c.Query("parseStatus")), ProcessStage: strings.TrimSpace(c.Query("processStage")), CreatedFrom: strings.TrimSpace(c.Query("createdFrom")), CreatedTo: strings.TrimSpace(c.Query("createdTo")),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/purchase"
|
||||
@@ -44,6 +45,8 @@ type ListRequest struct {
|
||||
OrderCodes []string
|
||||
ParseStatus string
|
||||
ProcessStage string
|
||||
CreatedFrom string
|
||||
CreatedTo string
|
||||
}
|
||||
|
||||
type ListResponse struct {
|
||||
@@ -65,10 +68,20 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp
|
||||
if request.PageSize < 1 {
|
||||
request.PageSize = 20
|
||||
}
|
||||
if request.PageSize > 100 {
|
||||
request.PageSize = 100
|
||||
if request.PageSize > 500 {
|
||||
request.PageSize = 500
|
||||
}
|
||||
query := service.DB.WithContext(ctx).Model(&models.SYBProduct{})
|
||||
createdFrom, createdTo, err := createdAtRange(request.CreatedFrom, request.CreatedTo)
|
||||
if err != nil {
|
||||
return ListResponse{}, err
|
||||
}
|
||||
if createdFrom != nil {
|
||||
query = query.Where("created_at >= ?", *createdFrom)
|
||||
}
|
||||
if createdTo != nil {
|
||||
query = query.Where("created_at < ?", *createdTo)
|
||||
}
|
||||
if request.ShopName = strings.TrimSpace(request.ShopName); request.ShopName != "" {
|
||||
if len([]rune(request.ShopName)) > 255 {
|
||||
return ListResponse{}, invalidRequest("店铺名称不能超过 255 个字符")
|
||||
@@ -142,6 +155,43 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp
|
||||
return ListResponse{Items: items, Total: total, Page: request.Page, PageSize: request.PageSize}, nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
func createdAtRange(from, to string) (*time.Time, *time.Time, error) {
|
||||
from = strings.TrimSpace(from)
|
||||
to = strings.TrimSpace(to)
|
||||
if from == "" && to == "" {
|
||||
return nil, nil, nil
|
||||
}
|
||||
parse := func(value, label string) (*time.Time, error) {
|
||||
if value == "" {
|
||||
return nil, nil
|
||||
}
|
||||
parsed, err := time.ParseInLocation("2006-01-02", value, time.Local)
|
||||
if err != nil {
|
||||
return nil, invalidRequest(label + " 必须是 YYYY-MM-DD")
|
||||
}
|
||||
return &parsed, nil
|
||||
}
|
||||
start, err := parse(from, "createdFrom")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
endDay, err := parse(to, "createdTo")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if start != nil && endDay != nil && start.After(*endDay) {
|
||||
return nil, nil, invalidRequest("createdFrom 不能晚于 createdTo")
|
||||
}
|
||||
if endDay != nil {
|
||||
end := endDay.AddDate(0, 0, 1)
|
||||
endDay = &end
|
||||
}
|
||||
return start, endDay, nil
|
||||
}
|
||||
|
||||
func normalizeOrderCodes(raw []string) ([]string, error) {
|
||||
seen := make(map[string]bool, len(raw))
|
||||
result := make([]string, 0, len(raw))
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/sybimport"
|
||||
@@ -93,6 +94,76 @@ func TestServiceListRejectsTooManyOrTooLongOrderCodes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceListCapsPageSizeAt500(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
order := realOrder()
|
||||
for i := 0; i < 3; i++ {
|
||||
detail := realDetailA()
|
||||
detail.ID += uint64(i)
|
||||
order.Code = fmt.Sprintf("260728TB95MJTQ-%d", i)
|
||||
if _, err := sybimport.ApplyDetail(context.Background(), db, order, detail); err != nil {
|
||||
t.Fatalf("apply detail %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
service := sybimport.NewService(db)
|
||||
|
||||
overLimit, err := service.List(context.Background(), sybimport.ListRequest{Page: 1, PageSize: 600})
|
||||
if err != nil {
|
||||
t.Fatalf("list with oversized page size: %v", err)
|
||||
}
|
||||
if overLimit.PageSize != 500 {
|
||||
t.Fatalf("expected page size capped at 500, got %d", overLimit.PageSize)
|
||||
}
|
||||
|
||||
within, err := service.List(context.Background(), sybimport.ListRequest{Page: 1, PageSize: 500})
|
||||
if err != nil {
|
||||
t.Fatalf("list at exactly 500: %v", err)
|
||||
}
|
||||
if within.PageSize != 500 {
|
||||
t.Fatalf("expected page size of exactly 500 to pass through unchanged, got %d", within.PageSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceListFiltersByCreatedDateInclusive(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
order := realOrder()
|
||||
for i, day := range []string{"2026-09-01", "2026-09-02", "2026-09-03"} {
|
||||
rowOrder := order
|
||||
rowOrder.Code = fmt.Sprintf("CREATED-%d", i)
|
||||
rowOrder.StockID += uint64(i)
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
service := sybimport.NewService(db)
|
||||
|
||||
between, err := service.List(context.Background(), sybimport.ListRequest{CreatedFrom: "2026-09-01", CreatedTo: "2026-09-02"})
|
||||
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"})
|
||||
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"})
|
||||
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 {
|
||||
t.Fatalf("reversed created date range should be rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceListRejectsInvalidParseStatus(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
service := sybimport.NewService(db)
|
||||
|
||||
@@ -480,54 +480,10 @@ func splitDateRange(dateFrom, dateTo string) ([]string, error) {
|
||||
// blip as a logout would trigger needless logins and could throw away a
|
||||
// perfectly good session.
|
||||
func Connect(ctx context.Context, store *sybclient.SessionStore, cfg ConnectConfig) (*sybclient.Client, error) {
|
||||
if cfg.Username == "" || cfg.Password == "" {
|
||||
return nil, errors.New("顺云宝账号或密码未配置,请设置 GOAUTO_SYB_USERNAME 和 GOAUTO_SYB_PASSWORD")
|
||||
}
|
||||
client, err := sybclient.New(cfg.BaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cached, err := store.Load(ctx, cfg.Username, time.Now())
|
||||
switch {
|
||||
case err == nil:
|
||||
if importErr := client.ImportCookiesJSON(cached.CookiesJSON); importErr == nil {
|
||||
if checkErr := client.CheckSession(ctx, cached.UserID, cfg.Username); checkErr == nil {
|
||||
return client, nil
|
||||
} else if errors.Is(checkErr, sybclient.ErrSessionInvalid) {
|
||||
if delErr := store.Delete(ctx, cfg.Username); delErr != nil {
|
||||
return nil, delErr
|
||||
}
|
||||
}
|
||||
}
|
||||
case errors.Is(err, sybclient.ErrNoSession):
|
||||
// Nothing cached; fall through to a fresh login.
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cfg.OcrURL == "" {
|
||||
return nil, errors.New("顺云宝会话已失效,且未配置验证码识别服务;请配置 extend.syb.ocrurl 或改用手工登录")
|
||||
}
|
||||
ocr, err := sybclient.NewOcrClient(cfg.OcrURL, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, reason := client.LoginWithOCR(ctx, ocr, cfg.Username, cfg.Password, cfg.OcrMaxAttempts)
|
||||
if result == nil {
|
||||
return nil, fmt.Errorf("顺云宝自动登录失败,需要手工输入验证码: %s", reason)
|
||||
}
|
||||
jar, err := client.ExportCookiesJSON()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := store.Save(ctx, sybclient.Session{
|
||||
Username: cfg.Username, UserID: result.User.ID,
|
||||
CookiesJSON: jar, ExpiresAt: result.ExpiresAt,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client, nil
|
||||
return sybclient.AcquireSession(ctx, store.DB(), sybclient.LoginConfig{
|
||||
BaseURL: cfg.BaseURL, Username: cfg.Username, Password: cfg.Password,
|
||||
OcrURL: cfg.OcrURL, OcrMaxAttempts: cfg.OcrMaxAttempts,
|
||||
})
|
||||
}
|
||||
|
||||
// ConnectConfig carries the login settings. The password is passed through and
|
||||
|
||||
@@ -43,26 +43,29 @@ func (h Handler) db(c *gin.Context) (*gorm.DB, bool) {
|
||||
// only the summary fields already computed by the sync run itself; it never
|
||||
// carries yeeke_session (token/cookies) or a raw page response.
|
||||
type SyncRunDTO struct {
|
||||
ID uint64 `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Trigger string `json:"trigger"`
|
||||
TotalPages int `json:"totalPages"`
|
||||
ReadCount int `json:"readCount"`
|
||||
CreatedCount int `json:"createdCount"`
|
||||
UpdatedCount int `json:"updatedCount"`
|
||||
SkippedCount int `json:"skippedCount"`
|
||||
FailedCount int `json:"failedCount"`
|
||||
ErrorMessage string `json:"errorMessage"`
|
||||
StartedAt string `json:"startedAt"`
|
||||
FinishedAt *string `json:"finishedAt"`
|
||||
LastSuccessAt *string `json:"lastSuccessAt"`
|
||||
ID uint64 `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Trigger string `json:"trigger"`
|
||||
TotalPages int `json:"totalPages"`
|
||||
ReadCount int `json:"readCount"`
|
||||
CreatedCount int `json:"createdCount"`
|
||||
UpdatedCount int `json:"updatedCount"`
|
||||
SkippedCount int `json:"skippedCount"`
|
||||
FailedCount int `json:"failedCount"`
|
||||
MissingMarkedCount int `json:"missingMarkedCount"`
|
||||
RecoveredCount int `json:"recoveredCount"`
|
||||
ErrorMessage string `json:"errorMessage"`
|
||||
StartedAt string `json:"startedAt"`
|
||||
FinishedAt *string `json:"finishedAt"`
|
||||
LastSuccessAt *string `json:"lastSuccessAt"`
|
||||
}
|
||||
|
||||
func toDTO(r models.YeekeSyncRun) SyncRunDTO {
|
||||
dto := SyncRunDTO{
|
||||
ID: r.ID, Status: r.Status, Trigger: r.Trigger, TotalPages: r.TotalPages,
|
||||
ReadCount: r.ReadCount, CreatedCount: r.CreatedCount, UpdatedCount: r.UpdatedCount,
|
||||
SkippedCount: r.SkippedCount, FailedCount: r.FailedCount, ErrorMessage: r.ErrorMessage,
|
||||
SkippedCount: r.SkippedCount, FailedCount: r.FailedCount,
|
||||
MissingMarkedCount: r.MissingMarkedCount, RecoveredCount: r.RecoveredCount, ErrorMessage: r.ErrorMessage,
|
||||
StartedAt: r.StartedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
if r.FinishedAt != nil {
|
||||
|
||||
@@ -43,10 +43,14 @@ type Report struct {
|
||||
// MissingMarked is the number of yeeke_return_item rows flipped from
|
||||
// "ok" to "missing" by this run's completion (#338). It is only ever
|
||||
// non-zero on a run whose page walk finished naturally, wrote no
|
||||
// failures, and stayed under the 20% safety-valve threshold; it is not
|
||||
// persisted as a run column, only reported here and folded into
|
||||
// error_message when the safety valve skips marking.
|
||||
// failures, and stayed under the 20% safety-valve threshold. Persisted
|
||||
// as yeeke_sync_run.missing_marked_count; when the safety valve skips
|
||||
// marking it stays 0 and the reason goes to error_message.
|
||||
MissingMarked int
|
||||
// Recovered is the number of yeeke_return_item rows that were "missing"
|
||||
// before this run and reappeared in it, flipping back to "ok" (#338).
|
||||
// Persisted as yeeke_sync_run.recovered_count.
|
||||
Recovered int
|
||||
}
|
||||
|
||||
// knownClaimStatuses lists the status values the sync code currently
|
||||
@@ -184,7 +188,7 @@ func (s *Service) run(ctx context.Context, r *models.YeekeSyncRun) (Report, erro
|
||||
var runErr error
|
||||
defer func() {
|
||||
now := time.Now().UTC()
|
||||
updates := map[string]any{"status": rep.Status, "total_pages": rep.TotalPages, "read_count": rep.Read, "created_count": rep.Created, "updated_count": rep.Updated, "skipped_count": rep.Skipped, "failed_count": rep.Failed, "error_message": errMsg, "active_slot": nil, "lease_owner": "", "lease_expires_at": nil, "finished_at": now}
|
||||
updates := map[string]any{"status": rep.Status, "total_pages": rep.TotalPages, "read_count": rep.Read, "created_count": rep.Created, "updated_count": rep.Updated, "skipped_count": rep.Skipped, "failed_count": rep.Failed, "missing_marked_count": rep.MissingMarked, "recovered_count": rep.Recovered, "error_message": errMsg, "active_slot": nil, "lease_owner": "", "lease_expires_at": nil, "finished_at": now}
|
||||
if rep.Status == "succeeded" {
|
||||
updates["last_success_at"] = now
|
||||
}
|
||||
@@ -235,7 +239,7 @@ func (s *Service) run(ctx context.Context, r *models.YeekeSyncRun) (Report, erro
|
||||
}
|
||||
seen[finger] = true
|
||||
for _, x := range p.Records {
|
||||
created, updated, err := s.upsert(ctx, x)
|
||||
created, updated, recovered, err := s.upsert(ctx, x)
|
||||
if err != nil {
|
||||
rep.Failed++
|
||||
if firstWriteErr == nil {
|
||||
@@ -244,6 +248,7 @@ func (s *Service) run(ctx context.Context, r *models.YeekeSyncRun) (Report, erro
|
||||
continue
|
||||
}
|
||||
rep.Read++
|
||||
rep.Recovered += recovered
|
||||
if created {
|
||||
rep.Created++
|
||||
} else if updated {
|
||||
@@ -354,7 +359,10 @@ func pageFingerprint(p yeekeclient.ReturnPage) string {
|
||||
h := sha256.Sum256(b)
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
func (s *Service) upsert(ctx context.Context, p yeekeclient.ReturnPackage) (bool, bool, error) {
|
||||
|
||||
// upsert writes one package and its items. recovered counts items that were
|
||||
// marked "missing" before and reappeared in this run (#338).
|
||||
func (s *Service) upsert(ctx context.Context, p yeekeclient.ReturnPackage) (created bool, updated bool, recovered int, err error) {
|
||||
now := time.Now().UTC()
|
||||
key := packageKey(p)
|
||||
status := external(p.Status)
|
||||
@@ -363,7 +371,7 @@ func (s *Service) upsert(ctx context.Context, p yeekeclient.ReturnPackage) (bool
|
||||
// behind without its items. Rows are always inserted fully populated —
|
||||
// MySQL strict mode (NO_ZERO_DATE) rejects the zero last_synced_at an
|
||||
// empty placeholder insert would carry, which failed every record.
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var row models.YeekeReturnPackage
|
||||
e := tx.Where("external_id = ?", key).First(&row).Error
|
||||
isNew = errors.Is(e, gorm.ErrRecordNotFound)
|
||||
@@ -411,6 +419,9 @@ func (s *Service) upsert(ctx context.Context, p yeekeclient.ReturnPackage) (bool
|
||||
case ie != nil:
|
||||
return ie
|
||||
default:
|
||||
if existing.SyncStatus == "missing" {
|
||||
recovered++
|
||||
}
|
||||
iv := map[string]any{"item_id": item.ItemID, "variation_id": item.VariationID, "item_name": item.ItemName, "variation_name": item.VariationName, "image": item.Image, "quantity": item.Quantity, "last_synced_at": now, "sync_status": "ok", "missing_since": (*time.Time)(nil)}
|
||||
if e = tx.Model(&existing).Updates(iv).Error; e != nil {
|
||||
return e
|
||||
@@ -420,7 +431,7 @@ func (s *Service) upsert(ctx context.Context, p yeekeclient.ReturnPackage) (bool
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
return false, false, 0, err
|
||||
}
|
||||
return isNew, !isNew, nil
|
||||
return isNew, !isNew, recovered, nil
|
||||
}
|
||||
|
||||
@@ -366,9 +366,24 @@ func TestReappearingRecordRecoversFromMissing(t *testing.T) {
|
||||
}))
|
||||
defer srv3.Close()
|
||||
s.client, _ = yeekeclient.New(srv3.URL)
|
||||
if _, err := s.Sync(context.Background(), "manual"); err != nil {
|
||||
rep3, err := s.Sync(context.Background(), "manual")
|
||||
if err != nil {
|
||||
t.Fatalf("run 3 (recovery): %v", err)
|
||||
}
|
||||
if rep3.Recovered != 1 || rep3.MissingMarked != 0 {
|
||||
t.Fatalf("run3 Recovered=%d MissingMarked=%d, want 1 and 0", rep3.Recovered, rep3.MissingMarked)
|
||||
}
|
||||
// Both counters are persisted on the run rows shown by the sync-runs page.
|
||||
var run2, run3 models.YeekeSyncRun
|
||||
if e := db.First(&run2, rep2.RunID).Error; e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if e := db.First(&run3, rep3.RunID).Error; e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if run2.MissingMarkedCount != 1 || run2.RecoveredCount != 0 || run3.MissingMarkedCount != 0 || run3.RecoveredCount != 1 {
|
||||
t.Fatalf("persisted counts run2=(%d,%d) run3=(%d,%d), want (1,0) and (0,1)", run2.MissingMarkedCount, run2.RecoveredCount, run3.MissingMarkedCount, run3.RecoveredCount)
|
||||
}
|
||||
|
||||
// A fresh variable is used here (not the p1 declared above): GORM's Scan
|
||||
// does not reset an already non-nil pointer field to nil when the new
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"go-admin/app/goauto/migrations"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
"gorm.io/gorm"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// #338: adds yeeke_sync_run.missing_marked_count and recovered_count (registered in
|
||||
// migrations.MigratedModels) on databases whose earlier versions are already
|
||||
// recorded in sys_migration.
|
||||
func init() {
|
||||
_, f, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(f), migrateYeekeSyncRunMissingCounts)
|
||||
}
|
||||
func migrateYeekeSyncRunMissingCounts(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := migrations.Migrate(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
adminmodels "go-admin/app/admin/models"
|
||||
"go-admin/app/goauto/access"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
migrationmodels "go-admin/cmd/migrate/migration/models"
|
||||
common "go-admin/common/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const afterSalesInitialPasswordEnv = "GOAUTO_AFTER_SALES_INITIAL_PASSWORD"
|
||||
|
||||
var afterSalesUsers = []string{"zengyt", "huangyj", "zhuyt", "wangxy"}
|
||||
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateAfterSalesRole)
|
||||
}
|
||||
|
||||
// migrateAfterSalesRole creates the internal 售后 role and accounts. It
|
||||
// copies the current purchaser menu/API grants, so the two roles stay aligned
|
||||
// for the current product surface (including yeeke 退货同步 and 退货商品).
|
||||
// The initial password is deliberately supplied only at migration time via an
|
||||
// environment variable; it is never stored in source, logs, or issue text.
|
||||
func migrateAfterSalesRole(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
role, err := ensureAfterSalesRole(tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := clonePurchaserPermissions(tx, role.RoleId); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureAfterSalesUsers(tx, role.RoleId); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func ensureAfterSalesRole(db *gorm.DB) (migrationmodels.SysRole, error) {
|
||||
var purchaser migrationmodels.SysRole
|
||||
if err := db.Where("role_key = ?", access.RolePurchaser).First(&purchaser).Error; err != nil {
|
||||
return migrationmodels.SysRole{}, fmt.Errorf("find purchaser role: %w", err)
|
||||
}
|
||||
|
||||
role := migrationmodels.SysRole{}
|
||||
if err := db.Where("role_key = ?", access.RoleAfterSales).
|
||||
Assign(migrationmodels.SysRole{
|
||||
RoleName: "售后", Status: "2", RoleSort: purchaser.RoleSort + 1,
|
||||
Admin: false, DataScope: purchaser.DataScope,
|
||||
Remark: "GoAuto 售后业务角色(系统维护)",
|
||||
}).FirstOrCreate(&role, migrationmodels.SysRole{RoleKey: access.RoleAfterSales}).Error; err != nil {
|
||||
return migrationmodels.SysRole{}, err
|
||||
}
|
||||
return role, nil
|
||||
}
|
||||
|
||||
func clonePurchaserPermissions(db *gorm.DB, roleID int) error {
|
||||
var purchaser migrationmodels.SysRole
|
||||
if err := db.Where("role_key = ?", access.RolePurchaser).First(&purchaser).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Exec(`
|
||||
INSERT INTO sys_role_menu (role_id, menu_id)
|
||||
SELECT ?, source.menu_id
|
||||
FROM sys_role_menu AS source
|
||||
WHERE source.role_id = ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sys_role_menu AS target
|
||||
WHERE target.role_id = ? AND target.menu_id = source.menu_id
|
||||
)`, roleID, purchaser.RoleId, roleID).Error; err != nil {
|
||||
return fmt.Errorf("clone purchaser menus: %w", err)
|
||||
}
|
||||
if err := db.Exec(`
|
||||
INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5)
|
||||
SELECT source.ptype, ?, source.v1, source.v2, source.v3, source.v4, source.v5
|
||||
FROM casbin_rule AS source
|
||||
WHERE source.ptype = 'p' AND source.v0 = ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM casbin_rule AS target
|
||||
WHERE target.ptype = source.ptype AND target.v0 = ?
|
||||
AND target.v1 = source.v1 AND target.v2 = source.v2
|
||||
AND COALESCE(target.v3, '') = COALESCE(source.v3, '')
|
||||
AND COALESCE(target.v4, '') = COALESCE(source.v4, '')
|
||||
AND COALESCE(target.v5, '') = COALESCE(source.v5, '')
|
||||
)`, access.RoleAfterSales, access.RolePurchaser, access.RoleAfterSales).Error; err != nil {
|
||||
return fmt.Errorf("clone purchaser API policies: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureAfterSalesUsers(db *gorm.DB, roleID int) error {
|
||||
missing := make([]string, 0, len(afterSalesUsers))
|
||||
for _, username := range afterSalesUsers {
|
||||
var existing adminmodels.SysUser
|
||||
err := db.Unscoped().Where("username = ?", username).First(&existing).Error
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
missing = append(missing, username)
|
||||
case err != nil:
|
||||
return fmt.Errorf("find after-sales user %q: %w", username, err)
|
||||
case existing.RoleId != roleID:
|
||||
return fmt.Errorf("after-sales user %q already exists with another role", username)
|
||||
}
|
||||
}
|
||||
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
password := strings.TrimSpace(os.Getenv(afterSalesInitialPasswordEnv))
|
||||
if password == "" {
|
||||
return fmt.Errorf("%s must be set when creating after-sales users", afterSalesInitialPasswordEnv)
|
||||
}
|
||||
// Validate before creating any account; the value itself is never logged or
|
||||
// written to a repository artifact.
|
||||
if len(password) < 1 {
|
||||
return errors.New("after-sales initial password is empty")
|
||||
}
|
||||
for _, username := range missing {
|
||||
user := adminmodels.SysUser{
|
||||
Username: username, Password: password, NickName: username,
|
||||
RoleId: roleID, Status: "2", Remark: "GoAuto 售后账号",
|
||||
}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
return fmt.Errorf("create after-sales user %q: %w", username, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
adminmodels "go-admin/app/admin/models"
|
||||
"go-admin/app/goauto/access"
|
||||
migrationmodels "go-admin/cmd/migrate/migration/models"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestEnsureAfterSalesRoleAndUsersIsIdempotent(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&migrationmodels.SysRole{}, &migrationmodels.SysMenu{}, &adminmodels.SysUser{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec(`CREATE TABLE IF NOT EXISTS sys_role_menu (role_id integer NOT NULL, menu_id integer NOT NULL, PRIMARY KEY (role_id, menu_id))`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec(`CREATE TABLE IF NOT EXISTS casbin_rule (id integer PRIMARY KEY AUTOINCREMENT, ptype varchar(100), v0 varchar(100), v1 varchar(100), v2 varchar(100), v3 varchar(100), v4 varchar(100), v5 varchar(100))`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
purchaser := migrationmodels.SysRole{RoleName: "采购员", RoleKey: access.RolePurchaser, Status: "2", RoleSort: 20, DataScope: "1"}
|
||||
if err = db.Create(&purchaser).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec(`INSERT INTO sys_menu (menu_name, title, menu_type, parent_id) VALUES ('GoAutoYeekeReturns', 'yeeke 退货包裹', 'C', 0)`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec(`INSERT INTO sys_role_menu (role_id, menu_id) SELECT ?, menu_id FROM sys_menu`, purchaser.RoleId).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec(`INSERT INTO casbin_rule (ptype, v0, v1, v2) VALUES ('p', ?, '/api/admin/v1/yeeke-returns', 'GET')`, access.RolePurchaser).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
oldPassword := os.Getenv(afterSalesInitialPasswordEnv)
|
||||
defer os.Setenv(afterSalesInitialPasswordEnv, oldPassword)
|
||||
if err = os.Setenv(afterSalesInitialPasswordEnv, "test-only-password"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
role, err := ensureAfterSalesRole(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = clonePurchaserPermissions(db, role.RoleId); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = ensureAfterSalesUsers(db, role.RoleId); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = clonePurchaserPermissions(db, role.RoleId); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = ensureAfterSalesUsers(db, role.RoleId); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err = db.Model(&adminmodels.SysUser{}).Where("role_id = ?", role.RoleId).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != int64(len(afterSalesUsers)) {
|
||||
t.Fatalf("got %d after-sales users, want %d", count, len(afterSalesUsers))
|
||||
}
|
||||
var user adminmodels.SysUser
|
||||
if err = db.Where("username = ?", afterSalesUsers[0]).First(&user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(user.Password), []byte("test-only-password")) != nil {
|
||||
t.Fatal("initial password was not stored as a bcrypt hash")
|
||||
}
|
||||
if err = db.Table("casbin_rule").Where("ptype = 'p' AND v0 = ?", access.RoleAfterSales).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("got %d after-sales policies, want 1", count)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"go-admin/app/goauto/access"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// #341 follow-up: the after-sales role was created before #338 added the
|
||||
// return-match API surface. Add only the reviewed return-match grants so the
|
||||
// existing role can use the new SYB action without recreating or changing its
|
||||
// users.
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateAfterSalesReturnMatch)
|
||||
}
|
||||
|
||||
type afterSalesReturnMatchAPI struct {
|
||||
ID int `gorm:"column:id;primaryKey;autoIncrement"`
|
||||
Title string `gorm:"column:title;size:128"`
|
||||
Path string `gorm:"column:path;size:128"`
|
||||
Type string `gorm:"column:type;size:16"`
|
||||
Action string `gorm:"column:action;size:16"`
|
||||
}
|
||||
|
||||
func (afterSalesReturnMatchAPI) TableName() string { return "sys_api" }
|
||||
|
||||
type afterSalesReturnMatchPolicy struct {
|
||||
ID uint `gorm:"column:id;primaryKey;autoIncrement"`
|
||||
Ptype string `gorm:"column:ptype;size:100"`
|
||||
V0 string `gorm:"column:v0;size:100"`
|
||||
V1 string `gorm:"column:v1;size:100"`
|
||||
V2 string `gorm:"column:v2;size:100"`
|
||||
V3 string `gorm:"column:v3;size:100"`
|
||||
V4 string `gorm:"column:v4;size:100"`
|
||||
V5 string `gorm:"column:v5;size:100"`
|
||||
}
|
||||
|
||||
func (afterSalesReturnMatchPolicy) TableName() string { return "casbin_rule" }
|
||||
|
||||
func migrateAfterSalesReturnMatch(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
var role struct {
|
||||
RoleID int `gorm:"column:role_id"`
|
||||
}
|
||||
if err := tx.Table("sys_role").Select("role_id").Where("role_key = ?", access.RoleAfterSales).First(&role).Error; err != nil {
|
||||
return fmt.Errorf("find after-sales role: %w", err)
|
||||
}
|
||||
for _, permission := range access.AdminAPIs {
|
||||
if len(permission.Path) < len("/api/admin/v1/return-matches") || permission.Path[:len("/api/admin/v1/return-matches")] != "/api/admin/v1/return-matches" {
|
||||
continue
|
||||
}
|
||||
api := afterSalesReturnMatchAPI{Path: permission.Path, Action: permission.Method}
|
||||
if err := tx.Where("path = ? AND action = ?", permission.Path, permission.Method).
|
||||
Assign(afterSalesReturnMatchAPI{Title: permission.Title, Path: permission.Path, Action: permission.Method, Type: "BUS"}).
|
||||
FirstOrCreate(&api).Error; err != nil {
|
||||
return fmt.Errorf("ensure return-match API: %w", err)
|
||||
}
|
||||
policy := afterSalesReturnMatchPolicy{Ptype: "p", V0: access.RoleAfterSales, V1: permission.Path, V2: permission.Method}
|
||||
var count int64
|
||||
if err := tx.Model(&afterSalesReturnMatchPolicy{}).Where("ptype = ? AND v0 = ? AND v1 = ? AND v2 = ?", policy.Ptype, policy.V0, policy.V1, policy.V2).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
if err := tx.Create(&policy).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/access"
|
||||
migrationmodels "go-admin/cmd/migrate/migration/models"
|
||||
common "go-admin/common/models"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestAfterSalesReturnMatchCatalogAndGrants(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&migrationmodels.SysRole{}, &afterSalesReturnMatchAPI{}, &afterSalesReturnMatchPolicy{}, &common.Migration{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Create(&migrationmodels.SysRole{RoleKey: access.RoleAfterSales}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, version := range []string{"test-first", "test-repeat"} {
|
||||
if err = migrateAfterSalesReturnMatch(db, version); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var apis []afterSalesReturnMatchAPI
|
||||
if err = db.Find(&apis).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(apis) != 8 {
|
||||
t.Fatalf("API count=%d", len(apis))
|
||||
}
|
||||
for _, api := range apis {
|
||||
if api.Path == "" || api.Action == "" {
|
||||
t.Fatalf("empty API path/action: id=%d", api.ID)
|
||||
}
|
||||
}
|
||||
var count int64
|
||||
if err = db.Model(&afterSalesReturnMatchPolicy{}).Where("v0 = ?", access.RoleAfterSales).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 8 {
|
||||
t.Fatalf("policy count=%d", count)
|
||||
}
|
||||
// Simulate the original deployed catalogue bug, then repair and repeat.
|
||||
if err = db.Model(&afterSalesReturnMatchAPI{}).Where("id > 0").Updates(map[string]any{"path": "", "action": ""}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, version := range []string{"repair-first", "repair-repeat"} {
|
||||
if err = migrateFixReturnMatchAPICatalog(db, version); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err = db.Model(&afterSalesReturnMatchAPI{}).Where("path = '' OR action = ''").Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("unrepaired API rows=%d", count)
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"go-admin/app/goauto/access"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Repair the API catalogue rows created by 1789801200000 before the path and
|
||||
// action fields were populated. This is idempotent and only touches the
|
||||
// return-match endpoints.
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateFixReturnMatchAPICatalog)
|
||||
}
|
||||
|
||||
func migrateFixReturnMatchAPICatalog(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
for _, permission := range access.AdminAPIs {
|
||||
if len(permission.Path) < len("/api/admin/v1/return-matches") || permission.Path[:len("/api/admin/v1/return-matches")] != "/api/admin/v1/return-matches" {
|
||||
continue
|
||||
}
|
||||
if err := tx.Table("sys_api").Where("title = ?", permission.Title).Updates(map[string]any{
|
||||
"title": permission.Title, "path": permission.Path, "action": permission.Method, "type": "BUS",
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
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), migrateSYBSessionAuthLease)
|
||||
}
|
||||
|
||||
// The lease is additive and contains no credentials. It serializes automatic
|
||||
// SYB re-authentication across sync and purchase writeback workers.
|
||||
func migrateSYBSessionAuthLease(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.AutoMigrate(&models.SYBSessionAuthLease{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
@@ -91,7 +91,7 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
canPurchase() { const roles = this.$store.getters.roles || []; return roles.includes('admin') || roles.includes('purchaser') },
|
||||
canPurchase() { const roles = this.$store.getters.roles || []; return roles.includes('admin') || roles.includes('purchaser') || roles.includes('after_sales') },
|
||||
relatedPurchaseCandidates() { return this.related.selected.filter(row => this.isRelatedPurchaseCandidate(row)) },
|
||||
stockColorOptions() { return this.stockSpecValues('color').filter(item => item.priceCent !== null && item.priceCent !== undefined && item.priceCent >= 0) },
|
||||
stockSizeOptions() { return this.stockSpecValues('size') },
|
||||
|
||||
@@ -3,16 +3,21 @@
|
||||
<template #wrapper>
|
||||
<el-card class="page-card" shadow="never">
|
||||
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="search">
|
||||
<el-form-item v-if="canPurchase"><span class="selection-count" aria-live="polite">已选择 <strong>{{ selectedProducts.length }}</strong> 条</span></el-form-item>
|
||||
<el-form-item label="店铺">
|
||||
<el-autocomplete v-model="query.shopName" class="shop-input" placeholder="选择或输入店铺" clearable value-key="displayName" :fetch-suggestions="suggestSybShops">
|
||||
<template #default="{ item }"><div class="shop-option" :title="item.displayName"><span class="shop-option-name">{{ item.displayName }}</span><el-tag v-if="!item.enabled" size="small" type="info" effect="plain">已停用</el-tag></div></template>
|
||||
</el-autocomplete>
|
||||
</el-form-item>
|
||||
<el-form-item label="订单号"><el-input v-model="query.orderCodesText" type="textarea" :autosize="{ minRows: 1, maxRows: 4 }" resize="none" class="order-codes-input" placeholder="每行一个订单号,最多 100 个" @keydown.ctrl.enter.prevent="search" /></el-form-item>
|
||||
<el-form-item label="解析状态"><el-select v-model="query.parseStatus" clearable placeholder="全部" style="width: 110px"><el-option label="成功" value="success" /><el-option label="失败" value="failed" /></el-select></el-form-item>
|
||||
<el-form-item label="处理阶段"><el-select v-model="query.processStage" clearable placeholder="全部" style="width: 132px"><el-option v-for="item in processStageOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
|
||||
<el-form-item class="toolbar-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button><el-tooltip v-if="canPurchase" :content="aiMatchButtonReason" :disabled="!aiMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="specMatchLoading" :disabled="purchaseReadinessLoading || specMatchLoading || aiMatchCandidates.length === 0" @click="runBatchSpecMatch">AI 匹配<span class="action-count">{{ aiMatchCandidates.length }}</span></el-button></span></el-tooltip><el-button v-if="canPurchase" type="success" plain :disabled="imageSearchRows.length === 0" @click="openImageSearchBatch">图搜采集<span class="action-count">{{ imageSearchRows.length }}</span></el-button><el-button v-if="canPurchase" type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || collectionCandidates.length === 0" @click="openCollectionBatch">创建采集<span class="action-count action-count-primary">{{ collectionCandidates.length }}</span></el-button><el-button v-if="canPurchase" type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || purchaseCandidates.length === 0" @click="openPurchaseBatch">创建采购<span class="action-count action-count-primary">{{ purchaseCandidates.length }}</span></el-button><el-button v-if="canPurchase" :loading="returnMatchBatchLoading" :disabled="purchaseReadinessLoading || returnMatchBatchLoading || returnMatchCandidateIds.length === 0" @click="runBatchMatchReturns">匹配退货<span class="action-count">{{ returnMatchCandidateIds.length }}</span></el-button></el-form-item>
|
||||
<div class="search-row search-row-primary">
|
||||
<el-form-item v-if="canPurchase"><span class="selection-count" aria-live="polite">已选择 <strong>{{ selectedProducts.length }}</strong> 条</span></el-form-item>
|
||||
<el-form-item label="店铺">
|
||||
<el-autocomplete v-model="query.shopName" class="shop-input" placeholder="选择或输入店铺" clearable value-key="displayName" :fetch-suggestions="suggestSybShops">
|
||||
<template #default="{ item }"><div class="shop-option" :title="item.displayName"><span class="shop-option-name">{{ item.displayName }}</span><el-tag v-if="!item.enabled" size="small" type="info" effect="plain">已停用</el-tag></div></template>
|
||||
</el-autocomplete>
|
||||
</el-form-item>
|
||||
<el-form-item label="订单号"><el-input v-model="query.orderCodesText" type="textarea" :autosize="{ minRows: 1, maxRows: 4 }" resize="none" class="order-codes-input" placeholder="每行一个订单号,最多 100 个" @keydown.ctrl.enter.prevent="search" /></el-form-item>
|
||||
<el-form-item label="解析状态"><el-select v-model="query.parseStatus" clearable placeholder="全部" style="width: 110px"><el-option label="成功" value="success" /><el-option label="失败" value="failed" /></el-select></el-form-item>
|
||||
<el-form-item label="处理阶段"><el-select v-model="query.processStage" clearable placeholder="全部" style="width: 132px"><el-option v-for="item in processStageOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
|
||||
<el-form-item class="toolbar-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button><el-tooltip v-if="canPurchase" :content="aiMatchButtonReason" :disabled="!aiMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="specMatchLoading" :disabled="purchaseReadinessLoading || specMatchLoading || aiMatchCandidates.length === 0 || aiMatchButtonOverLimit" @click="runBatchSpecMatch">AI 匹配<span class="action-count">{{ aiMatchCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="imageSearchButtonReason" :disabled="!imageSearchButtonReason" placement="top"><span class="action-button-wrap"><el-button type="success" plain :disabled="imageSearchRows.length === 0 || imageSearchButtonOverLimit" @click="openImageSearchBatch">图搜采集<span class="action-count">{{ imageSearchRows.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="collectionButtonReason" :disabled="!collectionButtonReason" placement="top"><span class="action-button-wrap"><el-button type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || collectionCandidates.length === 0 || collectionButtonOverLimit" @click="openCollectionBatch">创建采集<span class="action-count action-count-primary">{{ collectionCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="purchaseButtonReason" :disabled="!purchaseButtonReason" placement="top"><span class="action-button-wrap"><el-button type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || purchaseCandidates.length === 0 || purchaseButtonOverLimit" @click="openPurchaseBatch">创建采购<span class="action-count action-count-primary">{{ purchaseCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="returnMatchButtonReason" :disabled="!returnMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="returnMatchBatchLoading" :disabled="purchaseReadinessLoading || returnMatchBatchLoading || returnMatchCandidateIds.length === 0 || returnMatchButtonOverLimit" @click="runBatchMatchReturns">匹配退货<span class="action-count">{{ returnMatchCandidateIds.length }}</span></el-button></span></el-tooltip></el-form-item>
|
||||
</div>
|
||||
<div class="search-row search-row-secondary">
|
||||
<el-form-item label="创建时间"><el-date-picker v-model="query.createdAtRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" clearable /></el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<el-alert v-if="canPurchase" title="先完成并保存规格匹配,商品才可以创建采购;AI 匹配只处理已解析规格、已关联 PDD 且具备完整可售 SKU 组合的明细。表头全选仅作用于当前页。" type="info" :closable="false" show-icon class="notice compact-notice" />
|
||||
<el-table ref="productTable" v-loading="loading" :data="products" row-key="id" border stripe empty-text="暂无 SYB 商品明细" @selection-change="handleSelectionChange">
|
||||
@@ -63,6 +68,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<el-alert v-if="returnMatchByProductId[row.id].syncStatus === 'missing'" title="退货已不在 yeeke 列表" type="error" :closable="false" show-icon class="return-missing-alert" />
|
||||
<el-alert v-if="isDeadlinePassed(returnMatchByProductId[row.id].destroyDeadline)" title="退货已过销毁截止" type="error" :closable="false" show-icon class="return-missing-alert" />
|
||||
<div class="quick-link-actions">
|
||||
<el-button type="primary" link @click="openMatchDetail(returnMatchByProductId[row.id].id)">查看对比</el-button>
|
||||
<el-button v-if="canPurchase" type="primary" link @click="openMatchDetail(returnMatchByProductId[row.id].id, true)">备注</el-button>
|
||||
@@ -74,7 +80,7 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="132" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openDetail(row.id)">详情</el-button><el-button v-if="canPurchase && isPurchaseCandidate(row)" type="primary" link @click="openSinglePurchase(row)">采购</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
|
||||
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :page-sizes="[20, 50, 100, 200, 500]" :total="total" @pagination="load" />
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="quickPicker.open" :title="quickPicker.row ? '选择采集手机' : '切换采集手机'" width="460px" :close-on-click-modal="false" @close="quickCancelPicker">
|
||||
@@ -223,6 +229,7 @@
|
||||
<div class="split-col">
|
||||
<h3 class="section-title">yeeke 退货商品</h3>
|
||||
<el-alert v-if="matchDetail.data.yeeke && matchDetail.data.yeeke.syncStatus === 'missing'" title="退货已不在 yeeke 列表" type="error" :closable="false" show-icon class="notice" />
|
||||
<el-alert v-if="matchDetail.data.yeeke && isDeadlinePassed(matchDetail.data.yeeke.destroyDeadline)" title="退货已过销毁截止,请核实退货是否仍在库" type="error" :closable="false" show-icon class="notice" />
|
||||
<el-descriptions :column="1" border size="small" v-if="matchDetail.data.yeeke">
|
||||
<el-descriptions-item label="退货订单号">{{ matchDetail.data.yeeke.orderSn }}</el-descriptions-item>
|
||||
<el-descriptions-item label="商品ID">{{ matchDetail.data.yeeke.itemId }}</el-descriptions-item>
|
||||
@@ -331,6 +338,12 @@ import PddProductDetailDrawer from '../pdd-products/PddProductDetailDrawer.vue'
|
||||
|
||||
// 与服务端 image_search.go 的 imageSearchMaxBatchTasks 保持一致(单批去重后任务数上限,#280)。
|
||||
const IMAGE_SEARCH_MAX_BATCH_TASKS = 50
|
||||
// #339: 各批量按钮的服务端上限,用于勾选数超限时禁用按钮并提示原因。
|
||||
// 创建采购 / AI 匹配:purchase/batch.go maxBatchPurchaseItems;创建采集(去重后 PDD 商品数):
|
||||
// task/admin_service.go BatchCreate 的 100 上限;匹配退货:returnmatch/service.go maxBatchMatchItems。
|
||||
const PURCHASE_BATCH_MAX = 100
|
||||
const COLLECTION_BATCH_MAX = 100
|
||||
const RETURN_MATCH_BATCH_MAX = 500
|
||||
|
||||
export default {
|
||||
name: 'GoAutoSybProducts',
|
||||
@@ -351,7 +364,7 @@ export default {
|
||||
imageSearchBatch: this.emptyImageSearchBatch(),
|
||||
collectionBatchData: { ruleId: null, deviceId: null },
|
||||
collectionBatchRules: { ruleId: [{ required: true, message: '请选择采集规则', trigger: 'change' }] },
|
||||
query: { page: 1, pageSize: 20, shopName: '', orderCodesText: '', parseStatus: '', processStage: '' },
|
||||
query: { page: 1, pageSize: 200, shopName: '', orderCodesText: '', parseStatus: '', processStage: '', createdAtRange: [] },
|
||||
returnMatchByProductId: {},
|
||||
returnMatchLoading: false,
|
||||
returnMatchBatchLoading: false,
|
||||
@@ -366,24 +379,38 @@ export default {
|
||||
computed: {
|
||||
purchaseDeviceUnavailable() { return !!this.purchaseDialog.deviceId && !this.purchaseDevices.some(device => device.id === this.purchaseDialog.deviceId) },
|
||||
purchaseImageSearchLinkedCount() { return this.purchaseDialog.items.filter(item => item.imageSearchLinked).length },
|
||||
canPurchase() { const roles = this.$store.getters.roles || []; return roles.includes('admin') || roles.includes('purchaser') },
|
||||
canPurchase() { const roles = this.$store.getters.roles || []; return roles.includes('admin') || roles.includes('purchaser') || roles.includes('after_sales') },
|
||||
processStageOptions() { return [{ value: 'manual_action', label: '待人工处理' }, { value: 'pdd_unlinked', label: '未关联 PDD' }, { value: 'pdd_pending', label: 'PDD 待采集' }, { value: 'pdd_collecting', label: 'PDD 采集中' }, { value: 'pdd_collection_failed', label: 'PDD 采集失败' }, { value: 'color_mapping', label: '规格待匹配' }, { value: 'purchase_ready', label: '可创建采购' }, { value: 'task_created', label: '已创建任务' }, { value: 'purchase_succeeded', label: '采购成功' }, { value: 'order_review', label: '待人工核对' }, { value: 'return_pending', label: '退货待确认' }, { value: 'return_used', label: '已用退货' }] },
|
||||
returnMatchCandidateIds() { return this.selectedProducts.filter(row => this.isReturnMatchCandidate(row)).map(row => row.id) },
|
||||
aiMatchCandidates() { return this.selectedProducts.filter(row => this.purchaseReady(row).aiMatchEligible === true) },
|
||||
aiMatchButtonOverLimit() { return this.aiMatchCandidates.length > PURCHASE_BATCH_MAX },
|
||||
aiMatchButtonReason() {
|
||||
if (this.purchaseReadinessLoading) return '正在检查 AI 匹配资格'
|
||||
if (!this.selectedProducts.length) return '请先勾选当前页中可处理的明细'
|
||||
if (this.aiMatchButtonOverLimit) return `一次最多 ${PURCHASE_BATCH_MAX} 条`
|
||||
if (this.aiMatchCandidates.length) return ''
|
||||
const reasons = [...new Set(this.selectedProducts.map(row => this.purchaseReady(row).aiMatchDisabledReason).filter(Boolean))]
|
||||
return reasons[0] || '所选明细不满足 AI 匹配前提'
|
||||
},
|
||||
purchaseCandidates() { return this.selectedProducts.filter(row => this.isPurchaseCandidate(row)) },
|
||||
// #339: 创建采购上限保持 100(服务端 maxBatchPurchaseItems 不放宽),超出勾选数时禁用并提示。
|
||||
purchaseButtonOverLimit() { return this.purchaseCandidates.length > PURCHASE_BATCH_MAX },
|
||||
purchaseButtonReason() { return this.purchaseButtonOverLimit ? `一次最多 ${PURCHASE_BATCH_MAX} 条` : '' },
|
||||
collectionCandidateRows() { return this.selectedProducts.filter(row => this.isCollectionCandidate(row)) },
|
||||
collectionCandidates() { return [...new Set(this.collectionCandidateRows.map(row => this.purchaseReady(row).pddProductId))] },
|
||||
// #339: 创建采集按去重后的 PDD 商品数计数,与服务端 task.BatchCreate 的 100 上限口径一致。
|
||||
collectionButtonOverLimit() { return this.collectionCandidates.length > COLLECTION_BATCH_MAX },
|
||||
collectionButtonReason() { return this.collectionButtonOverLimit ? `一次最多 ${COLLECTION_BATCH_MAX} 个 PDD 商品` : '' },
|
||||
imageSearchRows() { return this.selectedProducts.filter(row => row.shopeeProductId && row.imageUrl) },
|
||||
// 服务端按去重后的蝦皮商品数创建任务(见 image_search.go 的 imageSearchMaxBatchTasks),
|
||||
// 这里用相同口径预估,避免提交后才发现超限。
|
||||
imageSearchMaxBatchTasks() { return IMAGE_SEARCH_MAX_BATCH_TASKS },
|
||||
imageSearchButtonTaskCount() { return new Set(this.imageSearchRows.map(row => row.shopeeProductId)).size },
|
||||
imageSearchButtonOverLimit() { return this.imageSearchButtonTaskCount > IMAGE_SEARCH_MAX_BATCH_TASKS },
|
||||
imageSearchButtonReason() { return this.imageSearchButtonOverLimit ? `一次最多 ${IMAGE_SEARCH_MAX_BATCH_TASKS} 个蝦皮商品` : '' },
|
||||
// #339: 匹配退货服务端上限(returnmatch.maxBatchMatchItems),超出直接勾选数即禁用。
|
||||
returnMatchButtonOverLimit() { return this.returnMatchCandidateIds.length > RETURN_MATCH_BATCH_MAX },
|
||||
returnMatchButtonReason() { return this.returnMatchButtonOverLimit ? `一次最多 ${RETURN_MATCH_BATCH_MAX} 条` : '' },
|
||||
imageSearchBatchTaskCount() { return new Set(this.imageSearchBatch.rows.map(row => row.shopeeProductId)).size },
|
||||
imageSearchBatchOverLimit() { return this.imageSearchBatchTaskCount > IMAGE_SEARCH_MAX_BATCH_TASKS },
|
||||
imageSearchBatchDurationText() {
|
||||
@@ -410,7 +437,8 @@ export default {
|
||||
this.$refs.productTable?.clearSelection()
|
||||
const requestOptions = allowNetworkRetry ? { suppressNetworkError: true } : {}
|
||||
try {
|
||||
const r = await listSybProducts({ page: this.query.page, pageSize: this.query.pageSize, shopName: this.query.shopName.trim(), orderCodes: orderCodes.join(','), parseStatus: this.query.parseStatus, processStage: this.query.processStage }, requestOptions)
|
||||
const [createdFrom = '', createdTo = ''] = this.query.createdAtRange || []
|
||||
const r = await listSybProducts({ page: this.query.page, pageSize: this.query.pageSize, shopName: this.query.shopName.trim(), orderCodes: orderCodes.join(','), parseStatus: this.query.parseStatus, processStage: this.query.processStage, createdFrom, createdTo }, requestOptions)
|
||||
if (generation !== this.loadGeneration) return
|
||||
this.products = r.data.items
|
||||
this.total = r.data.total
|
||||
@@ -430,7 +458,7 @@ export default {
|
||||
}
|
||||
},
|
||||
search() { this.query.page = 1; this.load() },
|
||||
reset() { this.query = { page: 1, pageSize: 20, shopName: '', orderCodesText: '', parseStatus: '', processStage: '' }; this.load() },
|
||||
reset() { this.query = { page: 1, pageSize: 200, shopName: '', orderCodesText: '', parseStatus: '', processStage: '', createdAtRange: [] }; this.load() },
|
||||
normalizeShopName(value) { return String(value || '').normalize('NFKC').trim().toLocaleLowerCase() },
|
||||
async ensureShopOptions() {
|
||||
if (this.shopOptionsLoaded) return
|
||||
@@ -475,16 +503,29 @@ export default {
|
||||
purchasePriceText(item) { if (item.minUnitPriceCent === undefined || item.maxUnitPriceCent === undefined) return ''; return `允许单价 ¥${(item.minUnitPriceCent / 100).toFixed(2)}~¥${(item.maxUnitPriceCent / 100).toFixed(2)}` },
|
||||
processMeta(stage) { return { manual_action: { label: '待人工处理', type: 'warning' }, pdd_unlinked: { label: '未关联 PDD', type: 'info' }, pdd_pending: { label: 'PDD 待采集', type: 'info' }, pdd_collecting: { label: 'PDD 采集中', type: 'primary' }, pdd_collection_failed: { label: 'PDD 采集失败', type: 'danger' }, color_mapping: { label: '规格待匹配', type: 'warning' }, purchase_ready: { label: '可创建采购', type: 'success' }, task_created: { label: '已创建任务', type: 'primary' }, purchase_succeeded: { label: '采购成功', type: 'success' }, order_review: { label: '待人工核对', type: 'danger' }, return_pending: { label: '退货待确认', type: 'warning' }, return_used: { label: '已用退货', type: 'info' }}[stage] || { label: '待人工处理', type: 'warning' } },
|
||||
purchaseActionLabel(item) { return { open_pdd_link: '去关联', open_mapping: '去匹配', open_shopee: '查看蝦皮商品', open_pdd: '查看 PDD 商品', open_task: '查看任务', reparse: '查看并处理', select_device: '重新选择设备', refresh: '刷新' }[item.nextAction] || '' },
|
||||
// #339: the page can now show up to 500 rows, but the preview endpoint
|
||||
// (previewPurchaseTasks -> purchase/batch.go maxBatchPurchaseItems) still
|
||||
// caps a single request at 100 ids, so this splits the current page into
|
||||
// sequential chunks of <=100 and merges their results. Any chunk failing
|
||||
// falls back to the same page-wide failure state the single-request path
|
||||
// used before, so rows never end up in a partially-updated mix of real
|
||||
// and failed readiness.
|
||||
async loadPurchaseReadiness(ids, requestOptions = {}, generation = this.loadGeneration, selectedIDsOverride = null) {
|
||||
if (generation !== this.loadGeneration) return
|
||||
const selectedIDs = selectedIDsOverride || new Set(this.selectedProducts.map(row => row.id))
|
||||
this.purchaseReadiness = {}
|
||||
if (!this.canPurchase || !ids.length) { this.purchaseReadinessLoading = false; return }
|
||||
this.purchaseReadinessLoading = true
|
||||
const chunkSize = 100
|
||||
const merged = {}
|
||||
try {
|
||||
const r = await previewPurchaseTasks({ sybProductIds: ids }, { ...requestOptions, suppressErrorMessage: true })
|
||||
if (generation !== this.loadGeneration) return
|
||||
this.purchaseReadiness = Object.fromEntries(r.data.items.map(item => [item.sybProductId, item]))
|
||||
for (let start = 0; start < ids.length; start += chunkSize) {
|
||||
const chunk = ids.slice(start, start + chunkSize)
|
||||
const r = await previewPurchaseTasks({ sybProductIds: chunk }, { ...requestOptions, suppressErrorMessage: true })
|
||||
if (generation !== this.loadGeneration) return
|
||||
for (const item of r.data.items) merged[item.sybProductId] = item
|
||||
}
|
||||
this.purchaseReadiness = merged
|
||||
await this.$nextTick()
|
||||
const valid = this.products.filter(row => selectedIDs.has(row.id) && this.isSelectableCandidate(row))
|
||||
this.$refs.productTable?.clearSelection()
|
||||
@@ -540,6 +581,8 @@ export default {
|
||||
|
||||
// ---------------- 退货匹配 (#338) ----------------
|
||||
formatMatchDeadline(value) { return value ? new Date(value).toLocaleString() : '—' },
|
||||
// #338: an already-matched return whose destroy deadline has passed is only flagged, never auto-cancelled.
|
||||
isDeadlinePassed(value) { return Boolean(value) && new Date(value).getTime() <= Date.now() },
|
||||
matchStatusLabel(status) { return { matched: '待确认', confirmed: '已确认', cancelled: '已取消' }[status] || status },
|
||||
async loadReturnMatches(ids, generation = this.loadGeneration) {
|
||||
this.returnMatchByProductId = {}
|
||||
@@ -819,7 +862,9 @@ export default {
|
||||
<style lang="scss" scoped>
|
||||
.page-card { min-height: calc(100vh - 124px); }
|
||||
.page-heading { display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:16px;h1{margin:0 0 6px;font-size:24px;color:#1f2937}p{margin:0;color:#606266;line-height:1.5} }
|
||||
.search-form{display:flex;flex-wrap:nowrap;align-items:center;column-gap:8px;padding:12px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}
|
||||
.search-form{display:flex;flex-direction:column;align-items:stretch;gap:0;padding:12px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}
|
||||
.search-row{display:flex;flex-wrap:wrap;align-items:center;column-gap:8px}
|
||||
.search-row-secondary{justify-content:flex-start}
|
||||
.search-form :deep(.el-form-item){margin-right:0;margin-bottom:12px}
|
||||
.search-form :deep(.el-form-item__content){flex-wrap:nowrap}
|
||||
.shop-input{width:130px}.order-codes-input{width:210px}.unlinked-tag{margin-left:8px}
|
||||
@@ -851,7 +896,6 @@ export default {
|
||||
.section-title{margin:0 0 12px}
|
||||
.drawer-actions{display:flex;gap:12px;margin-top:16px}
|
||||
.raw-json{max-height:520px;overflow:auto;padding:12px;background:#0f172a;color:#e2e8f0;border-radius:6px;font-size:12px;line-height:1.5;white-space:pre-wrap;word-break:break-all}
|
||||
@media(max-width:1280px){.search-form{flex-wrap:wrap}}
|
||||
@media(max-width:1280px){.search-row-primary{align-items:flex-start}.search-row-primary .toolbar-actions{margin-left:auto}}
|
||||
@media(max-width:900px){.split{grid-template-columns:1fr}}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
canStartSync() { return (this.$store.getters.roles || []).some(role => role === 'admin' || role === 'purchaser') },
|
||||
canStartSync() { return (this.$store.getters.roles || []).some(role => role === 'admin' || role === 'purchaser' || role === 'after_sales') },
|
||||
hasRunningSync() { return this.items.some(item => item.status === 'running') }
|
||||
},
|
||||
created() { this.load().then(() => { const id = Number(this.$route.query.runId); if (id > 0) this.openDetail(id) }) },
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
<template v-if="row.occupyingSybProductId">
|
||||
<a class="link" href="javascript:void(0)" @click="openSybProduct(row.occupyingSybProductId)">{{ row.occupyingSybOrderCode }} ↗</a>
|
||||
<div class="muted">{{ row.occupyingSybStageLabel }}</div>
|
||||
<div v-if="isDeadlinePassed(row.destroyDeadLine)" class="deadline-passed">已过销毁截止</div>
|
||||
</template>
|
||||
<span v-else class="muted">—</span>
|
||||
</template>
|
||||
@@ -193,6 +194,8 @@ export default {
|
||||
openSybProduct(sybProductId) { this.$router.push({ path: '/syb-products/index', query: { sybProductId } }) },
|
||||
statusMeta(status) { return { running: { label: '执行中', type: 'primary' }, succeeded: { label: '成功', type: 'success' }, failed: { label: '失败', type: 'danger' }, interrupted: { label: '已中断', type: 'warning' }}[status] || { label: status || '-', type: 'info' } },
|
||||
formatTime(value) { if (!value) return '—'; return new Date(value).toLocaleString('zh-CN', { hour12: false }) },
|
||||
// #338: a matched return past its destroy deadline is flagged only, never auto-released.
|
||||
isDeadlinePassed(value) { return Boolean(value) && new Date(value).getTime() <= Date.now() },
|
||||
async load() {
|
||||
this.loading = true; this.loadError = ''
|
||||
try {
|
||||
@@ -277,4 +280,5 @@ export default {
|
||||
.item-title{font-size:15px;font-weight:600;color:#1f2937;margin-bottom:4px}
|
||||
.muted{font-size:12px;color:#909399}
|
||||
@media(max-width:800px){.page-heading{flex-direction:column}}
|
||||
.deadline-passed{color:var(--el-color-danger);font-size:12px}
|
||||
</style>
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
<el-table-column label="更新" prop="updatedCount" width="70" />
|
||||
<el-table-column label="跳过" prop="skippedCount" width="70" />
|
||||
<el-table-column label="失败" prop="failedCount" width="70" />
|
||||
<el-table-column label="标记不可用" prop="missingMarkedCount" width="96" />
|
||||
<el-table-column label="恢复可用" prop="recoveredCount" width="84" />
|
||||
<el-table-column label="脱敏原因" min-width="200"><template #default="{ row }">{{ row.errorMessage || '—' }}</template></el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
@@ -69,7 +71,7 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
canSync() { return (this.$store.getters.roles || []).some(role => role === 'admin' || role === 'purchaser') },
|
||||
canSync() { return (this.$store.getters.roles || []).some(role => role === 'admin' || role === 'purchaser' || role === 'after_sales') },
|
||||
hasRunningSync() { return this.items.some(item => item.status === 'running') }
|
||||
},
|
||||
created() { this.load() },
|
||||
|
||||
Reference in New Issue
Block a user