fix: 修复顺运宝采集任务孤儿状态 (#165)
This commit is contained in:
@@ -19,7 +19,7 @@ import (
|
|||||||
"cmautobuy/admin/spec"
|
"cmautobuy/admin/spec"
|
||||||
)
|
)
|
||||||
|
|
||||||
const mysqlSchemaVersion = 11
|
const mysqlSchemaVersion = 12
|
||||||
|
|
||||||
// OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。
|
// OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。
|
||||||
func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) {
|
func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) {
|
||||||
@@ -555,10 +555,102 @@ func MigrateMySQL(db *sql.DB) error {
|
|||||||
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 11, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 11, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||||
return fmt.Errorf("记录 MySQL schema v11 失败: %w", err)
|
return fmt.Errorf("记录 MySQL schema v11 失败: %w", err)
|
||||||
}
|
}
|
||||||
|
current = 11
|
||||||
|
}
|
||||||
|
if current < 12 {
|
||||||
|
if err := migrateMySQLV12(db); err != nil {
|
||||||
|
return fmt.Errorf("执行 MySQL schema v12 失败: %w", err)
|
||||||
|
}
|
||||||
|
if err := checkMySQLV12Shape(db); err != nil {
|
||||||
|
return fmt.Errorf("MySQL schema v12 自检失败,未记录版本: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 12, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||||
|
return fmt.Errorf("记录 MySQL schema v12 失败: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return CheckMySQLSchema(db)
|
return CheckMySQLSchema(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// migrateMySQLV12 保存采集任务的顺运宝来源,并回收没有有效任务的孤儿采集中状态。
|
||||||
|
// CREATE TABLE IF NOT EXISTS 和带条件的 UPDATE 都可重放,适合 MySQL DDL 隐式提交后的重启恢复。
|
||||||
|
func migrateMySQLV12(db *sql.DB) error {
|
||||||
|
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS task_syb_sources (
|
||||||
|
task_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
syb_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
created_at VARCHAR(35) NOT NULL,
|
||||||
|
PRIMARY KEY (task_id, syb_id),
|
||||||
|
KEY idx_task_syb_sources_syb (syb_id, task_id),
|
||||||
|
CONSTRAINT fk_task_syb_sources_task FOREIGN KEY (task_id)
|
||||||
|
REFERENCES tasks(task_id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_task_syb_sources_syb FOREIGN KEY (syb_id)
|
||||||
|
REFERENCES syb_orders(syb_id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`); err != nil {
|
||||||
|
return fmt.Errorf("建立采集任务顺运宝来源表失败: %w", err)
|
||||||
|
}
|
||||||
|
now := model.NowISO()
|
||||||
|
if _, err := db.Exec(`UPDATE pdd_products AS pp
|
||||||
|
SET collect_status='pending', collect_msg=NULL, updated_at=?
|
||||||
|
WHERE pp.collect_status='collecting' AND pp.deleted_at IS NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM tasks t
|
||||||
|
WHERE t.task_type='collect' AND t.pdd_goods_id=pp.goods_id
|
||||||
|
AND t.status IN ('pending','assigned','claimed')
|
||||||
|
)`, now); err != nil {
|
||||||
|
return fmt.Errorf("回收无有效任务的 PDD 采集中状态失败: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkMySQLV12Shape(db *sql.DB) error {
|
||||||
|
exists, err := mysqlTableExists(db, "task_syb_sources")
|
||||||
|
if err != nil || !exists {
|
||||||
|
return fmt.Errorf("采集任务顺运宝来源表缺失")
|
||||||
|
}
|
||||||
|
for _, column := range []struct {
|
||||||
|
name string
|
||||||
|
length int64
|
||||||
|
collation string
|
||||||
|
}{
|
||||||
|
{"task_id", 191, "utf8mb4_bin"},
|
||||||
|
{"syb_id", 191, "utf8mb4_bin"},
|
||||||
|
{"created_at", 35, "utf8mb4_0900_ai_ci"},
|
||||||
|
} {
|
||||||
|
if err := checkMySQLVarcharColumn(db, "task_syb_sources", column.name, column.length, false, column.collation, ""); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, index := range []struct{ name, columns string }{
|
||||||
|
{"PRIMARY", "task_id,syb_id"},
|
||||||
|
{"idx_task_syb_sources_syb", "syb_id,task_id"},
|
||||||
|
} {
|
||||||
|
var columns string
|
||||||
|
if err := db.QueryRow(`SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index)
|
||||||
|
FROM information_schema.statistics
|
||||||
|
WHERE table_schema=DATABASE() AND table_name='task_syb_sources' AND index_name=?`, index.name).Scan(&columns); err != nil || columns != index.columns {
|
||||||
|
return fmt.Errorf("采集任务顺运宝来源索引 %s 不正确", index.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, foreignKey := range []struct{ name, column, table, target string }{
|
||||||
|
{"fk_task_syb_sources_task", "task_id", "tasks", "task_id"},
|
||||||
|
{"fk_task_syb_sources_syb", "syb_id", "syb_orders", "syb_id"},
|
||||||
|
} {
|
||||||
|
var column, table, target string
|
||||||
|
err := db.QueryRow(`SELECT column_name,referenced_table_name,referenced_column_name
|
||||||
|
FROM information_schema.key_column_usage
|
||||||
|
WHERE constraint_schema=DATABASE() AND table_name='task_syb_sources' AND constraint_name=?`, foreignKey.name).
|
||||||
|
Scan(&column, &table, &target)
|
||||||
|
if err != nil || column != foreignKey.column || table != foreignKey.table || target != foreignKey.target {
|
||||||
|
return fmt.Errorf("采集任务顺运宝来源约束 %s 不正确", foreignKey.name)
|
||||||
|
}
|
||||||
|
var deleteRule string
|
||||||
|
if err := db.QueryRow(`SELECT delete_rule FROM information_schema.referential_constraints
|
||||||
|
WHERE constraint_schema=DATABASE() AND table_name='task_syb_sources' AND constraint_name=?`, foreignKey.name).Scan(&deleteRule); err != nil || deleteRule != "CASCADE" {
|
||||||
|
return fmt.Errorf("采集任务顺运宝来源约束 %s 删除规则不正确", foreignKey.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// migrateMySQLV11 增加顺运宝店铺列,并从保留的原始 JSON 回填历史数据。
|
// migrateMySQLV11 增加顺运宝店铺列,并从保留的原始 JSON 回填历史数据。
|
||||||
// DDL 和 UPDATE 都可重放:中断后再次启动只补缺列和仍为空的记录。
|
// DDL 和 UPDATE 都可重放:中断后再次启动只补缺列和仍为空的记录。
|
||||||
func migrateMySQLV11(db *sql.DB) error {
|
func migrateMySQLV11(db *sql.DB) error {
|
||||||
@@ -1167,6 +1259,7 @@ func CheckMySQLSchema(db *sql.DB) error {
|
|||||||
"users", "web_sessions", "client_user_assignments", "syb_sync_runs", "admin_initialization_lock",
|
"users", "web_sessions", "client_user_assignments", "syb_sync_runs", "admin_initialization_lock",
|
||||||
"spec_mapping_decisions",
|
"spec_mapping_decisions",
|
||||||
"catalog_import_runs",
|
"catalog_import_runs",
|
||||||
|
"task_syb_sources",
|
||||||
}
|
}
|
||||||
if err := checkMySQLSchema(db, mysqlRequiredTables); err != nil {
|
if err := checkMySQLSchema(db, mysqlRequiredTables); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1189,7 +1282,16 @@ func CheckMySQLSchema(db *sql.DB) error {
|
|||||||
if err := checkMySQLV8Shape(db); err != nil {
|
if err := checkMySQLV8Shape(db); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return checkMySQLV9Shape(db)
|
if err := checkMySQLV9Shape(db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := checkMySQLV10Shape(db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := checkMySQLV11Shape(db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return checkMySQLV12Shape(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkMySQLV9Shape(db *sql.DB) error {
|
func checkMySQLV9Shape(db *sql.DB) error {
|
||||||
|
|||||||
@@ -585,6 +585,71 @@ func TestMySQLMigrate_V11回填顺运宝店铺且可重放(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMySQLMigrate_V11升级V12并修复孤儿采集中状态(t *testing.T) {
|
||||||
|
db := openMySQLMigrationTestDB(t)
|
||||||
|
defer db.Close()
|
||||||
|
cleanMySQLTestSchema(t, db)
|
||||||
|
defer cleanMySQLTestSchema(t, db)
|
||||||
|
if err := MigrateMySQL(db); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// 模拟已在 v11 的生产库:去掉 v12 版本及其附加表,保留全部 v1-v11 结构。
|
||||||
|
mustExec(t, db, `DELETE FROM schema_migrations WHERE version=12`)
|
||||||
|
mustExec(t, db, `DROP TABLE task_syb_sources`)
|
||||||
|
now := model.NowISO()
|
||||||
|
mustExec(t, db, `INSERT INTO pdd_products(goods_id,url,collect_status,created_at,updated_at) VALUES
|
||||||
|
('ORPHAN','https://mobile.yangkeduo.com/goods.html?goods_id=1','collecting',?,?),
|
||||||
|
('ACTIVE','https://mobile.yangkeduo.com/goods.html?goods_id=2','collecting',?,?)`, now, now, now, now)
|
||||||
|
mustExec(t, db, `INSERT INTO tasks(task_id,task_type,status,pdd_goods_url,pdd_goods_id,created_at,updated_at)
|
||||||
|
VALUES('COL-ACTIVE','collect','pending','https://mobile.yangkeduo.com/goods.html?goods_id=2','ACTIVE',?,?)`, now, now)
|
||||||
|
|
||||||
|
if err := MigrateMySQL(db); err != nil {
|
||||||
|
t.Fatalf("v11 升级 v12 失败: %v", err)
|
||||||
|
}
|
||||||
|
if err := MigrateMySQL(db); err != nil {
|
||||||
|
t.Fatalf("v12 重放失败: %v", err)
|
||||||
|
}
|
||||||
|
var orphanStatus, activeStatus string
|
||||||
|
if err := db.QueryRow(`SELECT collect_status FROM pdd_products WHERE goods_id='ORPHAN'`).Scan(&orphanStatus); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.QueryRow(`SELECT collect_status FROM pdd_products WHERE goods_id='ACTIVE'`).Scan(&activeStatus); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if orphanStatus != "pending" || activeStatus != "collecting" {
|
||||||
|
t.Fatalf("v12 状态修复错误:orphan=%s active=%s", orphanStatus, activeStatus)
|
||||||
|
}
|
||||||
|
var versionCount int
|
||||||
|
if err := db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version=12`).Scan(&versionCount); err != nil || versionCount != 1 {
|
||||||
|
t.Fatalf("v12 版本记录错误: count=%d err=%v", versionCount, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMySQLMigrate_V12形状错误不记录版本(t *testing.T) {
|
||||||
|
db := openMySQLMigrationTestDB(t)
|
||||||
|
defer db.Close()
|
||||||
|
cleanMySQLTestSchema(t, db)
|
||||||
|
defer cleanMySQLTestSchema(t, db)
|
||||||
|
if err := MigrateMySQL(db); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mustExec(t, db, `DELETE FROM schema_migrations WHERE version=12`)
|
||||||
|
mustExec(t, db, `DROP TABLE task_syb_sources`)
|
||||||
|
mustExec(t, db, `CREATE TABLE task_syb_sources (
|
||||||
|
task_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
syb_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
created_at VARCHAR(35) NOT NULL,
|
||||||
|
PRIMARY KEY(syb_id,task_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`)
|
||||||
|
if err := MigrateMySQL(db); err == nil {
|
||||||
|
t.Fatal("错误主键和缺失外键必须阻止 v12")
|
||||||
|
}
|
||||||
|
var versionCount int
|
||||||
|
if err := db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version=12`).Scan(&versionCount); err != nil || versionCount != 0 {
|
||||||
|
t.Fatalf("v12 自检失败时不得记录版本:count=%d err=%v", versionCount, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func openMySQLMigrationTestDB(t *testing.T) *sql.DB {
|
func openMySQLMigrationTestDB(t *testing.T) *sql.DB {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if os.Getenv("CMAUTOBUY_MYSQL_TEST") != "1" {
|
if os.Getenv("CMAUTOBUY_MYSQL_TEST") != "1" {
|
||||||
|
|||||||
+20
-7
@@ -387,7 +387,8 @@ func SetCollectFailed(q Execer, pddGoodsID, msg, artifactRef string) error {
|
|||||||
//
|
//
|
||||||
// 允许发起采集的状态:
|
// 允许发起采集的状态:
|
||||||
// - pending / failed —— 没有任务在跑;
|
// - pending / failed —— 没有任务在跑;
|
||||||
// - collecting **且已经超时**(`updated_at` 早于 `now - model.CollectStaleAfter`)
|
// - collecting **且已经超时**(`updated_at` 早于 `now - model.CollectStaleAfter`),
|
||||||
|
// 或数据库里已经没有有效采集任务;
|
||||||
// —— 客户端离线、崩溃或任务被删都会让一个 collecting 卡住不动,
|
// —— 客户端离线、崩溃或任务被删都会让一个 collecting 卡住不动,
|
||||||
// 这些是常态不是异常,界面上必须有出口,见 #24。
|
// 这些是常态不是异常,界面上必须有出口,见 #24。
|
||||||
//
|
//
|
||||||
@@ -414,11 +415,17 @@ func MarkCollecting(q Execer, pddGoodsID string) (bool, error) {
|
|||||||
staleBefore := now.Add(-model.CollectStaleAfter).UTC().Format(model.TimeLayout)
|
staleBefore := now.Add(-model.CollectStaleAfter).UTC().Format(model.TimeLayout)
|
||||||
|
|
||||||
res, err := q.Exec(`
|
res, err := q.Exec(`
|
||||||
UPDATE pdd_products
|
UPDATE pdd_products AS pp
|
||||||
SET collect_status = 'collecting', updated_at = ?
|
SET collect_status = 'collecting', updated_at = ?
|
||||||
WHERE goods_id = ? AND deleted_at IS NULL
|
WHERE pp.goods_id = ? AND deleted_at IS NULL
|
||||||
AND ( collect_status IN ('pending', 'failed')
|
AND ( collect_status IN ('pending', 'failed')
|
||||||
OR (collect_status = 'collecting' AND updated_at < ?) )`,
|
OR (collect_status = 'collecting' AND (
|
||||||
|
updated_at < ? OR NOT EXISTS (
|
||||||
|
SELECT 1 FROM tasks t
|
||||||
|
WHERE t.task_type='collect' AND t.pdd_goods_id=pp.goods_id
|
||||||
|
AND t.status IN ('pending','assigned','claimed')
|
||||||
|
)
|
||||||
|
)) )`,
|
||||||
nowISO, pddGoodsID, staleBefore)
|
nowISO, pddGoodsID, staleBefore)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("标记 PDD 商品 %s 采集中失败: %w", pddGoodsID, err)
|
return false, fmt.Errorf("标记 PDD 商品 %s 采集中失败: %w", pddGoodsID, err)
|
||||||
@@ -442,11 +449,17 @@ func MarkRecollecting(q Execer, pddGoodsID string) (bool, error) {
|
|||||||
staleBefore := now.Add(-model.CollectStaleAfter).UTC().Format(model.TimeLayout)
|
staleBefore := now.Add(-model.CollectStaleAfter).UTC().Format(model.TimeLayout)
|
||||||
|
|
||||||
res, err := q.Exec(`
|
res, err := q.Exec(`
|
||||||
UPDATE pdd_products
|
UPDATE pdd_products AS pp
|
||||||
SET collect_status = 'collecting', updated_at = ?
|
SET collect_status = 'collecting', updated_at = ?
|
||||||
WHERE goods_id = ? AND deleted_at IS NULL
|
WHERE pp.goods_id = ? AND deleted_at IS NULL
|
||||||
AND ( collect_status IN ('pending', 'failed', 'collected')
|
AND ( collect_status IN ('pending', 'failed', 'collected')
|
||||||
OR (collect_status = 'collecting' AND updated_at < ?) )`,
|
OR (collect_status = 'collecting' AND (
|
||||||
|
updated_at < ? OR NOT EXISTS (
|
||||||
|
SELECT 1 FROM tasks t
|
||||||
|
WHERE t.task_type='collect' AND t.pdd_goods_id=pp.goods_id
|
||||||
|
AND t.status IN ('pending','assigned','claimed')
|
||||||
|
)
|
||||||
|
)) )`,
|
||||||
nowISO, pddGoodsID, staleBefore)
|
nowISO, pddGoodsID, staleBefore)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("标记 PDD 商品 %s 重新采集中失败: %w", pddGoodsID, err)
|
return false, fmt.Errorf("标记 PDD 商品 %s 重新采集中失败: %w", pddGoodsID, err)
|
||||||
|
|||||||
+21
-13
@@ -396,17 +396,18 @@ const sybOrderContextFrom = `
|
|||||||
// SybOrderContext 是顺运宝明细及其当前蝦皮/PDD 处理上下文。
|
// SybOrderContext 是顺运宝明细及其当前蝦皮/PDD 处理上下文。
|
||||||
// 处理阶段由 service 计算,Repository 只提供数据库事实。
|
// 处理阶段由 service 计算,Repository 只提供数据库事实。
|
||||||
type SybOrderContext struct {
|
type SybOrderContext struct {
|
||||||
Order model.SybOrder
|
Order model.SybOrder
|
||||||
ShopeeExists bool
|
ShopeeExists bool
|
||||||
PddGoodsID string
|
PddGoodsID string
|
||||||
PddGoodsURL string
|
PddGoodsURL string
|
||||||
PddCollectStatus string
|
PddCollectStatus string
|
||||||
PddCollectMsg string
|
PddCollectMsg string
|
||||||
PddSkusJSON string
|
PddSkusJSON string
|
||||||
PddUpdatedAt string
|
PddUpdatedAt string
|
||||||
MappingOptionKey string
|
MappingOptionKey string
|
||||||
MappingOptions string
|
MappingOptions string
|
||||||
HasActiveTask bool
|
HasActiveTask bool
|
||||||
|
HasActiveCollectTask bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanSybOrderContext(s rowScanner) (SybOrderContext, error) {
|
func scanSybOrderContext(s rowScanner) (SybOrderContext, error) {
|
||||||
@@ -416,13 +417,13 @@ func scanSybOrderContext(s rowScanner) (SybOrderContext, error) {
|
|||||||
var shopeeExists int
|
var shopeeExists int
|
||||||
var pddGoodsID, pddGoodsURL, collectStatus, collectMsg, skusJSON, pddUpdatedAt sql.NullString
|
var pddGoodsID, pddGoodsURL, collectStatus, collectMsg, skusJSON, pddUpdatedAt sql.NullString
|
||||||
var mappingKey, mappingOptions sql.NullString
|
var mappingKey, mappingOptions sql.NullString
|
||||||
var hasActiveTask int
|
var hasActiveTask, hasActiveCollectTask int
|
||||||
err := s.Scan(
|
err := s.Scan(
|
||||||
&c.Order.SybID, &c.Order.OrderNo, &shopName, &title, &productSpec, &specKey, &shopeeGoodsID,
|
&c.Order.SybID, &c.Order.OrderNo, &shopName, &title, &productSpec, &specKey, &shopeeGoodsID,
|
||||||
&c.Order.Quantity, &priceCent, &imageURL, &c.Order.SybData,
|
&c.Order.Quantity, &priceCent, &imageURL, &c.Order.SybData,
|
||||||
&c.Order.CreatedAt, &c.Order.UpdatedAt, &shopeeExists,
|
&c.Order.CreatedAt, &c.Order.UpdatedAt, &shopeeExists,
|
||||||
&pddGoodsID, &pddGoodsURL, &collectStatus, &collectMsg, &skusJSON, &pddUpdatedAt,
|
&pddGoodsID, &pddGoodsURL, &collectStatus, &collectMsg, &skusJSON, &pddUpdatedAt,
|
||||||
&mappingKey, &mappingOptions, &hasActiveTask,
|
&mappingKey, &mappingOptions, &hasActiveTask, &hasActiveCollectTask,
|
||||||
)
|
)
|
||||||
c.Order.ShopName = shopName.String
|
c.Order.ShopName = shopName.String
|
||||||
c.Order.Title = title.String
|
c.Order.Title = title.String
|
||||||
@@ -441,6 +442,7 @@ func scanSybOrderContext(s rowScanner) (SybOrderContext, error) {
|
|||||||
c.MappingOptionKey = mappingKey.String
|
c.MappingOptionKey = mappingKey.String
|
||||||
c.MappingOptions = mappingOptions.String
|
c.MappingOptions = mappingOptions.String
|
||||||
c.HasActiveTask = hasActiveTask != 0
|
c.HasActiveTask = hasActiveTask != 0
|
||||||
|
c.HasActiveCollectTask = hasActiveCollectTask != 0
|
||||||
return c, err
|
return c, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -456,6 +458,9 @@ func ListSybOrderContexts(q Execer, filter SybOrderFilter, limit, offset int) ([
|
|||||||
pp.skus_json, pp.updated_at, sm.pdd_option_key, sm.pdd_options,
|
pp.skus_json, pp.updated_at, sm.pdd_option_key, sm.pdd_options,
|
||||||
EXISTS(SELECT 1 FROM tasks t WHERE t.task_type = 'purchase'
|
EXISTS(SELECT 1 FROM tasks t WHERE t.task_type = 'purchase'
|
||||||
AND t.syb_id = so.syb_id
|
AND t.syb_id = so.syb_id
|
||||||
|
AND t.status IN ('pending', 'assigned', 'claimed')),
|
||||||
|
EXISTS(SELECT 1 FROM tasks t WHERE t.task_type = 'collect'
|
||||||
|
AND t.pdd_goods_id = pp.goods_id
|
||||||
AND t.status IN ('pending', 'assigned', 'claimed'))` +
|
AND t.status IN ('pending', 'assigned', 'claimed'))` +
|
||||||
sybOrderContextFrom + where + `
|
sybOrderContextFrom + where + `
|
||||||
ORDER BY so.updated_at DESC, so.syb_id DESC`
|
ORDER BY so.updated_at DESC, so.syb_id DESC`
|
||||||
@@ -490,6 +495,9 @@ func GetSybOrderContext(q Execer, sybID string) (*SybOrderContext, error) {
|
|||||||
pp.skus_json, pp.updated_at, sm.pdd_option_key, sm.pdd_options,
|
pp.skus_json, pp.updated_at, sm.pdd_option_key, sm.pdd_options,
|
||||||
EXISTS(SELECT 1 FROM tasks t WHERE t.task_type = 'purchase'
|
EXISTS(SELECT 1 FROM tasks t WHERE t.task_type = 'purchase'
|
||||||
AND t.syb_id = so.syb_id
|
AND t.syb_id = so.syb_id
|
||||||
|
AND t.status IN ('pending', 'assigned', 'claimed')),
|
||||||
|
EXISTS(SELECT 1 FROM tasks t WHERE t.task_type = 'collect'
|
||||||
|
AND t.pdd_goods_id = pp.goods_id
|
||||||
AND t.status IN ('pending', 'assigned', 'claimed'))`+
|
AND t.status IN ('pending', 'assigned', 'claimed'))`+
|
||||||
sybOrderContextFrom+` WHERE so.syb_id = ?`, sybID))
|
sybOrderContextFrom+` WHERE so.syb_id = ?`, sybID))
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
|||||||
@@ -379,8 +379,14 @@ func taskFilterClause(filter TaskFilter) (string, []any) {
|
|||||||
if kw := strings.TrimSpace(filter.Keyword); kw != "" {
|
if kw := strings.TrimSpace(filter.Keyword); kw != "" {
|
||||||
pattern := "%" + escapeLike(kw) + "%"
|
pattern := "%" + escapeLike(kw) + "%"
|
||||||
clauses = append(clauses,
|
clauses = append(clauses,
|
||||||
"(t.task_id LIKE ? ESCAPE '!' OR t.order_no LIKE ? ESCAPE '!' OR t.pdd_goods_id LIKE ? ESCAPE '!')")
|
`(t.task_id LIKE ? ESCAPE '!' OR t.order_no LIKE ? ESCAPE '!' OR t.pdd_goods_id LIKE ? ESCAPE '!'
|
||||||
args = append(args, pattern, pattern, pattern)
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM task_syb_sources tss
|
||||||
|
JOIN syb_orders so ON so.syb_id=tss.syb_id
|
||||||
|
WHERE tss.task_id=t.task_id
|
||||||
|
AND (tss.syb_id LIKE ? ESCAPE '!' OR so.order_no LIKE ? ESCAPE '!')
|
||||||
|
))`)
|
||||||
|
args = append(args, pattern, pattern, pattern, pattern, pattern)
|
||||||
}
|
}
|
||||||
if filter.VisibleUserID != "" {
|
if filter.VisibleUserID != "" {
|
||||||
clauses = append(clauses, "t.created_by_user_id = ?")
|
clauses = append(clauses, "t.created_by_user_id = ?")
|
||||||
@@ -529,6 +535,59 @@ func DeleteTasksInScope(q Execer, taskIDs []string, visibleUserID string) (int64
|
|||||||
return res.RowsAffected()
|
return res.RowsAffected()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListCollectTaskGoodsIDsInScope 在删除前找出受影响的 PDD 商品。
|
||||||
|
// 调用方必须和删除使用同一事务、同一可见范围,避免越权数据影响状态回收。
|
||||||
|
func ListCollectTaskGoodsIDsInScope(q Execer, taskIDs []string, visibleUserID string) ([]string, error) {
|
||||||
|
if len(taskIDs) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(taskIDs)), ",")
|
||||||
|
args := make([]any, 0, len(taskIDs)+1)
|
||||||
|
for _, id := range taskIDs {
|
||||||
|
args = append(args, id)
|
||||||
|
}
|
||||||
|
where := `task_id IN (` + placeholders + `) AND task_type='collect' AND pdd_goods_id IS NOT NULL`
|
||||||
|
if visibleUserID != "" {
|
||||||
|
where += ` AND created_by_user_id = ?`
|
||||||
|
args = append(args, visibleUserID)
|
||||||
|
}
|
||||||
|
rows, err := q.Query(`SELECT DISTINCT pdd_goods_id FROM tasks WHERE `+where, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("查询待删除采集任务的 PDD 商品失败: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var goodsIDs []string
|
||||||
|
for rows.Next() {
|
||||||
|
var goodsID string
|
||||||
|
if err := rows.Scan(&goodsID); err != nil {
|
||||||
|
return nil, fmt.Errorf("读取待删除采集任务的 PDD 商品失败: %w", err)
|
||||||
|
}
|
||||||
|
goodsIDs = append(goodsIDs, goodsID)
|
||||||
|
}
|
||||||
|
return goodsIDs, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetCollectingIfNoActiveTask 把没有有效采集任务的孤儿状态回收到待采集。
|
||||||
|
// 条件判断和更新在一条 SQL 内完成,避免并发建任务时误覆盖 collecting。
|
||||||
|
func ResetCollectingIfNoActiveTask(q Execer, goodsID string) (bool, error) {
|
||||||
|
result, err := q.Exec(`UPDATE pdd_products AS pp
|
||||||
|
SET collect_status='pending', collect_msg=NULL, updated_at=?
|
||||||
|
WHERE pp.goods_id=? AND pp.collect_status='collecting'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM tasks t
|
||||||
|
WHERE t.task_type='collect' AND t.pdd_goods_id=pp.goods_id
|
||||||
|
AND t.status IN ('pending','assigned','claimed')
|
||||||
|
)`, model.NowISO(), goodsID)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("回收商品 %s 的孤立采集中状态失败: %w", goodsID, err)
|
||||||
|
}
|
||||||
|
n, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("确认商品 %s 的采集状态回收结果失败: %w", goodsID, err)
|
||||||
|
}
|
||||||
|
return n == 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
// InsertCollectTask 建一条不指定客户端的采集任务。
|
// InsertCollectTask 建一条不指定客户端的采集任务。
|
||||||
// 保留这个入口给蝦皮、顺运宝等现有流程使用,避免它们被 PDD 页的新选项影响。
|
// 保留这个入口给蝦皮、顺运宝等现有流程使用,避免它们被 PDD 页的新选项影响。
|
||||||
func InsertCollectTask(q Execer, taskID, goodsID, goodsURL string) error {
|
func InsertCollectTask(q Execer, taskID, goodsID, goodsURL string) error {
|
||||||
@@ -567,6 +626,31 @@ func InsertCollectTaskForClientAndUser(q Execer, taskID, goodsID, goodsURL, assi
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InsertCollectTaskSybSources 保存一条采集任务对应的全部顺运宝明细来源。
|
||||||
|
// 同一个 PDD 商品可能由多条明细共同发起,来源不能压成 tasks 上的单列。
|
||||||
|
func InsertCollectTaskSybSources(q Execer, taskID string, sybIDs []string) error {
|
||||||
|
taskID = strings.TrimSpace(taskID)
|
||||||
|
if taskID == "" {
|
||||||
|
return fmt.Errorf("采集任务编号不能为空")
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(sybIDs))
|
||||||
|
for _, raw := range sybIDs {
|
||||||
|
sybID := strings.TrimSpace(raw)
|
||||||
|
if sybID == "" {
|
||||||
|
return fmt.Errorf("顺运宝明细编号不能为空")
|
||||||
|
}
|
||||||
|
if _, ok := seen[sybID]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[sybID] = struct{}{}
|
||||||
|
if _, err := q.Exec(`INSERT INTO task_syb_sources(task_id,syb_id,created_at)
|
||||||
|
VALUES(?,?,?) ON DUPLICATE KEY UPDATE created_at=created_at`, taskID, sybID, model.NowISO()); err != nil {
|
||||||
|
return fmt.Errorf("保存采集任务 %s 的顺运宝来源 %s 失败: %w", taskID, sybID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// HasActivePurchaseTask 判断顺运宝明细是否已有尚未结束的采购任务。
|
// HasActivePurchaseTask 判断顺运宝明细是否已有尚未结束的采购任务。
|
||||||
func HasActivePurchaseTask(q Execer, sybID string) (bool, error) {
|
func HasActivePurchaseTask(q Execer, sybID string) (bool, error) {
|
||||||
var count int
|
var count int
|
||||||
|
|||||||
@@ -576,7 +576,7 @@ func (r CollectTaskResult) Skipped() int {
|
|||||||
// 返回的跳过分类必须显示给操作员。静默跳过的话,
|
// 返回的跳过分类必须显示给操作员。静默跳过的话,
|
||||||
// 操作员会以为任务建好了,等半天没动静也不知道为什么。
|
// 操作员会以为任务建好了,等半天没动静也不知道为什么。
|
||||||
func CreatePddCollectTasks(db *sql.DB, goodsIDs []string) (CollectTaskResult, error) {
|
func CreatePddCollectTasks(db *sql.DB, goodsIDs []string) (CollectTaskResult, error) {
|
||||||
return createPddCollectTasks(db, goodsIDs, "", "", "")
|
return createPddCollectTasks(db, goodsIDs, "", "", "", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreatePddCollectTasksForUser 为 PDD 批量页面创建可选客户端的采集任务。
|
// CreatePddCollectTasksForUser 为 PDD 批量页面创建可选客户端的采集任务。
|
||||||
@@ -586,7 +586,7 @@ func CreatePddCollectTasksForUser(db *sql.DB, actor *model.User, goodsIDs []stri
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return CollectTaskResult{}, err
|
return CollectTaskResult{}, err
|
||||||
}
|
}
|
||||||
return createPddCollectTasks(db, goodsIDs, strings.TrimSpace(clientID), visibleUserID, actor.UserID)
|
return createPddCollectTasks(db, goodsIDs, strings.TrimSpace(clientID), visibleUserID, actor.UserID, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecollectPddProductForUser 为详情页一次明确的重新采集创建单商品任务。
|
// RecollectPddProductForUser 为详情页一次明确的重新采集创建单商品任务。
|
||||||
@@ -658,7 +658,8 @@ func RecollectPddProductForUser(db *sql.DB, actor *model.User, goodsID, clientID
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func createPddCollectTasks(db *sql.DB, goodsIDs []string, clientID, visibleUserID, createdByUserID string) (CollectTaskResult, error) {
|
func createPddCollectTasks(db *sql.DB, goodsIDs []string, clientID, visibleUserID, createdByUserID string,
|
||||||
|
sybSourcesByGoodsID map[string][]string) (CollectTaskResult, error) {
|
||||||
var result CollectTaskResult
|
var result CollectTaskResult
|
||||||
|
|
||||||
goodsIDs = dedupe(goodsIDs)
|
goodsIDs = dedupe(goodsIDs)
|
||||||
@@ -725,8 +726,12 @@ func createPddCollectTasks(db *sql.DB, goodsIDs []string, clientID, visibleUserI
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
taskID := newCollectTaskID()
|
||||||
if err := repository.InsertCollectTaskForClientAndUser(
|
if err := repository.InsertCollectTaskForClientAndUser(
|
||||||
tx, newCollectTaskID(), p.GoodsID, p.URL, clientID, createdByUserID); err != nil {
|
tx, taskID, p.GoodsID, p.URL, clientID, createdByUserID); err != nil {
|
||||||
|
return CollectTaskResult{}, err
|
||||||
|
}
|
||||||
|
if err := repository.InsertCollectTaskSybSources(tx, taskID, sybSourcesByGoodsID[p.GoodsID]); err != nil {
|
||||||
return CollectTaskResult{}, err
|
return CollectTaskResult{}, err
|
||||||
}
|
}
|
||||||
result.Created++
|
result.Created++
|
||||||
|
|||||||
@@ -856,6 +856,23 @@ func TestCreatePddCollectTasks_采集中未超时的跳过并报数(t *testing.T
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreatePddCollectTasks_采集中但任务已不存在可立即重建(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
createProduct(t, db, "100000000003")
|
||||||
|
if _, err := db.Exec(`UPDATE pdd_products SET collect_status='collecting',updated_at=? WHERE goods_id=?`,
|
||||||
|
model.NowISO(), "100000000003"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := CreatePddCollectTasks(db, []string{"100000000003"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("孤儿采集中状态重建失败: %v", err)
|
||||||
|
}
|
||||||
|
if result.Created != 1 || result.SkippedCollecting != 0 {
|
||||||
|
t.Fatalf("没有有效任务时应立即重建,实际 %+v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 采集失败的可以重新采集,不能一直卡在失败状态。
|
// 采集失败的可以重新采集,不能一直卡在失败状态。
|
||||||
func TestCreatePddCollectTasks_失败的可以重新采集(t *testing.T) {
|
func TestCreatePddCollectTasks_失败的可以重新采集(t *testing.T) {
|
||||||
db := newTestDB(t)
|
db := newTestDB(t)
|
||||||
|
|||||||
+16
-13
@@ -143,19 +143,16 @@ func CreateSybPddCollectTask(db *sql.DB, sybID string) (CollectTaskResult, error
|
|||||||
if context == nil {
|
if context == nil {
|
||||||
return CollectTaskResult{}, fmt.Errorf("顺运宝明细不存在")
|
return CollectTaskResult{}, fmt.Errorf("顺运宝明细不存在")
|
||||||
}
|
}
|
||||||
return CreateShopeePddCollectTask(db, context.Order.ShopeeGoodsID)
|
if context.PddGoodsID == "" {
|
||||||
|
return CollectTaskResult{}, fmt.Errorf("请先关联 PDD 商品")
|
||||||
|
}
|
||||||
|
return createPddCollectTasks(db, []string{context.PddGoodsID}, "", "", "",
|
||||||
|
map[string][]string{context.PddGoodsID: {context.Order.SybID}})
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateSybPddCollectTaskForUser 为网页登录入口记录任务创建人。
|
// CreateSybPddCollectTaskForUser 为网页登录入口记录任务创建人。
|
||||||
func CreateSybPddCollectTaskForUser(db *sql.DB, actor *model.User, sybID string) (CollectTaskResult, error) {
|
func CreateSybPddCollectTaskForUser(db *sql.DB, actor *model.User, sybID string) (CollectTaskResult, error) {
|
||||||
context, err := repository.GetSybOrderContext(db, strings.TrimSpace(sybID))
|
return CreateSybPddCollectTasksForUser(db, actor, []string{sybID}, "")
|
||||||
if err != nil {
|
|
||||||
return CollectTaskResult{}, err
|
|
||||||
}
|
|
||||||
if context == nil {
|
|
||||||
return CollectTaskResult{}, fmt.Errorf("顺运宝明细不存在")
|
|
||||||
}
|
|
||||||
return CreateShopeePddCollectTaskForUser(db, actor, context.Order.ShopeeGoodsID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateSybPddCollectTasksForUser 把勾选的顺运宝明细转换成 PDD 商品后批量建采集任务。
|
// CreateSybPddCollectTasksForUser 把勾选的顺运宝明细转换成 PDD 商品后批量建采集任务。
|
||||||
@@ -168,6 +165,7 @@ func CreateSybPddCollectTasksForUser(db *sql.DB, actor *model.User, sybIDs []str
|
|||||||
}
|
}
|
||||||
|
|
||||||
goodsIDs := make([]string, 0, len(sybIDs))
|
goodsIDs := make([]string, 0, len(sybIDs))
|
||||||
|
sourcesByGoodsID := make(map[string][]string)
|
||||||
for _, sybID := range sybIDs {
|
for _, sybID := range sybIDs {
|
||||||
context, err := repository.GetSybOrderContext(db, sybID)
|
context, err := repository.GetSybOrderContext(db, sybID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -177,11 +175,16 @@ func CreateSybPddCollectTasksForUser(db *sql.DB, actor *model.User, sybIDs []str
|
|||||||
return CollectTaskResult{}, invalidInput("顺运宝明细 %s 不存在,请刷新页面后重新选择", sybID)
|
return CollectTaskResult{}, invalidInput("顺运宝明细 %s 不存在,请刷新页面后重新选择", sybID)
|
||||||
}
|
}
|
||||||
stage, _, _, _ := sybStageFor(*context)
|
stage, _, _, _ := sybStageFor(*context)
|
||||||
if stage != SybStagePddPending && stage != SybStagePddFailed {
|
if stage != SybStagePddPending && stage != SybStagePddFailed && stage != SybStagePddCollectingStale {
|
||||||
return CollectTaskResult{}, invalidInput("顺运宝明细 %s 已不处于 PDD 待采集或采集失败状态,请刷新页面后重新选择", sybID)
|
return CollectTaskResult{}, invalidInput("顺运宝明细 %s 已不处于 PDD 待采集、采集失败或采集超时状态,请刷新页面后重新选择", sybID)
|
||||||
}
|
}
|
||||||
goodsIDs = append(goodsIDs, context.PddGoodsID)
|
goodsIDs = append(goodsIDs, context.PddGoodsID)
|
||||||
|
sourcesByGoodsID[context.PddGoodsID] = append(sourcesByGoodsID[context.PddGoodsID], sybID)
|
||||||
}
|
}
|
||||||
|
visibleUserID, err := taskActorScope(actor)
|
||||||
return CreatePddCollectTasksForUser(db, actor, dedupe(goodsIDs), clientID)
|
if err != nil {
|
||||||
|
return CollectTaskResult{}, err
|
||||||
|
}
|
||||||
|
return createPddCollectTasks(db, dedupe(goodsIDs), strings.TrimSpace(clientID),
|
||||||
|
visibleUserID, actor.UserID, sourcesByGoodsID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,6 +143,42 @@ func TestCreateSybPddCollectTasksForUser_同一PDD商品去重(t *testing.T) {
|
|||||||
if err := db.QueryRow(`SELECT COUNT(*) FROM tasks WHERE task_type='collect'`).Scan(&taskCount); err != nil || taskCount != 1 {
|
if err := db.QueryRow(`SELECT COUNT(*) FROM tasks WHERE task_type='collect'`).Scan(&taskCount); err != nil || taskCount != 1 {
|
||||||
t.Fatalf("同一 PDD 商品应只建一条任务,count=%d err=%v", taskCount, err)
|
t.Fatalf("同一 PDD 商品应只建一条任务,count=%d err=%v", taskCount, err)
|
||||||
}
|
}
|
||||||
|
var sourceCount int
|
||||||
|
if err := db.QueryRow(`SELECT COUNT(*) FROM task_syb_sources`).Scan(&sourceCount); err != nil || sourceCount != 2 {
|
||||||
|
t.Fatalf("去重任务应保留两条顺运宝来源,count=%d err=%v", sourceCount, err)
|
||||||
|
}
|
||||||
|
for _, keyword := range []string{"ORDER-SYB-1", "ORDER-SYB-2", "SYB-1", "SYB-2"} {
|
||||||
|
list, err := ListTasksView(db, repository.TaskFilter{Keyword: keyword}, 1)
|
||||||
|
if err != nil || list.Total != 1 || len(list.Rows) != 1 {
|
||||||
|
t.Fatalf("按顺运宝来源 %q 搜索任务失败: total=%d rows=%d err=%v", keyword, list.Total, len(list.Rows), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := CreateSybPddCollectTasksForUser(db, admin, []string{"SYB-1"}, ""); !IsValidationError(err) {
|
||||||
|
t.Fatalf("未超时且有有效任务时应拒绝重复创建,实际 %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateSybPddCollectTasksForUser_无任务的采集中状态可恢复(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
admin, _, _ := insertTaskUsers(t, db)
|
||||||
|
seedShopeeProduct(t, db, "1001", "商品一")
|
||||||
|
if _, err := AssociateShopeePdd(db, "1001", pddURLA, false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
seedSybCollectOrder(t, db, "SYB-ORPHAN", "1001")
|
||||||
|
if _, err := db.Exec(`UPDATE pdd_products SET collect_status='collecting',updated_at=? WHERE goods_id=?`,
|
||||||
|
model.NowISO(), "737116531267"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := CreateSybPddCollectTasksForUser(db, admin, []string{"SYB-ORPHAN"}, "")
|
||||||
|
if err != nil || result.Created != 1 {
|
||||||
|
t.Fatalf("孤儿采集中状态应能重建任务:result=%+v err=%v", result, err)
|
||||||
|
}
|
||||||
|
var sources int
|
||||||
|
if err := db.QueryRow(`SELECT COUNT(*) FROM task_syb_sources WHERE syb_id='SYB-ORPHAN'`).Scan(&sources); err != nil || sources != 1 {
|
||||||
|
t.Fatalf("重建任务应保存顺运宝来源:count=%d err=%v", sources, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateSybPddCollectTasksForUser_不可采集明细整批拒绝(t *testing.T) {
|
func TestCreateSybPddCollectTasksForUser_不可采集明细整批拒绝(t *testing.T) {
|
||||||
|
|||||||
+28
-14
@@ -950,15 +950,16 @@ type SybOrderView struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
SybStageSpecMissing = "spec_missing"
|
SybStageSpecMissing = "spec_missing"
|
||||||
SybStagePddMissing = "pdd_missing"
|
SybStagePddMissing = "pdd_missing"
|
||||||
SybStagePddPending = "pdd_pending"
|
SybStagePddPending = "pdd_pending"
|
||||||
SybStagePddCollecting = "pdd_collecting"
|
SybStagePddCollecting = "pdd_collecting"
|
||||||
SybStagePddFailed = "pdd_failed"
|
SybStagePddCollectingStale = "pdd_collecting_stale"
|
||||||
SybStageMappingPending = "mapping_pending"
|
SybStagePddFailed = "pdd_failed"
|
||||||
SybStagePurchaseReady = "purchase_ready"
|
SybStageMappingPending = "mapping_pending"
|
||||||
SybStageTaskCreated = "task_created"
|
SybStagePurchaseReady = "purchase_ready"
|
||||||
SybStagePurchaseBlocked = "purchase_blocked"
|
SybStageTaskCreated = "task_created"
|
||||||
|
SybStagePurchaseBlocked = "purchase_blocked"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SybStageOption 是顺运宝处理阶段筛选项。
|
// SybStageOption 是顺运宝处理阶段筛选项。
|
||||||
@@ -974,6 +975,7 @@ func SybStageOptions() []SybStageOption {
|
|||||||
{Value: SybStagePddMissing, Text: "未关联 PDD"},
|
{Value: SybStagePddMissing, Text: "未关联 PDD"},
|
||||||
{Value: SybStagePddPending, Text: "PDD 待采集"},
|
{Value: SybStagePddPending, Text: "PDD 待采集"},
|
||||||
{Value: SybStagePddCollecting, Text: "PDD 采集中"},
|
{Value: SybStagePddCollecting, Text: "PDD 采集中"},
|
||||||
|
{Value: SybStagePddCollectingStale, Text: "PDD 采集中(超时)"},
|
||||||
{Value: SybStagePddFailed, Text: "PDD 采集失败"},
|
{Value: SybStagePddFailed, Text: "PDD 采集失败"},
|
||||||
{Value: SybStageMappingPending, Text: "规格待匹配"},
|
{Value: SybStageMappingPending, Text: "规格待匹配"},
|
||||||
{Value: SybStagePurchaseReady, Text: "可创建采购任务"},
|
{Value: SybStagePurchaseReady, Text: "可创建采购任务"},
|
||||||
@@ -1008,6 +1010,8 @@ func sybStageFor(c repository.SybOrderContext) (stage, text, help, action string
|
|||||||
return SybStagePddMissing, "未关联 PDD", "下一步关联采购商品。", "关联 PDD"
|
return SybStagePddMissing, "未关联 PDD", "下一步关联采购商品。", "关联 PDD"
|
||||||
case c.PddCollectStatus == string(model.CollectPending):
|
case c.PddCollectStatus == string(model.CollectPending):
|
||||||
return SybStagePddPending, "PDD 待采集", "已关联 PDD 商品,尚未创建采集任务。", "创建采集任务"
|
return SybStagePddPending, "PDD 待采集", "已关联 PDD 商品,尚未创建采集任务。", "创建采集任务"
|
||||||
|
case c.PddCollectStatus == string(model.CollectCollecting) && sybCollectingIsStale(c):
|
||||||
|
return SybStagePddCollectingStale, "PDD 采集中(超时)", "采集状态已超时或找不到有效任务,可以重新创建采集任务。", "重新创建采集任务"
|
||||||
case c.PddCollectStatus == string(model.CollectCollecting):
|
case c.PddCollectStatus == string(model.CollectCollecting):
|
||||||
return SybStagePddCollecting, "PDD 采集中", "客户端正在采集规格和价格,请稍后刷新。", "查看采集状态"
|
return SybStagePddCollecting, "PDD 采集中", "客户端正在采集规格和价格,请稍后刷新。", "查看采集状态"
|
||||||
case c.PddCollectStatus == string(model.CollectFailed):
|
case c.PddCollectStatus == string(model.CollectFailed):
|
||||||
@@ -1027,6 +1031,14 @@ func sybStageFor(c repository.SybOrderContext) (stage, text, help, action string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sybCollectingIsStale(c repository.SybOrderContext) bool {
|
||||||
|
if !c.HasActiveCollectTask {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
updatedAt, ok := model.ParseISO(c.PddUpdatedAt)
|
||||||
|
return !ok || time.Since(updatedAt) > model.CollectStaleAfter
|
||||||
|
}
|
||||||
|
|
||||||
// SybListResult 是列表页要的全部数据。
|
// SybListResult 是列表页要的全部数据。
|
||||||
type SybListResult struct {
|
type SybListResult struct {
|
||||||
Rows []SybOrderView
|
Rows []SybOrderView
|
||||||
@@ -1045,7 +1057,8 @@ type SybListResult struct {
|
|||||||
func ListSybOrdersView(db *sql.DB, keyword, shop, stage string, page int) (*SybListResult, error) {
|
func ListSybOrdersView(db *sql.DB, keyword, shop, stage string, page int) (*SybListResult, error) {
|
||||||
shop = strings.TrimSpace(shop)
|
shop = strings.TrimSpace(shop)
|
||||||
stage = ParseSybStage(stage)
|
stage = ParseSybStage(stage)
|
||||||
if stage == SybStageMappingPending || stage == SybStagePurchaseReady ||
|
if stage == SybStagePddCollecting || stage == SybStagePddCollectingStale ||
|
||||||
|
stage == SybStageMappingPending || stage == SybStagePurchaseReady ||
|
||||||
stage == SybStageTaskCreated || stage == SybStagePurchaseBlocked {
|
stage == SybStageTaskCreated || stage == SybStagePurchaseBlocked {
|
||||||
return listAdvancedSybStage(db, keyword, shop, stage, page)
|
return listAdvancedSybStage(db, keyword, shop, stage, page)
|
||||||
}
|
}
|
||||||
@@ -1131,7 +1144,8 @@ func sybOrderViewFor(context repository.SybOrderContext) SybOrderView {
|
|||||||
Quantity: o.Quantity, ImageURL: o.ImageURL,
|
Quantity: o.Quantity, ImageURL: o.ImageURL,
|
||||||
UpdatedAt: formatLocalTime(o.UpdatedAt)}
|
UpdatedAt: formatLocalTime(o.UpdatedAt)}
|
||||||
v.Stage, v.StageText, v.StageHelp, v.ActionText = sybStageFor(context)
|
v.Stage, v.StageText, v.StageHelp, v.ActionText = sybStageFor(context)
|
||||||
v.CanCollect = v.Stage == SybStagePddPending || v.Stage == SybStagePddFailed
|
v.CanCollect = v.Stage == SybStagePddPending || v.Stage == SybStagePddFailed ||
|
||||||
|
v.Stage == SybStagePddCollectingStale
|
||||||
v.CanPurchase = v.Stage == SybStagePurchaseReady
|
v.CanPurchase = v.Stage == SybStagePurchaseReady
|
||||||
switch {
|
switch {
|
||||||
case v.CanCollect:
|
case v.CanCollect:
|
||||||
@@ -1205,9 +1219,10 @@ func GetSybProcessingDetail(db *sql.DB, sybID string) (*SybProcessingDetail, err
|
|||||||
PddGoodsID: context.PddGoodsID, PddURL: context.PddGoodsURL,
|
PddGoodsID: context.PddGoodsID, PddURL: context.PddGoodsURL,
|
||||||
CollectStatus: context.PddCollectStatus, CollectMsg: context.PddCollectMsg,
|
CollectStatus: context.PddCollectStatus, CollectMsg: context.PddCollectMsg,
|
||||||
}
|
}
|
||||||
|
d.Stage, d.StageText, d.StageHelp, _ = sybStageFor(*context)
|
||||||
d.CanCollect = context.PddGoodsID != "" &&
|
d.CanCollect = context.PddGoodsID != "" &&
|
||||||
(context.PddCollectStatus == string(model.CollectPending) ||
|
(d.Stage == SybStagePddPending || d.Stage == SybStagePddFailed ||
|
||||||
context.PddCollectStatus == string(model.CollectFailed))
|
d.Stage == SybStagePddCollectingStale)
|
||||||
d.HasActiveTask = context.HasActiveTask
|
d.HasActiveTask = context.HasActiveTask
|
||||||
if context.PddCollectStatus == string(model.CollectCollected) && context.PddSkusJSON != "" {
|
if context.PddCollectStatus == string(model.CollectCollected) && context.PddSkusJSON != "" {
|
||||||
choices, keys, names, parseErr := pddOptionChoices(context.PddSkusJSON)
|
choices, keys, names, parseErr := pddOptionChoices(context.PddSkusJSON)
|
||||||
@@ -1226,7 +1241,6 @@ func GetSybProcessingDetail(db *sql.DB, sybID string) (*SybProcessingDetail, err
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
d.ContextVersion = mappingContextVersion(*context)
|
d.ContextVersion = mappingContextVersion(*context)
|
||||||
d.Stage, d.StageText, d.StageHelp, _ = sybStageFor(*context)
|
|
||||||
return d, nil
|
return d, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -113,7 +113,15 @@ func TestSybStageFor_全部筛选阶段与逐行推导一致(t *testing.T) {
|
|||||||
{SybStageSpecMissing, "核对数据", copyWith(func(c *repository.SybOrderContext) { c.Order.SpecKey = "" })},
|
{SybStageSpecMissing, "核对数据", copyWith(func(c *repository.SybOrderContext) { c.Order.SpecKey = "" })},
|
||||||
{SybStagePddMissing, "关联 PDD", copyWith(func(c *repository.SybOrderContext) { c.PddGoodsID, c.PddCollectStatus = "", "" })},
|
{SybStagePddMissing, "关联 PDD", copyWith(func(c *repository.SybOrderContext) { c.PddGoodsID, c.PddCollectStatus = "", "" })},
|
||||||
{SybStagePddPending, "创建采集任务", copyWith(func(c *repository.SybOrderContext) { c.PddCollectStatus = string(model.CollectPending) })},
|
{SybStagePddPending, "创建采集任务", copyWith(func(c *repository.SybOrderContext) { c.PddCollectStatus = string(model.CollectPending) })},
|
||||||
{SybStagePddCollecting, "查看采集状态", copyWith(func(c *repository.SybOrderContext) { c.PddCollectStatus = string(model.CollectCollecting) })},
|
{SybStagePddCollecting, "查看采集状态", copyWith(func(c *repository.SybOrderContext) {
|
||||||
|
c.PddCollectStatus = string(model.CollectCollecting)
|
||||||
|
c.PddUpdatedAt = model.NowISO()
|
||||||
|
c.HasActiveCollectTask = true
|
||||||
|
})},
|
||||||
|
{SybStagePddCollectingStale, "重新创建采集任务", copyWith(func(c *repository.SybOrderContext) {
|
||||||
|
c.PddCollectStatus = string(model.CollectCollecting)
|
||||||
|
c.PddUpdatedAt = model.NowISO()
|
||||||
|
})},
|
||||||
{SybStagePddFailed, "重新采集", copyWith(func(c *repository.SybOrderContext) { c.PddCollectStatus = string(model.CollectFailed) })},
|
{SybStagePddFailed, "重新采集", copyWith(func(c *repository.SybOrderContext) { c.PddCollectStatus = string(model.CollectFailed) })},
|
||||||
{SybStageMappingPending, "匹配规格", copyWith(func(c *repository.SybOrderContext) { c.MappingOptionKey = "" })},
|
{SybStageMappingPending, "匹配规格", copyWith(func(c *repository.SybOrderContext) { c.MappingOptionKey = "" })},
|
||||||
{SybStageTaskCreated, "查看采购任务", copyWith(func(c *repository.SybOrderContext) { c.HasActiveTask = true })},
|
{SybStageTaskCreated, "查看采购任务", copyWith(func(c *repository.SybOrderContext) { c.HasActiveTask = true })},
|
||||||
@@ -150,6 +158,10 @@ func TestSybOrderViewFor_批量动作能力按阶段隔离(t *testing.T) {
|
|||||||
context := base
|
context := base
|
||||||
context.PddCollectStatus = string(tc.status)
|
context.PddCollectStatus = string(tc.status)
|
||||||
context.MappingOptionKey = tc.mappingKey
|
context.MappingOptionKey = tc.mappingKey
|
||||||
|
if tc.status == model.CollectCollecting {
|
||||||
|
context.PddUpdatedAt = model.NowISO()
|
||||||
|
context.HasActiveCollectTask = true
|
||||||
|
}
|
||||||
view := sybOrderViewFor(context)
|
view := sybOrderViewFor(context)
|
||||||
if view.CanCollect != tc.wantCollect || view.CanPurchase != tc.wantPurchase {
|
if view.CanCollect != tc.wantCollect || view.CanPurchase != tc.wantPurchase {
|
||||||
t.Errorf("status=%s: CanCollect=%v CanPurchase=%v", tc.status, view.CanCollect, view.CanPurchase)
|
t.Errorf("status=%s: CanCollect=%v CanPurchase=%v", tc.status, view.CanCollect, view.CanPurchase)
|
||||||
@@ -171,3 +183,14 @@ func TestSybOrderViewFor_批量动作能力按阶段隔离(t *testing.T) {
|
|||||||
t.Fatalf("采购就绪行应只允许采购:CanCollect=%v CanPurchase=%v", view.CanCollect, view.CanPurchase)
|
t.Fatalf("采购就绪行应只允许采购:CanCollect=%v CanPurchase=%v", view.CanCollect, view.CanPurchase)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSybCollectingIsStale_有效任务但时间损坏时允许恢复(t *testing.T) {
|
||||||
|
context := repository.SybOrderContext{
|
||||||
|
PddCollectStatus: string(model.CollectCollecting),
|
||||||
|
PddUpdatedAt: "not-a-time",
|
||||||
|
HasActiveCollectTask: true,
|
||||||
|
}
|
||||||
|
if !sybCollectingIsStale(context) {
|
||||||
|
t.Fatal("无法解析更新时间时不应永久停留在正常采集中")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+15
-3
@@ -404,7 +404,7 @@ func (r *TaskListResult) StatusLine() string {
|
|||||||
|
|
||||||
// DeleteTasks 批量删除任务,返回实际删掉的条数。
|
// DeleteTasks 批量删除任务,返回实际删掉的条数。
|
||||||
func DeleteTasks(db *sql.DB, taskIDs []string) (int64, error) {
|
func DeleteTasks(db *sql.DB, taskIDs []string) (int64, error) {
|
||||||
return repository.DeleteTasks(db, dedupe(taskIDs))
|
return deleteTasksInScope(db, dedupe(taskIDs), "", false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteTasksForUser 保证整批删除要么全部在当前范围内成功,要么全部回滚。
|
// DeleteTasksForUser 保证整批删除要么全部在当前范围内成功,要么全部回滚。
|
||||||
@@ -413,7 +413,10 @@ func DeleteTasksForUser(db *sql.DB, actor *model.User, taskIDs []string) (int64,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
ids := dedupe(taskIDs)
|
return deleteTasksInScope(db, dedupe(taskIDs), visibleUserID, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteTasksInScope(db *sql.DB, ids []string, visibleUserID string, enforceExactScope bool) (int64, error) {
|
||||||
if len(ids) == 0 {
|
if len(ids) == 0 {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
@@ -422,13 +425,22 @@ func DeleteTasksForUser(db *sql.DB, actor *model.User, taskIDs []string) (int64,
|
|||||||
return 0, fmt.Errorf("开始删除任务事务失败: %w", err)
|
return 0, fmt.Errorf("开始删除任务事务失败: %w", err)
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
goodsIDs, err := repository.ListCollectTaskGoodsIDsInScope(tx, ids, visibleUserID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
n, err := repository.DeleteTasksInScope(tx, ids, visibleUserID)
|
n, err := repository.DeleteTasksInScope(tx, ids, visibleUserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
if n != int64(len(ids)) {
|
if enforceExactScope && n != int64(len(ids)) {
|
||||||
return 0, ErrTaskNotVisible
|
return 0, ErrTaskNotVisible
|
||||||
}
|
}
|
||||||
|
for _, goodsID := range goodsIDs {
|
||||||
|
if _, err := repository.ResetCollectingIfNoActiveTask(tx, goodsID); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
return 0, fmt.Errorf("提交删除任务事务失败: %w", err)
|
return 0, fmt.Errorf("提交删除任务事务失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -594,6 +594,33 @@ func TestDeleteTasks_批量删除(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDeleteTasks_删除最后有效采集任务才回收商品状态(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
if _, err := repository.EnsurePddProduct(db, "111", "https://mobile.yangkeduo.com/goods.html?goods_id=111"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`UPDATE pdd_products SET collect_status='collecting' WHERE goods_id='111'`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
insertTestTask(t, db, testTaskParams{taskID: "COL-A", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "111"})
|
||||||
|
insertTestTask(t, db, testTaskParams{taskID: "COL-B", taskType: model.TaskCollect, status: model.TaskAssigned, pddGoodsID: "111"})
|
||||||
|
|
||||||
|
if _, err := DeleteTasks(db, []string{"COL-A"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
product, err := repository.GetPddProductByGoodsID(db, "111")
|
||||||
|
if err != nil || product.CollectStatus != model.CollectCollecting {
|
||||||
|
t.Fatalf("仍有有效任务时不应回收:status=%s err=%v", product.CollectStatus, err)
|
||||||
|
}
|
||||||
|
if _, err := DeleteTasks(db, []string{"COL-B"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
product, err = repository.GetPddProductByGoodsID(db, "111")
|
||||||
|
if err != nil || product.CollectStatus != model.CollectPending {
|
||||||
|
t.Fatalf("删除最后有效任务后应回收为 pending:status=%s err=%v", product.CollectStatus, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTaskScope_采购员只能查看自己的任务且不能伪造筛选(t *testing.T) {
|
func TestTaskScope_采购员只能查看自己的任务且不能伪造筛选(t *testing.T) {
|
||||||
db := newTestDB(t)
|
db := newTestDB(t)
|
||||||
admin, buyerA, buyerB := insertTaskUsers(t, db)
|
admin, buyerA, buyerB := insertTaskUsers(t, db)
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
<input type="hidden" name="stage" value="{{$.StageFilter}}">
|
<input type="hidden" name="stage" value="{{$.StageFilter}}">
|
||||||
<input type="hidden" name="page" value="{{$.CurrentPage}}">
|
<input type="hidden" name="page" value="{{$.CurrentPage}}">
|
||||||
{{if .CanCollect}}
|
{{if .CanCollect}}
|
||||||
<button type="submit">{{if eq .CollectStatus "failed"}}重新创建采集任务{{else}}创建采集任务{{end}}</button>
|
<button type="submit">{{if or (eq .CollectStatus "failed") (eq .Stage "pdd_collecting_stale")}}重新创建采集任务{{else}}创建采集任务{{end}}</button>
|
||||||
{{else}}
|
{{else}}
|
||||||
<span class="hint">{{if eq .CollectStatus "collecting"}}采集任务正在执行,请稍后刷新。{{else}}PDD 数据已采集,下一步匹配采购规格。{{end}}</span>
|
<span class="hint">{{if eq .CollectStatus "collecting"}}采集任务正在执行,请稍后刷新。{{else}}PDD 数据已采集,下一步匹配采购规格。{{end}}</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -266,7 +266,8 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立
|
|||||||
|
|
||||||
- **类型**:全部 / 采集 / 采购。
|
- **类型**:全部 / 采集 / 采购。
|
||||||
- **状态**:全部 + 7 个状态,见 §6.2。
|
- **状态**:全部 + 7 个状态,见 §6.2。
|
||||||
- **关键词**:同时匹配任务编号、订单号、PDD 商品 ID。
|
- **关键词**:同时匹配任务编号、采购订单号、PDD 商品 ID;从顺运宝创建的采集任务
|
||||||
|
还要匹配其全部来源顺运宝订单号和明细 ID。
|
||||||
- **创建人**:仅管理员显示,可选全部账号(包含已禁用账号)或“历史任务”。
|
- **创建人**:仅管理员显示,可选全部账号(包含已禁用账号)或“历史任务”。
|
||||||
|
|
||||||
`[必须]` 采购员登录后只可查看自己创建的任务;URL 参数不能扩大范围。管理员查看
|
`[必须]` 采购员登录后只可查看自己创建的任务;URL 参数不能扩大范围。管理员查看
|
||||||
|
|||||||
@@ -108,6 +108,10 @@ SQLite 同一时刻只允许一个写事务,连接放太开会互相抢锁、
|
|||||||
| v8(#59) | 顺运宝同步记录:新增 `syb_sync_runs` 和开始时间倒序索引;未改写 v1–v7。 |
|
| v8(#59) | 顺运宝同步记录:新增 `syb_sync_runs` 和开始时间倒序索引;未改写 v1–v7。 |
|
||||||
| v9(#151) | `syb_orders` 增加可空 `shop_name`,从有效 `syb_data.stock.shopName` 回填历史店铺名;无效 JSON、缺字段和空白值保持 `NULL`。 |
|
| v9(#151) | `syb_orders` 增加可空 `shop_name`,从有效 `syb_data.stock.shopName` 回填历史店铺名;无效 JSON、缺字段和空白值保持 `NULL`。 |
|
||||||
|
|
||||||
|
生产 MySQL 迁移继续独立追加;v12(#165)新增 `task_syb_sources`,并把没有任何
|
||||||
|
有效采集任务的孤立 `pdd_products.collect_status='collecting'` 回收到 `pending`。
|
||||||
|
历史 SQLite migrations 保持冻结,不追加该表。
|
||||||
|
|
||||||
**v3 为什么丢弃旧 `sku_mappings` 数据(见 #20):** 新主键需要 `pdd_option_key`,
|
**v3 为什么丢弃旧 `sku_mappings` 数据(见 #20):** 新主键需要 `pdd_option_key`,
|
||||||
这是 Go 的 `service.OptionKey()` 用 `json.Marshal` 算出来的规范化键,SQL 语句
|
这是 Go 的 `service.OptionKey()` 用 `json.Marshal` 算出来的规范化键,SQL 语句
|
||||||
复现不了。硬凑一个键出来有两种后果:算错了会让映射静默失效,需要人工重新匹配一遍;
|
复现不了。硬凑一个键出来有两种后果:算错了会让映射静默失效,需要人工重新匹配一遍;
|
||||||
@@ -290,8 +294,8 @@ CREATE INDEX idx_pdd_products_status ON pdd_products(collect_status);
|
|||||||
`collect_status` 就永远卡在 `collecting`,界面上没有任何入口能救回来,
|
`collect_status` 就永远卡在 `collecting`,界面上没有任何入口能救回来,
|
||||||
只能改数据库(见 #24)。
|
只能改数据库(见 #24)。
|
||||||
|
|
||||||
修复:`collecting` 状态超过 `model.CollectStaleAfter`(15 分钟)**也允许**
|
修复:`collecting` 状态超过 `model.CollectStaleAfter`(15 分钟),或已经找不到
|
||||||
重新建采集任务:
|
`pending/assigned/claimed` 采集任务时,**也允许**重新建采集任务:
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
-- repository.MarkCollecting,简化版
|
-- repository.MarkCollecting,简化版
|
||||||
@@ -299,7 +303,8 @@ UPDATE pdd_products
|
|||||||
SET collect_status = 'collecting', updated_at = ?
|
SET collect_status = 'collecting', updated_at = ?
|
||||||
WHERE goods_id = ? AND deleted_at IS NULL
|
WHERE goods_id = ? AND deleted_at IS NULL
|
||||||
AND ( collect_status IN ('pending', 'failed')
|
AND ( collect_status IN ('pending', 'failed')
|
||||||
OR (collect_status = 'collecting' AND updated_at < ?) )
|
OR (collect_status = 'collecting' AND
|
||||||
|
(updated_at < ? OR NOT EXISTS (有效采集任务))) )
|
||||||
```
|
```
|
||||||
|
|
||||||
`[必须]` 15 分钟为什么是这个数:采集本身几十秒到两分钟,加上排队等客户端来领,
|
`[必须]` 15 分钟为什么是这个数:采集本身几十秒到两分钟,加上排队等客户端来领,
|
||||||
@@ -698,6 +703,29 @@ CREATE INDEX idx_tasks_order ON tasks(order_no);
|
|||||||
|
|
||||||
状态含义见 [01 需求](01-requirements.md) §6.2。
|
状态含义见 [01 需求](01-requirements.md) §6.2。
|
||||||
|
|
||||||
|
### 7.1 `task_syb_sources` 采集任务来源(生产 MySQL v12)
|
||||||
|
|
||||||
|
从顺运宝创建采集任务时,同一个 PDD 商品可能对应多个货运单明细。来源使用关联表,
|
||||||
|
不能压进 `tasks.syb_id` 单列;后者仍只表示采购任务自身的顺运宝明细。
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE task_syb_sources (
|
||||||
|
task_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
syb_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
created_at VARCHAR(35) NOT NULL,
|
||||||
|
PRIMARY KEY (task_id, syb_id),
|
||||||
|
KEY idx_task_syb_sources_syb (syb_id, task_id),
|
||||||
|
FOREIGN KEY (task_id) REFERENCES tasks(task_id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (syb_id) REFERENCES syb_orders(syb_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- `[必须]` 任务和全部来源在同一事务写入;按 PDD 商品去重后仍要保留每条来源。
|
||||||
|
- `[必须]` 采集采购关键字搜索通过本表支持顺运宝订单号和明细 ID。
|
||||||
|
- `[必须]` 删除任务后若同商品已无 `pending/assigned/claimed` 采集任务,必须在同一事务
|
||||||
|
把仍为 `collecting` 的商品回收到 `pending`;有其他有效任务时不得回收。
|
||||||
|
- 历史任务没有可靠来源,不做猜测回填,仍可按任务号和 PDD 商品 ID 查询。
|
||||||
|
|
||||||
## 8. `task_claims` 领取历史
|
## 8. `task_claims` 领取历史
|
||||||
|
|
||||||
记录"哪台客户端领过哪个任务"。
|
记录"哪台客户端领过哪个任务"。
|
||||||
|
|||||||
@@ -526,8 +526,10 @@ HTML 片段,只替换弹窗内部的表格、总数和分页。主货运单表
|
|||||||
- 图片显示小缩略图。点击缩略图在弹窗中按比例查看顺运宝原图,并提供新窗口打开入口;
|
- 图片显示小缩略图。点击缩略图在弹窗中按比例查看顺运宝原图,并提供新窗口打开入口;
|
||||||
图片加载失败时显示可读提示。`[必须]` 只存 URL,不代理、缓存或把图片塞进数据库。
|
图片加载失败时显示可读提示。`[必须]` 只存 URL,不代理、缓存或把图片塞进数据库。
|
||||||
- 处理阶段是按当前关联事实**算出来的**,不是存一份容易过期的状态字段。阶段至少区分:
|
- 处理阶段是按当前关联事实**算出来的**,不是存一份容易过期的状态字段。阶段至少区分:
|
||||||
采购数据异常(顺运宝未提供规格)、未关联 PDD、PDD 待采集、采集中、
|
采购数据异常(顺运宝未提供规格)、未关联 PDD、PDD 待采集、采集中、采集中(超时)、
|
||||||
采集失败、规格待匹配、可采购和已创建任务。
|
采集失败、规格待匹配、可采购和已创建任务。
|
||||||
|
- `collecting` 已超过 15 分钟,或已找不到 `pending/assigned/claimed` 采集任务时,
|
||||||
|
显示“PDD 采集中(超时)”和“重新创建采集任务”,不能继续伪装成正常进行中。
|
||||||
- 每行显示一个有文字的“下一步”按钮。商品标题是服务端渲染处理弹窗的明确入口,
|
- 每行显示一个有文字的“下一步”按钮。商品标题是服务端渲染处理弹窗的明确入口,
|
||||||
支持鼠标和键盘;双击其他普通单元格不执行动作。图片入口只负责查看原图,不能打开处理详情。
|
支持鼠标和键盘;双击其他普通单元格不执行动作。图片入口只负责查看原图,不能打开处理详情。
|
||||||
- “下一步”随实时阶段变化:未关联 PDD、待采集、采集中、采集失败、规格待匹配、
|
- “下一步”随实时阶段变化:未关联 PDD、待采集、采集中、采集失败、规格待匹配、
|
||||||
@@ -536,7 +538,8 @@ HTML 片段,只替换弹窗内部的表格、总数和分页。主货运单表
|
|||||||
- 规格原文为空时不显示匹配表单,明确提示“顺运宝未提供规格”,并禁止建立映射或创建采购任务。
|
- 规格原文为空时不显示匹配表单,明确提示“顺运宝未提供规格”,并禁止建立映射或创建采购任务。
|
||||||
- 完整货运单 JSON 不作为列显示,落在 `syb_data` 里,供后续排查用。
|
- 完整货运单 JSON 不作为列显示,落在 `syb_data` 里,供后续排查用。
|
||||||
|
|
||||||
复选框按批量动作能力启用,不再等同于“可采购”:PDD 待采集、PDD 采集失败行可用于
|
复选框按批量动作能力启用,不再等同于“可采购”:PDD 待采集、PDD 采集失败、
|
||||||
|
PDD 采集中(超时)行可用于
|
||||||
“创建 PDD 采集任务”,可创建采购任务行可用于“创建采购任务”,其他阶段继续禁用并通过
|
“创建 PDD 采集任务”,可创建采购任务行可用于“创建采购任务”,其他阶段继续禁用并通过
|
||||||
`title` 和无障碍标签说明原因。混合勾选时两个按钮分别统计和提交自身兼容的行,不能把
|
`title` 和无障碍标签说明原因。混合勾选时两个按钮分别统计和提交自身兼容的行,不能把
|
||||||
待采集商品带进采购确认,也不能把采购就绪商品带进采集请求。
|
待采集商品带进采购确认,也不能把采购就绪商品带进采集请求。
|
||||||
@@ -639,7 +642,7 @@ ID 和在线状态;所有可见 Client 都可选择且只有一个时自动选
|
|||||||
|
|
||||||
- **类型**:全部 / 采集 / 采购。
|
- **类型**:全部 / 采集 / 采购。
|
||||||
- **状态**:全部 + 7 个状态,见 [01 需求](01-requirements.md) §6.2。
|
- **状态**:全部 + 7 个状态,见 [01 需求](01-requirements.md) §6.2。
|
||||||
- **关键词**:同时匹配任务编号、订单号、PDD 商品 ID。
|
- **关键词**:同时匹配任务编号、采购订单号、PDD 商品 ID、来源顺运宝订单号和明细 ID。
|
||||||
- **创建人**:仅管理员显示;包含全部账号、已禁用账号和“历史任务”。采购员不显示
|
- **创建人**:仅管理员显示;包含全部账号、已禁用账号和“历史任务”。采购员不显示
|
||||||
此控件,服务端固定只查询本人创建的任务。
|
此控件,服务端固定只查询本人创建的任务。
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user