feat: 安全回写档口入库码并支持核验恢复 (#234)
This commit is contained in:
@@ -155,6 +155,70 @@ func (h *Handler) InnerCodeMatch(c *gin.Context) {
|
|||||||
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, message)
|
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) {
|
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}}
|
values := url.Values{"date": {businessDate}, "page": {strconv.Itoa(pageNumber)}, "message": {message}}
|
||||||
if strings.TrimSpace(status) != "" {
|
if strings.TrimSpace(status) != "" {
|
||||||
|
|||||||
@@ -120,6 +120,8 @@ func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration, aiSecret
|
|||||||
innerCodes := pages.Group("/inner-codes")
|
innerCodes := pages.Group("/inner-codes")
|
||||||
innerCodes.POST("/import", h.InnerCodeImport)
|
innerCodes.POST("/import", h.InnerCodeImport)
|
||||||
innerCodes.POST("/match", h.InnerCodeMatch)
|
innerCodes.POST("/match", h.InnerCodeMatch)
|
||||||
|
innerCodes.POST("/apply", h.InnerCodeApply)
|
||||||
|
innerCodes.POST("/recheck", h.InnerCodeRecheck)
|
||||||
|
|
||||||
// 7. 用户管理:先经过网页登录,再叠加管理员角色校验。
|
// 7. 用户管理:先经过网页登录,再叠加管理员角色校验。
|
||||||
users := pages.Group("/users", AdminRequired())
|
users := pages.Group("/users", AdminRequired())
|
||||||
|
|||||||
@@ -78,6 +78,13 @@ func main() {
|
|||||||
if aiInterrupted > 0 {
|
if aiInterrupted > 0 {
|
||||||
log.Printf("已把 %d 个上次进程遗留的 AI 匹配批次标记为中断", aiInterrupted)
|
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("数据库已就绪")
|
log.Printf("数据库已就绪")
|
||||||
|
|
||||||
// 3. Web 引擎
|
// 3. Web 引擎
|
||||||
|
|||||||
@@ -274,6 +274,102 @@ func scanInnerCodeRecord(scanner innerCodeRowScanner) (model.InnerCodeRecord, er
|
|||||||
return row, nil
|
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 {
|
func nullablePositiveInt(value int) any {
|
||||||
if value <= 0 {
|
if value <= 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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')`)
|
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) {
|
func mustExec(t *testing.T, db *sql.DB, query string, args ...any) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if _, err := db.Exec(query, args...); err != nil {
|
if _, err := db.Exec(query, args...); err != nil {
|
||||||
|
|||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
+61
-4
@@ -24,6 +24,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrSessionInvalid 表示服务端**明确**判定当前会话未登录或已过期
|
// ErrSessionInvalid 表示服务端**明确**判定当前会话未登录或已过期
|
||||||
@@ -37,6 +38,15 @@ import (
|
|||||||
// 还可能把本来有效的会话丢掉,见 §3.5 的理由和工单 #46。
|
// 还可能把本来有效的会话丢掉,见 §3.5 的理由和工单 #46。
|
||||||
var ErrSessionInvalid = errors.New("顺运宝会话未登录或已过期")
|
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 会话:验证码、登录、货运单查询共用同一个
|
// Client 是一个顺运宝 ERP 会话:验证码、登录、货运单查询共用同一个
|
||||||
// http.Client(同一个 Cookie Jar)。
|
// http.Client(同一个 Cookie Jar)。
|
||||||
//
|
//
|
||||||
@@ -177,20 +187,20 @@ func (c *Client) do(ctx context.Context, method, path string, query url.Values,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
// 网络故障(超时、连不上、DNS 失败……)——`[必须]` 不能当成"未登录",
|
// 网络故障(超时、连不上、DNS 失败……)——`[必须]` 不能当成"未登录",
|
||||||
// 见 ErrSessionInvalid 的注释和 08 §3.5。
|
// 见 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()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
raw, err := io.ReadAll(resp.Body)
|
raw, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
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 {
|
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||||
return nil, fmt.Errorf("顺运宝接口 %s 返回 %d: %w", path, resp.StatusCode, ErrSessionInvalid)
|
return nil, fmt.Errorf("顺运宝接口 %s 返回 %d: %w", path, resp.StatusCode, ErrSessionInvalid)
|
||||||
}
|
}
|
||||||
if resp.StatusCode >= http.StatusInternalServerError {
|
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 {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("顺运宝接口 %s 返回意外状态码 %d", path, resp.StatusCode)
|
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
|
var env envelope
|
||||||
if err := json.Unmarshal(raw, &env); err != nil {
|
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 !env.Status {
|
||||||
if isSessionInvalidMessage(env.Msg, env.Code) {
|
if isSessionInvalidMessage(env.Msg, env.Code) {
|
||||||
@@ -631,6 +641,53 @@ func (c *Client) DetailListByStock(ctx context.Context, ids []int64) ([]StockDet
|
|||||||
return out, nil
|
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 数字/字符串统一转 ----------
|
// ---------- 类型转换:JSON 数字/字符串统一转 ----------
|
||||||
|
|
||||||
// toInt64 兼容 JSON 数字被 encoding/json 解成 float64、以及顺运宝个别
|
// toInt64 兼容 JSON 数字被 encoding/json 解成 float64、以及顺运宝个别
|
||||||
|
|||||||
@@ -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) {
|
func TestClient_业务失败但不是登录问题时返回普通错误(t *testing.T) {
|
||||||
|
|||||||
@@ -410,6 +410,23 @@ SKU(平均每商品 1.17 个),**查无此 SKU 是常态**,不是异常
|
|||||||
`[建议]` 登录后**只调 `/am/user/get` 验证会话**,其余几个是网页自己的初始化请求,
|
`[建议]` 登录后**只调 `/am/user/get` 验证会话**,其余几个是网页自己的初始化请求,
|
||||||
Go 侧不用跟着调。
|
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. 实现时的固定约束
|
## 8. 实现时的固定约束
|
||||||
|
|||||||
Reference in New Issue
Block a user