diff --git a/admin/handler/web/inner_code.go b/admin/handler/web/inner_code.go index 5e20c83..a969f72 100644 --- a/admin/handler/web/inner_code.go +++ b/admin/handler/web/inner_code.go @@ -155,6 +155,70 @@ func (h *Handler) InnerCodeMatch(c *gin.Context) { h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, message) } +// InnerCodeApply 在操作员确认后逐条执行安全回写。 +func (h *Handler) InnerCodeApply(c *gin.Context) { + businessDate, status, keyword := c.PostForm("date"), c.PostForm("status"), c.PostForm("q") + pageNumber := service.ParsePage(c.PostForm("page")) + ids := make([]int64, 0) + for _, raw := range c.PostFormArray("ids") { + if id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64); err == nil && id > 0 { + ids = append(ids, id) + } + } + client, message := h.innerCodeSybClient() + if message != "" { + h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, message) + return + } + result, err := service.ApplyInnerCodes(c.Request.Context(), h.db, client, ids, currentUser(c).UserID) + if err != nil { + h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, "回写未完成:"+err.Error()) + return + } + message = fmt.Sprintf("回写完成:选择 %d 条,成功 %d 条,已存在 %d 条,跳过 %d 条,失败 %d 条,需核对 %d 条。", + result.Requested, result.Updated, result.AlreadyFilled, result.Skipped, result.Failed, result.NeedsCheck) + h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, message) +} + +// InnerCodeRecheck 只重新读取未知结果,绝不再次发送删除或写入。 +func (h *Handler) InnerCodeRecheck(c *gin.Context) { + businessDate, status, keyword := c.PostForm("date"), c.PostForm("status"), c.PostForm("q") + pageNumber := service.ParsePage(c.PostForm("page")) + id, err := strconv.ParseInt(strings.TrimSpace(c.PostForm("id")), 10, 64) + if err != nil || id <= 0 { + h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, "记录编号无效,没有执行核对。") + return + } + client, message := h.innerCodeSybClient() + if message != "" { + h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, message) + return + } + _, message, err = service.RecheckInnerCode(c.Request.Context(), h.db, client, id) + if err != nil { + message = "重新核对失败:" + err.Error() + } + h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, message) +} + +func (h *Handler) innerCodeSybClient() (*syb.Client, string) { + cfg, err := config.Load() + if err != nil { + return nil, "顺运宝配置不可用,没有发送远端请求。" + } + client, err := syb.New(cfg.Syb.BaseURL) + if err != nil { + return nil, "顺运宝地址配置有误,没有发送远端请求。" + } + if err := service.EnsureSybSession(h.db, client, cfg.Syb.Username, time.Now()); err != nil { + if errors.Is(err, service.ErrSybLoginRequired) { + return nil, "顺运宝会话已过期,请先到“顺运宝数据”页面登录。" + } + return nil, "校验顺运宝会话失败,没有发送远端请求。" + } + return client, "" +} + func (h *Handler) innerCodeRedirect(c *gin.Context, businessDate, status, keyword string, pageNumber int, message string) { values := url.Values{"date": {businessDate}, "page": {strconv.Itoa(pageNumber)}, "message": {message}} if strings.TrimSpace(status) != "" { diff --git a/admin/handler/web/web.go b/admin/handler/web/web.go index fc32646..e2710af 100644 --- a/admin/handler/web/web.go +++ b/admin/handler/web/web.go @@ -120,6 +120,8 @@ func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration, aiSecret innerCodes := pages.Group("/inner-codes") innerCodes.POST("/import", h.InnerCodeImport) innerCodes.POST("/match", h.InnerCodeMatch) + innerCodes.POST("/apply", h.InnerCodeApply) + innerCodes.POST("/recheck", h.InnerCodeRecheck) // 7. 用户管理:先经过网页登录,再叠加管理员角色校验。 users := pages.Group("/users", AdminRequired()) diff --git a/admin/main.go b/admin/main.go index 8ca9f9e..e9d2c0f 100644 --- a/admin/main.go +++ b/admin/main.go @@ -78,6 +78,13 @@ func main() { if aiInterrupted > 0 { log.Printf("已把 %d 个上次进程遗留的 AI 匹配批次标记为中断", aiInterrupted) } + innerCodeInterrupted, err := service.InterruptApplyingInnerCodes(db, time.Now()) + if err != nil { + log.Fatalf("恢复中断的档口入库码回写失败: %v", err) + } + if innerCodeInterrupted > 0 { + log.Printf("已把 %d 条上次进程遗留的档口入库码回写标记为需核对", innerCodeInterrupted) + } log.Printf("数据库已就绪") // 3. Web 引擎 diff --git a/admin/repository/inner_code.go b/admin/repository/inner_code.go index 2cf81e7..ab9850a 100644 --- a/admin/repository/inner_code.go +++ b/admin/repository/inner_code.go @@ -274,6 +274,102 @@ func scanInnerCodeRecord(scanner innerCodeRowScanner) (model.InnerCodeRecord, er return row, nil } +// ClaimInnerCodeForApply 原子领取一条 ready 记录。返回 claimed=false 表示状态已变化。 +func ClaimInnerCodeForApply(db *sql.DB, id int64, actorUserID, now string) (*model.InnerCodeRecord, bool, error) { + tx, err := db.Begin() + if err != nil { + return nil, false, fmt.Errorf("开始领取档口入库码事务失败: %w", err) + } + defer tx.Rollback() + record, err := scanInnerCodeRecord(tx.QueryRow(`SELECT `+innerCodeListColumns+ + ` FROM syb_inner_code_records WHERE id=? FOR UPDATE`, id)) + if errors.Is(err, sql.ErrNoRows) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + if record.Status != model.InnerCodeReady { + return &record, false, nil + } + result, err := tx.Exec(`UPDATE syb_inner_code_records + SET status='applying',applied_by_user_id=?,apply_started_at=?,updated_at=? + WHERE id=? AND status='ready'`, actorUserID, now, now, id) + if err != nil { + return nil, false, fmt.Errorf("领取档口入库码记录失败: %w", err) + } + if affected, err := result.RowsAffected(); err != nil || affected != 1 { + return &record, false, nil + } + if err := tx.Commit(); err != nil { + return nil, false, fmt.Errorf("提交档口入库码领取失败: %w", err) + } + record.Status = model.InnerCodeApplying + record.AppliedByUserID = actorUserID + record.ApplyStartedAt = now + record.UpdatedAt = now + return &record, true, nil +} + +// FinishInnerCodeApply 保存一条已领取记录的最终结果。 +func FinishInnerCodeApply(q Execer, id int64, status model.InnerCodeStatus, message, remoteCode, finishedAt string) error { + result, err := q.Exec(`UPDATE syb_inner_code_records + SET status=?,result_message=?,remote_inner_code=?, + applied_at=CASE WHEN ? IN ('updated','already_filled') THEN ? ELSE applied_at END, + updated_at=? + WHERE id=? AND status='applying'`, status, message, nullableString(remoteCode), status, finishedAt, finishedAt, id) + if err != nil { + return fmt.Errorf("保存档口入库码回写结果失败: %w", err) + } + if affected, err := result.RowsAffected(); err != nil || affected != 1 { + return fmt.Errorf("档口入库码记录 %d 已不在回写中,拒绝覆盖结果", id) + } + return nil +} + +// GetInnerCodeForRecheck 读取一条需核对记录。 +func GetInnerCodeForRecheck(q Execer, id int64) (*model.InnerCodeRecord, error) { + record, err := scanInnerCodeRecord(q.QueryRow(`SELECT `+innerCodeListColumns+ + ` FROM syb_inner_code_records WHERE id=?`, id)) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &record, nil +} + +// SaveInnerCodeRecheck 保存只读重新核对的远端结果,不执行状态领取或写入。 +func SaveInnerCodeRecheck(q Execer, id int64, status model.InnerCodeStatus, message, remoteCode, checkedAt string) error { + result, err := q.Exec(`UPDATE syb_inner_code_records + SET status=?,result_message=?,remote_inner_code=?, + applied_at=CASE WHEN ?='updated' THEN ? ELSE applied_at END,updated_at=? + WHERE id=? AND status='needs_check'`, status, message, nullableString(remoteCode), status, checkedAt, checkedAt, id) + if err != nil { + return fmt.Errorf("保存档口入库码核对结果失败: %w", err) + } + if affected, err := result.RowsAffected(); err != nil || affected != 1 { + return fmt.Errorf("档口入库码记录 %d 已不需要核对", id) + } + return nil +} + +// InterruptApplyingInnerCodes 在启动时把未知结果的 applying 收敛为 needs_check。 +func InterruptApplyingInnerCodes(q Execer, interruptedAt string) (int, error) { + result, err := q.Exec(`UPDATE syb_inner_code_records + SET status='needs_check',result_message='Admin 在回写完成前退出,请重新核对远端结果;系统不会自动重写', + updated_at=? WHERE status='applying'`, interruptedAt) + if err != nil { + return 0, fmt.Errorf("恢复中断的档口入库码回写失败: %w", err) + } + affected, err := result.RowsAffected() + if err != nil { + return 0, fmt.Errorf("读取中断档口入库码数量失败: %w", err) + } + return int(affected), nil +} + func nullablePositiveInt(value int) any { if value <= 0 { return nil diff --git a/admin/repository/inner_code_test.go b/admin/repository/inner_code_test.go new file mode 100644 index 0000000..58663be --- /dev/null +++ b/admin/repository/inner_code_test.go @@ -0,0 +1,40 @@ +package repository + +import ( + "database/sql" + "testing" + + _ "modernc.org/sqlite" +) + +func TestInterruptApplyingInnerCodes_只收敛回写中记录(t *testing.T) { + db, err := sql.Open("sqlite", "file:inner_code_interrupt?mode=memory&cache=shared") + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err := db.Exec(`CREATE TABLE syb_inner_code_records ( + id INTEGER PRIMARY KEY,status TEXT NOT NULL,result_message TEXT,updated_at TEXT NOT NULL + )`); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`INSERT INTO syb_inner_code_records(id,status,updated_at) VALUES + (1,'applying','old'),(2,'ready','old')`); err != nil { + t.Fatal(err) + } + count, err := InterruptApplyingInnerCodes(db, "2026-08-15T01:00:00Z") + if err != nil || count != 1 { + t.Fatalf("count=%d err=%v", count, err) + } + var status, message, updatedAt string + if err := db.QueryRow(`SELECT status,result_message,updated_at FROM syb_inner_code_records WHERE id=1`). + Scan(&status, &message, &updatedAt); err != nil { + t.Fatal(err) + } + if status != "needs_check" || message == "" || updatedAt != "2026-08-15T01:00:00Z" { + t.Fatalf("status=%s message=%q updated=%s", status, message, updatedAt) + } + if err := db.QueryRow(`SELECT status FROM syb_inner_code_records WHERE id=2`).Scan(&status); err != nil || status != "ready" { + t.Fatalf("ready 记录不应变化 status=%s err=%v", status, err) + } +} diff --git a/admin/repository/mysql_db_integration_test.go b/admin/repository/mysql_db_integration_test.go index f7155d8..3705302 100644 --- a/admin/repository/mysql_db_integration_test.go +++ b/admin/repository/mysql_db_integration_test.go @@ -1083,6 +1083,40 @@ func prepareMySQLV8(t *testing.T, db *sql.DB) { mustExec(t, db, `INSERT INTO schema_migrations(version,applied_at) VALUES(8,'2026-08-11T00:00:00Z')`) } +func TestMySQLMigrate_V22升级V23且唯一约束生效(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, `SET FOREIGN_KEY_CHECKS=0`) + mustExec(t, db, `DROP TABLE syb_inner_code_records`) + mustExec(t, db, `DELETE FROM schema_migrations WHERE version=23`) + mustExec(t, db, `SET FOREIGN_KEY_CHECKS=1`) + if err := MigrateMySQL(db); err != nil { + t.Fatalf("v22 升级 v23 失败: %v", err) + } + if err := MigrateMySQL(db); err != nil { + t.Fatalf("v23 重复迁移失败: %v", err) + } + if err := checkMySQLV23Shape(db); err != nil { + t.Fatal(err) + } + now := "2026-08-15T00:00:00Z" + mustExec(t, db, `INSERT INTO users(user_id,username,password_hash,role,status,password_changed_at,created_at,updated_at) + VALUES('inner-user','inner-user','hash','purchaser','active',?,?,?)`, now, now, now) + mustExec(t, db, `INSERT INTO syb_inner_code_records + (business_date,source_row,order_number,stall,spec_raw,spec_key,inner_code,status,created_by_user_id,created_at,updated_at) + VALUES('2026-08-15',2,'ORDER-1','A#1','黑色,M','黑色,M','DK-1','pending','inner-user',?,?)`, now, now) + if _, err := db.Exec(`INSERT INTO syb_inner_code_records + (business_date,source_row,order_number,stall,spec_raw,spec_key,inner_code,status,created_by_user_id,created_at,updated_at) + VALUES('2026-08-15',3,'ORDER-2','B#2','白色,L','白色,L','DK-1','pending','inner-user',?,?)`, now, now); err == nil { + t.Fatal("同一业务日期的 inner_code 唯一约束必须拒绝冲突") + } +} + func mustExec(t *testing.T, db *sql.DB, query string, args ...any) { t.Helper() if _, err := db.Exec(query, args...); err != nil { diff --git a/admin/service/inner_code_apply.go b/admin/service/inner_code_apply.go new file mode 100644 index 0000000..0958b15 --- /dev/null +++ b/admin/service/inner_code_apply.go @@ -0,0 +1,243 @@ +package service + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" + + "cmautobuy/admin/model" + "cmautobuy/admin/repository" + "cmautobuy/admin/syb" +) + +// InnerCodeWriter 是安全回写所需的最小顺运宝接口。 +type InnerCodeWriter interface { + InnerCodeDetailReader + DeleteInnerCode(context.Context, int64) error + UpdateDetailCode(context.Context, int64, int64, string) error +} + +// InnerCodeApplyResult 是一次用户确认操作的统计。 +type InnerCodeApplyResult struct { + Requested int + Updated int + AlreadyFilled int + Skipped int + Failed int + NeedsCheck int +} + +type innerCodeApplyOutcome struct { + Status model.InnerCodeStatus + Message string + RemoteCode string +} + +// ApplyInnerCodes 逐条原子领取并回写。每条写请求只发送一次,单条失败不阻断后续行。 +func ApplyInnerCodes(ctx context.Context, db *sql.DB, writer InnerCodeWriter, ids []int64, actorUserID string) (*InnerCodeApplyResult, error) { + ids = uniquePositiveInnerCodeIDs(ids) + if len(ids) == 0 { + return nil, fmt.Errorf("没有选择可回写记录") + } + if len(ids) > PageSize { + return nil, fmt.Errorf("单次最多回写当前页 %d 条记录", PageSize) + } + result := &InnerCodeApplyResult{Requested: len(ids)} + for _, id := range ids { + now := model.NowISO() + record, claimed, err := repository.ClaimInnerCodeForApply(db, id, actorUserID, now) + if err != nil { + return nil, err + } + if !claimed { + result.Skipped++ + continue + } + outcome := applyClaimedInnerCode(ctx, writer, *record) + finishedAt := model.NowISO() + if err := repository.FinishInnerCodeApply(db, id, outcome.Status, compactInnerCodeMessage(outcome.Message), outcome.RemoteCode, finishedAt); err != nil { + return nil, err + } + switch outcome.Status { + case model.InnerCodeUpdated: + result.Updated++ + case model.InnerCodeAlreadyFilled: + result.AlreadyFilled++ + case model.InnerCodeNeedsCheck: + result.NeedsCheck++ + case model.InnerCodeFailed: + result.Failed++ + default: + result.Skipped++ + } + } + return result, nil +} + +func applyClaimedInnerCode(ctx context.Context, writer InnerCodeWriter, record model.InnerCodeRecord) innerCodeApplyOutcome { + item, err := readCurrentInnerCodeDetail(ctx, writer, record) + if err != nil { + return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck, + Message: "写入前重新读取失败,没有发送删除或写入请求:" + err.Error(), RemoteCode: record.RemoteInnerCode} + } + currentCode := innerCodeRawText(item.Raw["innerExpCode"]) + if !innerCodeDetailIdentityMatches(record, *item) { + return innerCodeApplyOutcome{Status: model.InnerCodeSkipped, + Message: "顺运宝商品规格或档口身份已变化,停止回写,请重新匹配", RemoteCode: currentCode} + } + platform := innerCodeRawText(item.Raw["purchasePlatform"]) + purchaseCode := innerCodeRawText(item.Raw["purchaseCode"]) + if platform != "" || purchaseCode != "" { + return innerCodeApplyOutcome{Status: model.InnerCodeSkipped, + Message: "顺运宝商品已有采购平台或采购单号,停止回写", RemoteCode: currentCode} + } + if currentCode == record.InnerCode { + return innerCodeApplyOutcome{Status: model.InnerCodeAlreadyFilled, + Message: "写入前核验发现远端已是目标入库码,无需重复写入", RemoteCode: currentCode} + } + if currentCode != record.RemoteInnerCode { + return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck, + Message: "远端快递单号在规划后发生变化,已停止回写,请人工核对", RemoteCode: currentCode} + } + if currentCode != "" { + if err := writer.DeleteInnerCode(ctx, record.DetailID); err != nil { + status := model.InnerCodeFailed + message := "删除旧快递单号失败,未发送新值写入请求:" + err.Error() + if errors.Is(err, syb.ErrWriteResultUnknown) { + status = model.InnerCodeNeedsCheck + message = "删除旧快递单号的结果未知,禁止自动继续写入,请重新核对" + } + return innerCodeApplyOutcome{Status: status, Message: message, RemoteCode: currentCode} + } + currentCode = "" + } + if err := writer.UpdateDetailCode(ctx, record.StockID, record.DetailID, record.InnerCode); err != nil { + status := model.InnerCodeFailed + message := "写入档口入库码失败,系统不会自动重试:" + err.Error() + if errors.Is(err, syb.ErrWriteResultUnknown) { + status = model.InnerCodeNeedsCheck + message = "写入结果未知,禁止自动重试,请重新核对" + } + return innerCodeApplyOutcome{Status: status, Message: message, RemoteCode: currentCode} + } + verified, err := readCurrentInnerCodeDetail(ctx, writer, record) + if err != nil { + return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck, + Message: "写入请求已成功响应,但重新读取失败,请核对远端结果", RemoteCode: currentCode} + } + verifiedCode := innerCodeRawText(verified.Raw["innerExpCode"]) + if !innerCodeDetailIdentityMatches(record, *verified) { + return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck, + Message: "写入后商品规格或档口身份发生变化,请人工核对", RemoteCode: verifiedCode} + } + if verifiedCode != record.InnerCode { + return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck, + Message: "写入后远端值与目标入库码不一致,禁止自动重试,请人工核对", RemoteCode: verifiedCode} + } + return innerCodeApplyOutcome{Status: model.InnerCodeUpdated, + Message: "回写完成,远端再次读取结果一致", RemoteCode: verifiedCode} +} + +func readCurrentInnerCodeDetail(ctx context.Context, reader InnerCodeDetailReader, record model.InnerCodeRecord) (*syb.DetailItem, error) { + if record.StockID <= 0 || record.DetailID <= 0 { + return nil, fmt.Errorf("记录缺少有效的货运单或商品明细 ID") + } + stocks, err := reader.DetailListByStock(ctx, []int64{record.StockID}) + if err != nil { + return nil, err + } + if len(stocks) != 1 || stocks[0].ID != record.StockID { + return nil, fmt.Errorf("顺运宝没有唯一返回货运单 id=%d", record.StockID) + } + var found *syb.DetailItem + for index := range stocks[0].Details { + if stocks[0].Details[index].ID != record.DetailID { + continue + } + if found != nil { + return nil, fmt.Errorf("顺运宝重复返回商品明细 id=%d", record.DetailID) + } + item := stocks[0].Details[index] + found = &item + } + if found == nil { + return nil, fmt.Errorf("顺运宝未返回商品明细 id=%d", record.DetailID) + } + return found, nil +} + +// RecheckInnerCode 只重新读取一条 needs_check 记录,不发送任何写请求。 +func RecheckInnerCode(ctx context.Context, db *sql.DB, reader InnerCodeDetailReader, id int64) (model.InnerCodeStatus, string, error) { + record, err := repository.GetInnerCodeForRecheck(db, id) + if err != nil { + return "", "", err + } + if record == nil { + return "", "", fmt.Errorf("档口入库码记录不存在") + } + if record.Status != model.InnerCodeNeedsCheck { + return record.Status, "当前记录不需要核对", nil + } + item, readErr := readCurrentInnerCodeDetail(ctx, reader, *record) + status := model.InnerCodeNeedsCheck + remoteCode := record.RemoteInnerCode + message := "重新读取失败,仍需人工核对:" + errorText(readErr) + if readErr == nil { + remoteCode = innerCodeRawText(item.Raw["innerExpCode"]) + if !innerCodeDetailIdentityMatches(*record, *item) { + message = "重新读取到的商品身份与规划不一致;保持需核对,系统没有写入" + } else if remoteCode == record.InnerCode { + status = model.InnerCodeUpdated + message = "重新读取确认远端已是目标入库码;没有重复写入" + } else { + message = "重新读取后远端仍不是目标入库码;保持需核对,系统没有写入" + } + } + checkedAt := model.NowISO() + message = compactInnerCodeMessage(message) + if err := repository.SaveInnerCodeRecheck(db, id, status, message, remoteCode, checkedAt); err != nil { + return "", "", err + } + return status, message, nil +} + +func innerCodeDetailIdentityMatches(record model.InnerCodeRecord, item syb.DetailItem) bool { + return item.ProductSpec == record.SybSpec && + innerCodeRawText(item.Raw["sku"]) == record.SybSKU && + innerCodeRawText(item.Raw["variationSku"]) == record.SybVariationSKU +} + +func errorText(err error) string { + if err == nil { + return "" + } + return err.Error() +} + +func uniquePositiveInnerCodeIDs(ids []int64) []int64 { + seen := make(map[int64]bool, len(ids)) + result := make([]int64, 0, len(ids)) + for _, id := range ids { + if id > 0 && !seen[id] { + seen[id] = true + result = append(result, id) + } + } + return result +} + +// InterruptApplyingInnerCodes 在 Admin 启动时收敛未确认的远端写结果。 +func InterruptApplyingInnerCodes(db *sql.DB, now time.Time) (int, error) { + return repository.InterruptApplyingInnerCodes(db, now.UTC().Format(model.TimeLayout)) +} + +func compactInnerCodeMessage(message string) string { + message = strings.TrimSpace(message) + if len([]rune(message)) <= 500 { + return message + } + return string([]rune(message)[:500]) +} diff --git a/admin/service/inner_code_apply_test.go b/admin/service/inner_code_apply_test.go new file mode 100644 index 0000000..aeea10e --- /dev/null +++ b/admin/service/inner_code_apply_test.go @@ -0,0 +1,139 @@ +package service + +import ( + "context" + "errors" + "testing" + + "cmautobuy/admin/model" + "cmautobuy/admin/syb" +) + +type fakeInnerCodeWriter struct { + stock syb.StockDetail + readErr error + deleteErr error + updateErr error + readCount int + deleteCount int + updateCount int +} + +func (f *fakeInnerCodeWriter) DetailListByStock(context.Context, []int64) ([]syb.StockDetail, error) { + f.readCount++ + if f.readErr != nil { + return nil, f.readErr + } + return []syb.StockDetail{f.stock}, nil +} + +func (f *fakeInnerCodeWriter) DeleteInnerCode(_ context.Context, detailID int64) error { + f.deleteCount++ + if f.deleteErr != nil { + return f.deleteErr + } + for index := range f.stock.Details { + if f.stock.Details[index].ID == detailID { + f.stock.Details[index].Raw["innerExpCode"] = "" + } + } + return nil +} + +func (f *fakeInnerCodeWriter) UpdateDetailCode(_ context.Context, _, detailID int64, code string) error { + f.updateCount++ + if f.updateErr != nil { + return f.updateErr + } + for index := range f.stock.Details { + if f.stock.Details[index].ID == detailID { + f.stock.Details[index].Raw["innerExpCode"] = code + } + } + return nil +} + +func TestApplyClaimedInnerCode_空旧值直接写并复读确认(t *testing.T) { + record := innerCodeApplyTestRecord("") + writer := innerCodeApplyTestWriter(record, "") + outcome := applyClaimedInnerCode(context.Background(), writer, record) + if outcome.Status != model.InnerCodeUpdated || writer.deleteCount != 0 || writer.updateCount != 1 || writer.readCount != 2 { + t.Fatalf("outcome=%+v counts read=%d delete=%d update=%d", outcome, writer.readCount, writer.deleteCount, writer.updateCount) + } +} + +func TestApplyClaimedInnerCode_有旧值先删后写(t *testing.T) { + record := innerCodeApplyTestRecord("OLD") + writer := innerCodeApplyTestWriter(record, "OLD") + outcome := applyClaimedInnerCode(context.Background(), writer, record) + if outcome.Status != model.InnerCodeUpdated || writer.deleteCount != 1 || writer.updateCount != 1 { + t.Fatalf("outcome=%+v delete=%d update=%d", outcome, writer.deleteCount, writer.updateCount) + } +} + +func TestApplyClaimedInnerCode_规划后远端值变化不写入(t *testing.T) { + record := innerCodeApplyTestRecord("OLD") + writer := innerCodeApplyTestWriter(record, "OTHER") + outcome := applyClaimedInnerCode(context.Background(), writer, record) + if outcome.Status != model.InnerCodeNeedsCheck || writer.deleteCount != 0 || writer.updateCount != 0 { + t.Fatalf("outcome=%+v delete=%d update=%d", outcome, writer.deleteCount, writer.updateCount) + } +} + +func TestApplyClaimedInnerCode_删除明确失败不继续写(t *testing.T) { + record := innerCodeApplyTestRecord("OLD") + writer := innerCodeApplyTestWriter(record, "OLD") + writer.deleteErr = errors.New("已打单数据不能清除") + outcome := applyClaimedInnerCode(context.Background(), writer, record) + if outcome.Status != model.InnerCodeFailed || writer.deleteCount != 1 || writer.updateCount != 0 { + t.Fatalf("outcome=%+v delete=%d update=%d", outcome, writer.deleteCount, writer.updateCount) + } +} + +func TestApplyClaimedInnerCode_写入未知结果不复读也不重试(t *testing.T) { + record := innerCodeApplyTestRecord("") + writer := innerCodeApplyTestWriter(record, "") + writer.updateErr = fmtUnknownInnerCodeWriteError() + outcome := applyClaimedInnerCode(context.Background(), writer, record) + if outcome.Status != model.InnerCodeNeedsCheck || writer.updateCount != 1 || writer.readCount != 1 { + t.Fatalf("outcome=%+v update=%d read=%d", outcome, writer.updateCount, writer.readCount) + } +} + +func TestApplyClaimedInnerCode_采购字段出现后停止(t *testing.T) { + record := innerCodeApplyTestRecord("") + writer := innerCodeApplyTestWriter(record, "") + writer.stock.Details[0].Raw["purchaseCode"] = "CG-1" + outcome := applyClaimedInnerCode(context.Background(), writer, record) + if outcome.Status != model.InnerCodeSkipped || writer.updateCount != 0 { + t.Fatalf("outcome=%+v update=%d", outcome, writer.updateCount) + } +} + +func TestApplyClaimedInnerCode_档口身份变化后停止(t *testing.T) { + record := innerCodeApplyTestRecord("") + record.SybSKU = "A#1" + writer := innerCodeApplyTestWriter(record, "") + writer.stock.Details[0].Raw["sku"] = "B#2" + outcome := applyClaimedInnerCode(context.Background(), writer, record) + if outcome.Status != model.InnerCodeSkipped || writer.updateCount != 0 { + t.Fatalf("outcome=%+v update=%d", outcome, writer.updateCount) + } +} + +func innerCodeApplyTestRecord(oldCode string) model.InnerCodeRecord { + return model.InnerCodeRecord{ID: 1, StockID: 10, DetailID: 20, SybSpec: "黑色,M", + InnerCode: "DK-001", RemoteInnerCode: oldCode, Status: model.InnerCodeApplying} +} + +func innerCodeApplyTestWriter(record model.InnerCodeRecord, currentCode string) *fakeInnerCodeWriter { + item := syb.DetailItem{ID: record.DetailID, ProductSpec: record.SybSpec, Raw: map[string]any{ + "id": record.DetailID, "productSpec": record.SybSpec, "innerExpCode": currentCode, + "purchasePlatform": "", "purchaseCode": "", + }} + return &fakeInnerCodeWriter{stock: syb.StockDetail{ID: record.StockID, Details: []syb.DetailItem{item}}} +} + +func fmtUnknownInnerCodeWriteError() error { + return errors.Join(syb.ErrWriteResultUnknown, errors.New("timeout")) +} diff --git a/admin/syb/client.go b/admin/syb/client.go index 286282e..f6b3495 100644 --- a/admin/syb/client.go +++ b/admin/syb/client.go @@ -24,6 +24,7 @@ import ( "strconv" "strings" "time" + "unicode/utf8" ) // ErrSessionInvalid 表示服务端**明确**判定当前会话未登录或已过期 @@ -37,6 +38,15 @@ import ( // 还可能把本来有效的会话丢掉,见 §3.5 的理由和工单 #46。 var ErrSessionInvalid = errors.New("顺运宝会话未登录或已过期") +// ErrWriteResultUnknown 表示写请求可能已经到达顺运宝,但客户端无法确认结果。 +// 调用方只能重新读取核对,绝不能自动重发同一个写请求。 +var ErrWriteResultUnknown = errors.New("顺运宝写入结果未知") + +type requestOutcomeUnknownError struct{ err error } + +func (e requestOutcomeUnknownError) Error() string { return e.err.Error() } +func (e requestOutcomeUnknownError) Unwrap() error { return e.err } + // Client 是一个顺运宝 ERP 会话:验证码、登录、货运单查询共用同一个 // http.Client(同一个 Cookie Jar)。 // @@ -177,20 +187,20 @@ func (c *Client) do(ctx context.Context, method, path string, query url.Values, if err != nil { // 网络故障(超时、连不上、DNS 失败……)——`[必须]` 不能当成"未登录", // 见 ErrSessionInvalid 的注释和 08 §3.5。 - return nil, fmt.Errorf("请求顺运宝接口 %s 失败(网络问题,不代表未登录): %w", path, err) + return nil, requestOutcomeUnknownError{fmt.Errorf("请求顺运宝接口 %s 失败(网络问题,不代表未登录): %w", path, err)} } defer resp.Body.Close() raw, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("读取顺运宝接口 %s 响应失败: %w", path, err) + return nil, requestOutcomeUnknownError{fmt.Errorf("读取顺运宝接口 %s 响应失败: %w", path, err)} } if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { return nil, fmt.Errorf("顺运宝接口 %s 返回 %d: %w", path, resp.StatusCode, ErrSessionInvalid) } if resp.StatusCode >= http.StatusInternalServerError { - return nil, fmt.Errorf("顺运宝接口 %s 返回 %d(服务端故障,不代表未登录)", path, resp.StatusCode) + return nil, requestOutcomeUnknownError{fmt.Errorf("顺运宝接口 %s 返回 %d(服务端故障,不代表未登录)", path, resp.StatusCode)} } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("顺运宝接口 %s 返回意外状态码 %d", path, resp.StatusCode) @@ -198,7 +208,7 @@ func (c *Client) do(ctx context.Context, method, path string, query url.Values, var env envelope if err := json.Unmarshal(raw, &env); err != nil { - return nil, fmt.Errorf("顺运宝接口 %s 响应不是合法 JSON(格式错误,不代表未登录): %w", path, err) + return nil, requestOutcomeUnknownError{fmt.Errorf("顺运宝接口 %s 响应不是合法 JSON(格式错误,不代表未登录): %w", path, err)} } if !env.Status { if isSessionInvalidMessage(env.Msg, env.Code) { @@ -631,6 +641,53 @@ func (c *Client) DetailListByStock(ctx context.Context, ids []int64) ([]StockDet return out, nil } +// DeleteInnerCode 清除一个货运明细的 innerExpCode。 +// 请求只发送一次;结果未知时返回 ErrWriteResultUnknown,调用方不得重试。 +func (c *Client) DeleteInnerCode(ctx context.Context, detailID int64) error { + if detailID <= 0 { + return fmt.Errorf("detailId 必须是正整数") + } + _, err := c.do(ctx, http.MethodGet, "/am/stock/detail/deleteInnerCode", + url.Values{"detailId": {strconv.FormatInt(detailID, 10)}}, nil) + return classifyInnerCodeWriteError(err) +} + +// UpdateDetailCode 把档口入库码写入货运明细的 innerExpCode。 +// 请求只发送一次;结果未知时返回 ErrWriteResultUnknown,调用方不得重试。 +func (c *Client) UpdateDetailCode(ctx context.Context, stockID, detailID int64, code string) error { + code = strings.TrimSpace(code) + if stockID <= 0 || detailID <= 0 { + return fmt.Errorf("货运单 id 和 detailId 必须是正整数") + } + if code == "" { + return fmt.Errorf("code 不能为空") + } + if utf8.RuneCountInString(code) > 128 { + return fmt.Errorf("code 不能超过 128 个字符") + } + for _, character := range code { + if character < 32 || character == 127 { + return fmt.Errorf("code 不能包含控制字符") + } + } + _, err := c.do(ctx, http.MethodGet, "/am/stock/detail/updateDetailCode", url.Values{ + "t": {"0"}, "id": {strconv.FormatInt(stockID, 10)}, + "detailId": {strconv.FormatInt(detailID, 10)}, "code": {code}, + }, nil) + return classifyInnerCodeWriteError(err) +} + +func classifyInnerCodeWriteError(err error) error { + if err == nil { + return nil + } + var unknown requestOutcomeUnknownError + if errors.As(err, &unknown) { + return fmt.Errorf("%w:%v", ErrWriteResultUnknown, err) + } + return err +} + // ---------- 类型转换:JSON 数字/字符串统一转 ---------- // toInt64 兼容 JSON 数字被 encoding/json 解成 float64、以及顺运宝个别 diff --git a/admin/syb/client_test.go b/admin/syb/client_test.go index 63cf554..fc06b9e 100644 --- a/admin/syb/client_test.go +++ b/admin/syb/client_test.go @@ -431,6 +431,85 @@ func TestClient_DetailListByStock_超过100个id报错(t *testing.T) { } } +func TestClient_InnerCodeWrite_参数和路径正确且只发送一次(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/am/stock/detail/deleteInnerCode": + if r.URL.Query().Get("detailId") != "22" { + t.Errorf("delete query=%v", r.URL.Query()) + } + case "/am/stock/detail/updateDetailCode": + query := r.URL.Query() + if query.Get("t") != "0" || query.Get("id") != "11" || query.Get("detailId") != "22" || query.Get("code") != "DK-001" { + t.Errorf("update query=%v", query) + } + default: + t.Errorf("意外路径 %s", r.URL.Path) + } + io.WriteString(w, `{"status":true,"msg":"成功","data":null}`) + })) + defer server.Close() + client, err := New(server.URL) + if err != nil { + t.Fatal(err) + } + if err := client.DeleteInnerCode(context.Background(), 22); err != nil { + t.Fatal(err) + } + if err := client.UpdateDetailCode(context.Background(), 11, 22, " DK-001 "); err != nil { + t.Fatal(err) + } + if requests.Load() != 2 { + t.Fatalf("每个写动作只能发一次请求,实际总请求 %d", requests.Load()) + } +} + +func TestClient_InnerCodeWrite_未知结果与明确业务失败分开(t *testing.T) { + for _, tc := range []struct { + name string + status int + body string + wantUnknown bool + }{ + {"服务端故障", http.StatusInternalServerError, `oops`, true}, + {"成功响应损坏", http.StatusOK, `not-json`, true}, + {"明确业务失败", http.StatusOK, `{"status":false,"msg":"已打单数据不能清除","code":1}`, false}, + } { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tc.status) + io.WriteString(w, tc.body) + })) + defer server.Close() + client, _ := New(server.URL) + err := client.DeleteInnerCode(context.Background(), 22) + if err == nil || errors.Is(err, ErrWriteResultUnknown) != tc.wantUnknown { + t.Fatalf("err=%v unknown=%v want=%v", err, errors.Is(err, ErrWriteResultUnknown), tc.wantUnknown) + } + }) + } +} + +func TestClient_UpdateDetailCode_本地校验失败不发送请求(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + })) + defer server.Close() + client, _ := New(server.URL) + for _, code := range []string{"", "bad\ncode", strings.Repeat("长", 129)} { + if err := client.UpdateDetailCode(context.Background(), 1, 2, code); err == nil { + t.Errorf("code=%q 应被拒绝", code) + } + } + if requests.Load() != 0 { + t.Fatalf("本地校验失败不应发请求,实际 %d", requests.Load()) + } +} + // ── 业务失败但不是登录问题 ────────────────────────────── func TestClient_业务失败但不是登录问题时返回普通错误(t *testing.T) { diff --git a/docs/admin/08-顺运宝接口.md b/docs/admin/08-顺运宝接口.md index 6bcff88..03bfd56 100644 --- a/docs/admin/08-顺运宝接口.md +++ b/docs/admin/08-顺运宝接口.md @@ -410,6 +410,23 @@ SKU(平均每商品 1.17 个),**查无此 SKU 是常态**,不是异常 `[建议]` 登录后**只调 `/am/user/get` 验证会话**,其余几个是网页自己的初始化请求, Go 侧不用跟着调。 +### 7.1 档口入库码写入(工单 #234) + +档口入库码最终写入货运明细的 `innerExpCode`(页面名称“快递单号”)。两个写接口来自 +现有 Python 流程和线上响应样本: + +```text +GET /am/stock/detail/deleteInnerCode?detailId={detailID} +GET /am/stock/detail/updateDetailCode?t=0&id={stockID}&detailId={detailID}&code={innerCode} +``` + +- 旧值为空时不调用删除;旧值非空时,只有删除明确成功才发送写入。 +- 删除和写入请求都只允许发送一次,不使用自动重试。超时、5xx、响应读取失败或成功响应 + 无法解析时,结果可能已经在远端生效,必须标为“需核对”。 +- 每条写入前重新调用 `listByStock`,核对 stock/detail、规格、采购平台、采购单号和规划时 + 的旧值;写入后再次读取,只有 `innerExpCode` 与目标一致才标为已回写。 +- “重新核对”只调用 `listByStock`,绝不能再次调用上述两个写接口。 + --- ## 8. 实现时的固定约束