fix: 多件档口入库码逐件回写 (#250)

This commit is contained in:
chengma
2026-08-18 15:18:35 +08:00
parent d2acbd821c
commit b8f49fe4bb
14 changed files with 547 additions and 111 deletions
+1
View File
@@ -40,6 +40,7 @@ type InnerCodeRecord struct {
PurchasePlatform string PurchasePlatform string
PurchaseCode string PurchaseCode string
RemoteInnerCode string RemoteInnerCode string
RemoteItemsJSON string
Status InnerCodeStatus Status InnerCodeStatus
ResultMessage string ResultMessage string
CreatedByUserID string CreatedByUserID string
+23 -7
View File
@@ -156,7 +156,7 @@ func UpsertInnerCodeImportRow(tx *sql.Tx, row model.InnerCodeImportRow, now stri
SET source_row=?,print_sequence=?,shop_name=?,spec_raw=?,inner_code=?,source_duplicate_count=?, SET source_row=?,print_sequence=?,shop_name=?,spec_raw=?,inner_code=?,source_duplicate_count=?,
status='pending',stock_id=NULL,detail_id=NULL,syb_spec=NULL,syb_sku=NULL, status='pending',stock_id=NULL,detail_id=NULL,syb_spec=NULL,syb_sku=NULL,
syb_variation_sku=NULL,purchase_platform=NULL,purchase_code=NULL, syb_variation_sku=NULL,purchase_platform=NULL,purchase_code=NULL,
remote_inner_code=NULL,result_message=NULL,planned_at=NULL, remote_inner_code=NULL,remote_items_json=NULL,result_message=NULL,planned_at=NULL,
apply_batch_id=NULL,apply_queued_at=NULL,apply_started_at=NULL, apply_batch_id=NULL,apply_queued_at=NULL,apply_started_at=NULL,
deleted_at=NULL,deleted_by_user_id=NULL,updated_at=? deleted_at=NULL,deleted_by_user_id=NULL,updated_at=?
WHERE id=?`, row.SourceRow, nullablePositiveInt(row.PrintSequence), nullableString(row.ShopName), WHERE id=?`, row.SourceRow, nullablePositiveInt(row.PrintSequence), nullableString(row.ShopName),
@@ -182,6 +182,7 @@ func UpsertInnerCodeImportRow(tx *sql.Tx, row model.InnerCodeImportRow, now stri
purchase_platform=CASE WHEN status IN ('queued','updated','already_filled','applying','needs_check') THEN purchase_platform ELSE NULL END, purchase_platform=CASE WHEN status IN ('queued','updated','already_filled','applying','needs_check') THEN purchase_platform ELSE NULL END,
purchase_code=CASE WHEN status IN ('queued','updated','already_filled','applying','needs_check') THEN purchase_code ELSE NULL END, purchase_code=CASE WHEN status IN ('queued','updated','already_filled','applying','needs_check') THEN purchase_code ELSE NULL END,
remote_inner_code=CASE WHEN status IN ('queued','updated','already_filled','applying','needs_check') THEN remote_inner_code ELSE NULL END, remote_inner_code=CASE WHEN status IN ('queued','updated','already_filled','applying','needs_check') THEN remote_inner_code ELSE NULL END,
remote_items_json=CASE WHEN status IN ('queued','updated','already_filled','applying','needs_check') THEN remote_items_json ELSE NULL END,
result_message=CASE WHEN status IN ('queued','updated','already_filled','applying','needs_check') THEN result_message ELSE NULL END, result_message=CASE WHEN status IN ('queued','updated','already_filled','applying','needs_check') THEN result_message ELSE NULL END,
planned_at=CASE WHEN status IN ('queued','updated','already_filled','applying','needs_check') THEN planned_at ELSE NULL END, planned_at=CASE WHEN status IN ('queued','updated','already_filled','applying','needs_check') THEN planned_at ELSE NULL END,
apply_batch_id=CASE WHEN status IN ('queued','updated','already_filled','applying','needs_check') THEN apply_batch_id ELSE NULL END, apply_batch_id=CASE WHEN status IN ('queued','updated','already_filled','applying','needs_check') THEN apply_batch_id ELSE NULL END,
@@ -303,7 +304,7 @@ func SaveInnerCodePlans(db *sql.DB, plans []model.InnerCodeRecord, plannedAt str
UPDATE syb_inner_code_records UPDATE syb_inner_code_records
SET stock_id=?,detail_id=?,syb_spec=?,syb_sku=?,syb_variation_sku=?, SET stock_id=?,detail_id=?,syb_spec=?,syb_sku=?,syb_variation_sku=?,
purchase_platform=?,purchase_code=?,remote_inner_code=?,status=?, purchase_platform=?,purchase_code=?,remote_inner_code=?,status=?,
result_message=?,planned_at=?,apply_batch_id=NULL,apply_queued_at=NULL, remote_items_json=NULL,result_message=?,planned_at=?,apply_batch_id=NULL,apply_queued_at=NULL,
apply_started_at=NULL,updated_at=? apply_started_at=NULL,updated_at=?
WHERE id=? AND deleted_at IS NULL AND status IN ('pending','ready','skipped','failed')`, WHERE id=? AND deleted_at IS NULL AND status IN ('pending','ready','skipped','failed')`,
nullablePositiveInt64(plan.StockID), nullablePositiveInt64(plan.DetailID), nullablePositiveInt64(plan.StockID), nullablePositiveInt64(plan.DetailID),
@@ -405,7 +406,7 @@ const innerCodeListColumns = `id,business_date,source_row,COALESCE(print_sequenc
COALESCE(apply_batch_id,''),COALESCE(apply_queued_at,''), COALESCE(apply_batch_id,''),COALESCE(apply_queued_at,''),
COALESCE(stock_id,0),COALESCE(detail_id,0),COALESCE(syb_spec,''),COALESCE(syb_sku,''), COALESCE(stock_id,0),COALESCE(detail_id,0),COALESCE(syb_spec,''),COALESCE(syb_sku,''),
COALESCE(syb_variation_sku,''),COALESCE(purchase_platform,''),COALESCE(purchase_code,''), COALESCE(syb_variation_sku,''),COALESCE(purchase_platform,''),COALESCE(purchase_code,''),
COALESCE(remote_inner_code,''),status,COALESCE(result_message,''),created_by_user_id, COALESCE(remote_inner_code,''),COALESCE(remote_items_json,''),status,COALESCE(result_message,''),created_by_user_id,
COALESCE(applied_by_user_id,''),COALESCE(planned_at,''),COALESCE(apply_started_at,''), COALESCE(applied_by_user_id,''),COALESCE(planned_at,''),COALESCE(apply_started_at,''),
COALESCE(applied_at,''),COALESCE(deleted_at,''),COALESCE(deleted_by_user_id,''),created_at,updated_at` COALESCE(applied_at,''),COALESCE(deleted_at,''),COALESCE(deleted_by_user_id,''),created_at,updated_at`
@@ -485,7 +486,7 @@ func scanInnerCodeRecord(scanner innerCodeRowScanner) (model.InnerCodeRecord, er
&row.InnerCode, &row.SourceDuplicateCount, &row.ApplyBatchID, &row.ApplyQueuedAt, &row.InnerCode, &row.SourceDuplicateCount, &row.ApplyBatchID, &row.ApplyQueuedAt,
&row.StockID, &row.DetailID, &row.StockID, &row.DetailID,
&row.SybSpec, &row.SybSKU, &row.SybVariationSKU, &row.PurchasePlatform, &row.SybSpec, &row.SybSKU, &row.SybVariationSKU, &row.PurchasePlatform,
&row.PurchaseCode, &row.RemoteInnerCode, &row.Status, &row.ResultMessage, &row.PurchaseCode, &row.RemoteInnerCode, &row.RemoteItemsJSON, &row.Status, &row.ResultMessage,
&row.CreatedByUserID, &row.AppliedByUserID, &row.PlannedAt, &row.ApplyStartedAt, &row.CreatedByUserID, &row.AppliedByUserID, &row.PlannedAt, &row.ApplyStartedAt,
&row.AppliedAt, &row.DeletedAt, &row.DeletedByUserID, &row.CreatedAt, &row.UpdatedAt) &row.AppliedAt, &row.DeletedAt, &row.DeletedByUserID, &row.CreatedAt, &row.UpdatedAt)
if err != nil { if err != nil {
@@ -654,6 +655,21 @@ func FinishInnerCodeApply(q Execer, id int64, status model.InnerCodeStatus, mess
return nil return nil
} }
// SaveInnerCodeRemoteItems 在每个远端动作前后保存逐件检查点。
// 只有 applying 记录可更新,避免后台执行器覆盖已经被人工处理的结果。
func SaveInnerCodeRemoteItems(q Execer, id int64, remoteItemsJSON, message, updatedAt string) error {
result, err := q.Exec(`UPDATE syb_inner_code_records
SET remote_items_json=?,result_message=?,updated_at=?
WHERE id=? AND status='applying'`, nullableString(remoteItemsJSON), nullableString(message), updatedAt, 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 读取一条需核对记录。 // GetInnerCodeForRecheck 读取一条需核对记录。
func GetInnerCodeForRecheck(q Execer, id int64) (*model.InnerCodeRecord, error) { func GetInnerCodeForRecheck(q Execer, id int64) (*model.InnerCodeRecord, error) {
record, err := scanInnerCodeRecord(q.QueryRow(`SELECT `+innerCodeListColumns+ record, err := scanInnerCodeRecord(q.QueryRow(`SELECT `+innerCodeListColumns+
@@ -705,11 +721,11 @@ func SoftDeleteInnerCodeRecords(db *sql.DB, ids []int64, actorUserID, deletedAt
} }
// SaveInnerCodeRecheck 保存只读重新核对的远端结果,不执行状态领取或写入。 // SaveInnerCodeRecheck 保存只读重新核对的远端结果,不执行状态领取或写入。
func SaveInnerCodeRecheck(q Execer, id int64, status model.InnerCodeStatus, message, remoteCode, checkedAt string) error { func SaveInnerCodeRecheck(q Execer, id int64, status model.InnerCodeStatus, message, remoteCode, remoteItemsJSON, checkedAt string) error {
result, err := q.Exec(`UPDATE syb_inner_code_records result, err := q.Exec(`UPDATE syb_inner_code_records
SET status=?,result_message=?,remote_inner_code=?, SET status=?,result_message=?,remote_inner_code=?,remote_items_json=?,
applied_at=CASE WHEN ?='updated' THEN ? ELSE applied_at END,updated_at=? 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) WHERE id=? AND status='needs_check'`, status, message, nullableString(remoteCode), nullableString(remoteItemsJSON), status, checkedAt, checkedAt, id)
if err != nil { if err != nil {
return fmt.Errorf("保存档口入库码核对结果失败: %w", err) return fmt.Errorf("保存档口入库码核对结果失败: %w", err)
} }
+43 -2
View File
@@ -20,7 +20,7 @@ import (
"cmautobuy/admin/spec" "cmautobuy/admin/spec"
) )
const mysqlSchemaVersion = 26 const mysqlSchemaVersion = 27
// OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。 // OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。
func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) { func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) {
@@ -737,9 +737,34 @@ func MigrateMySQL(db *sql.DB) error {
return fmt.Errorf("记录 MySQL schema v26 失败: %w", err) return fmt.Errorf("记录 MySQL schema v26 失败: %w", err)
} }
} }
if current < 27 {
if err := migrateMySQLV27(db); err != nil {
return fmt.Errorf("执行 MySQL schema v27 失败: %w", err)
}
if err := checkMySQLV27Shape(db); err != nil {
return fmt.Errorf("MySQL schema v27 自检失败,未记录版本: %w", err)
}
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 27, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
return fmt.Errorf("记录 MySQL schema v27 失败: %w", err)
}
}
return CheckMySQLSchema(db) return CheckMySQLSchema(db)
} }
// migrateMySQLV27 保存多件档口入库码逐件远端检查点,不拆分新的业务表。
func migrateMySQLV27(db *sql.DB) error {
exists, err := mysqlColumnExists(db, "syb_inner_code_records", "remote_items_json")
if err != nil {
return err
}
if exists {
return nil
}
_, err = db.Exec(`ALTER TABLE syb_inner_code_records
ADD COLUMN remote_items_json JSON NULL AFTER remote_inner_code`)
return err
}
// migrateMySQLV26 建立采购运行时规格解析审计。一个记录同时保存候选观察和最终决策, // migrateMySQLV26 建立采购运行时规格解析审计。一个记录同时保存候选观察和最终决策,
// 不覆盖 PDD 商品主数据,也不通过外键阻止任务的既有硬删除流程。 // 不覆盖 PDD 商品主数据,也不通过外键阻止任务的既有硬删除流程。
func migrateMySQLV26(db *sql.DB) error { func migrateMySQLV26(db *sql.DB) error {
@@ -2320,7 +2345,23 @@ func CheckMySQLSchema(db *sql.DB) error {
if err := checkMySQLV25Shape(db); err != nil { if err := checkMySQLV25Shape(db); err != nil {
return err return err
} }
return checkMySQLV26Shape(db) if err := checkMySQLV26Shape(db); err != nil {
return err
}
return checkMySQLV27Shape(db)
}
func checkMySQLV27Shape(db *sql.DB) error {
var dataType, nullable string
if err := db.QueryRow(`SELECT data_type,is_nullable FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='syb_inner_code_records' AND column_name='remote_items_json'`).
Scan(&dataType, &nullable); err != nil {
return fmt.Errorf("检查档口入库码逐件检查点字段失败: %w", err)
}
if dataType != "json" || nullable != "YES" {
return fmt.Errorf("档口入库码逐件检查点字段 remote_items_json 结构不正确")
}
return nil
} }
func checkMySQLV26Shape(db *sql.DB) error { func checkMySQLV26Shape(db *sql.DB) error {
+248 -58
View File
@@ -5,6 +5,7 @@ import (
"crypto/rand" "crypto/rand"
"database/sql" "database/sql"
"encoding/hex" "encoding/hex"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
@@ -19,6 +20,7 @@ import (
type InnerCodeWriter interface { type InnerCodeWriter interface {
InnerCodeDetailReader InnerCodeDetailReader
DeleteInnerCode(context.Context, int64) error DeleteInnerCode(context.Context, int64) error
CreateInnerCodeDetail(context.Context, int64, string) (int64, error)
UpdateDetailCode(context.Context, int64, int64, string) error UpdateDetailCode(context.Context, int64, int64, string) error
} }
@@ -46,6 +48,16 @@ type innerCodeApplyOutcome struct {
RemoteCode string RemoteCode string
} }
type innerCodeRemoteItem struct {
Code string `json:"code"`
DetailID int64 `json:"detail_id,omitempty"`
Source string `json:"source,omitempty"`
Title string `json:"title,omitempty"`
Status string `json:"status"`
}
type innerCodeCheckpoint func([]innerCodeRemoteItem, string) error
// QueueInnerCodeApplyBatch 把任意数量的已选记录原子排队,远端请求由后台执行器处理。 // QueueInnerCodeApplyBatch 把任意数量的已选记录原子排队,远端请求由后台执行器处理。
func QueueInnerCodeApplyBatch(db *sql.DB, ids []int64, actorUserID string) (*InnerCodeApplyBatch, error) { func QueueInnerCodeApplyBatch(db *sql.DB, ids []int64, actorUserID string) (*InnerCodeApplyBatch, error) {
ids = uniquePositiveInnerCodeIDs(ids) ids = uniquePositiveInnerCodeIDs(ids)
@@ -95,7 +107,14 @@ func RunInnerCodeApplyBatch(ctx context.Context, db *sql.DB, writer InnerCodeWri
result.Skipped++ result.Skipped++
continue continue
} }
outcome := applyClaimedInnerCode(ctx, writer, *record) checkpoint := func(items []innerCodeRemoteItem, message string) error {
raw, err := json.Marshal(items)
if err != nil {
return fmt.Errorf("序列化档口入库码逐件检查点失败: %w", err)
}
return repository.SaveInnerCodeRemoteItems(db, id, string(raw), compactInnerCodeMessage(message), model.NowISO())
}
outcome := applyClaimedInnerCode(ctx, writer, *record, checkpoint)
finishedAt := model.NowISO() finishedAt := model.NowISO()
if err := repository.FinishInnerCodeApply(db, id, outcome.Status, compactInnerCodeMessage(outcome.Message), outcome.RemoteCode, finishedAt); err != nil { if err := repository.FinishInnerCodeApply(db, id, outcome.Status, compactInnerCodeMessage(outcome.Message), outcome.RemoteCode, finishedAt); err != nil {
return result, err return result, err
@@ -138,80 +157,121 @@ func newInnerCodeApplyBatchID() (string, error) {
return "ICB-" + time.Now().UTC().Format("20060102T150405") + "-" + hex.EncodeToString(random), nil return "ICB-" + time.Now().UTC().Format("20060102T150405") + "-" + hex.EncodeToString(random), nil
} }
func applyClaimedInnerCode(ctx context.Context, writer InnerCodeWriter, record model.InnerCodeRecord) innerCodeApplyOutcome { func applyClaimedInnerCode(ctx context.Context, writer InnerCodeWriter, record model.InnerCodeRecord, checkpoint innerCodeCheckpoint) innerCodeApplyOutcome {
item, err := readCurrentInnerCodeDetail(ctx, writer, record) codes, err := splitInnerCodes(record.InnerCode)
if err != nil || len(codes) != record.SourceDuplicateCount {
return innerCodeApplyOutcome{Status: model.InnerCodeFailed,
Message: fmt.Sprintf("导入单件码数量与源行数不一致(入库码 %d 个,源行 %d 行),没有发送远端写请求", len(codes), record.SourceDuplicateCount), RemoteCode: record.RemoteInnerCode}
}
stock, original, err := readCurrentInnerCodeStock(ctx, writer, record)
if err != nil { if err != nil {
return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck, return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck,
Message: "写入前重新读取失败,没有发送删除或写入请求:" + err.Error(), RemoteCode: record.RemoteInnerCode} Message: "写入前重新读取失败,没有发送远端写请求:" + err.Error(), RemoteCode: record.RemoteInnerCode}
} }
currentCode := innerCodeRawText(item.Raw["innerExpCode"]) if original.ProductQty != len(codes) {
if !innerCodeDetailIdentityMatches(record, *item) { return innerCodeApplyOutcome{Status: model.InnerCodeFailed,
Message: fmt.Sprintf("顺运宝商品数量为 %d,单件入库码为 %d 个,数量不一致;没有发送远端写请求", original.ProductQty, len(codes)), RemoteCode: innerCodeRawText(original.Raw["innerExpCode"])}
}
if !innerCodeDetailIdentityMatches(record, *original) {
return innerCodeApplyOutcome{Status: model.InnerCodeSkipped, return innerCodeApplyOutcome{Status: model.InnerCodeSkipped,
Message: "顺运宝商品规格或档口身份已变化,停止回写,请重新匹配", RemoteCode: currentCode} Message: "顺运宝商品规格或档口身份已变化,停止回写,请重新匹配", RemoteCode: innerCodeRawText(original.Raw["innerExpCode"])}
} }
platform := innerCodeRawText(item.Raw["purchasePlatform"]) if innerCodeRawText(original.Raw["purchasePlatform"]) != "" || innerCodeRawText(original.Raw["purchaseCode"]) != "" {
purchaseCode := innerCodeRawText(item.Raw["purchaseCode"])
if platform != "" || purchaseCode != "" {
return innerCodeApplyOutcome{Status: model.InnerCodeSkipped, return innerCodeApplyOutcome{Status: model.InnerCodeSkipped,
Message: "顺运宝商品已有采购平台或采购单号,停止回写", RemoteCode: currentCode} Message: "顺运宝商品已有采购平台或采购单号,停止回写", RemoteCode: innerCodeRawText(original.Raw["innerExpCode"])}
} }
if currentCode == record.InnerCode { items, missing, prepareErr := planInnerCodeRemoteItems(record, stock, codes)
if prepareErr != nil {
return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck, Message: prepareErr.Error(), RemoteCode: record.RemoteInnerCode}
}
if checkpoint == nil {
checkpoint = func([]innerCodeRemoteItem, string) error { return nil }
}
if len(missing) == 0 {
if err := checkpoint(items, fmt.Sprintf("远端已存在全部 %d 个单件入库码", len(codes))); err != nil {
return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck, Message: err.Error(), RemoteCode: strings.Join(codes, ",")}
}
return innerCodeApplyOutcome{Status: model.InnerCodeAlreadyFilled, return innerCodeApplyOutcome{Status: model.InnerCodeAlreadyFilled,
Message: "写入前核验发现远端已是目标入库码,无需重复写入", RemoteCode: currentCode} Message: fmt.Sprintf("远端已存在全部 %d 个单件入库码,无需重复写入", len(codes)), RemoteCode: strings.Join(codes, ",")}
} }
if currentCode != record.RemoteInnerCode {
return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck, currentCode := innerCodeRawText(original.Raw["innerExpCode"])
Message: "远端快递单号在规划后发生变化,已停止回写,请人工核对", RemoteCode: currentCode} if currentCode != "" && !containsInnerCode(codes, currentCode) {
} if currentCode != record.RemoteInnerCode {
if currentCode != "" { return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck,
Message: "原商品快递单号在规划后发生变化,已停止回写,请人工核对", RemoteCode: currentCode}
}
if err := checkpoint(items, "准备清除原商品上的旧快递单号"); err != nil {
return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck, Message: err.Error(), RemoteCode: currentCode}
}
if err := writer.DeleteInnerCode(ctx, record.DetailID); err != nil { if err := writer.DeleteInnerCode(ctx, record.DetailID); err != nil {
status := model.InnerCodeFailed return withInnerCodeProgress(unknownOrFailedInnerCodeOutcome(err, "删除旧快递单号", currentCode), items)
message := "删除旧快递单号失败,未发送新值写入请求:" + err.Error()
if errors.Is(err, syb.ErrWriteResultUnknown) {
status = model.InnerCodeNeedsCheck
message = "删除旧快递单号的结果未知,禁止自动继续写入,请重新核对"
}
return innerCodeApplyOutcome{Status: status, Message: message, RemoteCode: currentCode}
} }
currentCode = "" currentCode = ""
} }
if err := writer.UpdateDetailCode(ctx, record.StockID, record.DetailID, record.InnerCode); err != nil {
status := model.InnerCodeFailed for _, codeIndex := range missing {
message := "写入档口入库码失败,系统不会自动重试:" + err.Error() item := &items[codeIndex]
if errors.Is(err, syb.ErrWriteResultUnknown) { if item.DetailID == 0 && currentCode == "" {
status = model.InnerCodeNeedsCheck item.DetailID = record.DetailID
message = "写入结果未知,禁止自动重试,请重新核对" item.Source = "original"
currentCode = "reserved"
}
if item.DetailID == 0 {
item.Title = innerCodePlaceholderTitle(record.ID, codeIndex+1)
item.Status = "creating"
if err := checkpoint(items, fmt.Sprintf("准备为第 %d 件创建零价明细", codeIndex+1)); err != nil {
return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck, Message: err.Error(), RemoteCode: record.RemoteInnerCode}
}
detailID, err := writer.CreateInnerCodeDetail(ctx, record.StockID, item.Title)
if err != nil {
return withInnerCodeProgress(unknownOrFailedInnerCodeOutcome(err, fmt.Sprintf("创建第 %d 件明细", codeIndex+1), record.RemoteInnerCode), items)
}
item.DetailID = detailID
item.Source = "created"
item.Status = "created"
if err := checkpoint(items, fmt.Sprintf("第 %d 件明细已创建,准备写入入库码", codeIndex+1)); err != nil {
return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck, Message: "新增明细后保存检查点失败,禁止继续写入:" + err.Error(), RemoteCode: record.RemoteInnerCode}
}
}
item.Status = "writing"
if err := checkpoint(items, fmt.Sprintf("准备写入第 %d 个单件入库码", codeIndex+1)); err != nil {
return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck, Message: err.Error(), RemoteCode: record.RemoteInnerCode}
}
if err := writer.UpdateDetailCode(ctx, record.StockID, item.DetailID, item.Code); err != nil {
return withInnerCodeProgress(unknownOrFailedInnerCodeOutcome(err, fmt.Sprintf("写入第 %d 个单件入库码", codeIndex+1), record.RemoteInnerCode), items)
}
verifiedStock, _, err := readCurrentInnerCodeStock(ctx, writer, record)
if err != nil {
return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck, Message: "写入成功响应后重新读取失败,禁止自动重试", RemoteCode: record.RemoteInnerCode}
}
if countInnerCodeInStock(verifiedStock, item.Code) != 1 || innerCodeDetailCode(verifiedStock, item.DetailID) != item.Code {
return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck, Message: "写入后单件入库码未唯一出现在预期明细,禁止自动重试", RemoteCode: record.RemoteInnerCode}
}
item.Status = "confirmed"
if err := checkpoint(items, fmt.Sprintf("第 %d 个单件入库码已回读确认", codeIndex+1)); err != nil {
return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck, Message: "写入确认后保存检查点失败,请人工核对:" + err.Error(), RemoteCode: record.RemoteInnerCode}
} }
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, return innerCodeApplyOutcome{Status: model.InnerCodeUpdated,
Message: "回写完成,远端再次读取结果一致", RemoteCode: verifiedCode} Message: fmt.Sprintf("回写完成:%d 个单件入库码均已逐件回读确认", len(codes)), RemoteCode: strings.Join(codes, ",")}
} }
func readCurrentInnerCodeDetail(ctx context.Context, reader InnerCodeDetailReader, record model.InnerCodeRecord) (*syb.DetailItem, error) { func readCurrentInnerCodeDetail(ctx context.Context, reader InnerCodeDetailReader, record model.InnerCodeRecord) (*syb.DetailItem, error) {
_, found, err := readCurrentInnerCodeStock(ctx, reader, record)
return found, err
}
func readCurrentInnerCodeStock(ctx context.Context, reader InnerCodeDetailReader, record model.InnerCodeRecord) (syb.StockDetail, *syb.DetailItem, error) {
if record.StockID <= 0 || record.DetailID <= 0 { if record.StockID <= 0 || record.DetailID <= 0 {
return nil, fmt.Errorf("记录缺少有效的货运单或商品明细 ID") return syb.StockDetail{}, nil, fmt.Errorf("记录缺少有效的货运单或商品明细 ID")
} }
stocks, err := reader.DetailListByStock(ctx, []int64{record.StockID}) stocks, err := reader.DetailListByStock(ctx, []int64{record.StockID})
if err != nil { if err != nil {
return nil, err return syb.StockDetail{}, nil, err
} }
if len(stocks) != 1 || stocks[0].ID != record.StockID { if len(stocks) != 1 || stocks[0].ID != record.StockID {
return nil, fmt.Errorf("顺运宝没有唯一返回货运单 id=%d", record.StockID) return syb.StockDetail{}, nil, fmt.Errorf("顺运宝没有唯一返回货运单 id=%d", record.StockID)
} }
var found *syb.DetailItem var found *syb.DetailItem
for index := range stocks[0].Details { for index := range stocks[0].Details {
@@ -219,15 +279,134 @@ func readCurrentInnerCodeDetail(ctx context.Context, reader InnerCodeDetailReade
continue continue
} }
if found != nil { if found != nil {
return nil, fmt.Errorf("顺运宝重复返回商品明细 id=%d", record.DetailID) return syb.StockDetail{}, nil, fmt.Errorf("顺运宝重复返回商品明细 id=%d", record.DetailID)
} }
item := stocks[0].Details[index] item := stocks[0].Details[index]
found = &item found = &item
} }
if found == nil { if found == nil {
return nil, fmt.Errorf("顺运宝未返回商品明细 id=%d", record.DetailID) return syb.StockDetail{}, nil, fmt.Errorf("顺运宝未返回商品明细 id=%d", record.DetailID)
} }
return found, nil return stocks[0], found, nil
}
func splitInnerCodes(raw string) ([]string, error) {
parts := strings.Split(raw, ",")
codes := make([]string, 0, len(parts))
seen := make(map[string]bool, len(parts))
for _, part := range parts {
code := strings.TrimSpace(part)
if code == "" {
return nil, fmt.Errorf("单件入库码不能为空")
}
if seen[code] {
return nil, fmt.Errorf("单件入库码 %q 重复", code)
}
seen[code] = true
codes = append(codes, code)
}
return codes, nil
}
func planInnerCodeRemoteItems(record model.InnerCodeRecord, stock syb.StockDetail, codes []string) ([]innerCodeRemoteItem, []int, error) {
items := make([]innerCodeRemoteItem, len(codes))
indexByCode := make(map[string]int, len(codes))
for index, code := range codes {
items[index] = innerCodeRemoteItem{Code: code, Status: "planned"}
indexByCode[code] = index
}
for _, detail := range stock.Details {
code := innerCodeRawText(detail.Raw["innerExpCode"])
if index, ok := indexByCode[code]; ok {
if items[index].DetailID != 0 {
return nil, nil, fmt.Errorf("单件入库码 %s 在货运单中出现多次,停止自动回写", code)
}
source := "existing"
if detail.ID == record.DetailID {
source = "original"
}
items[index].DetailID = detail.ID
items[index].Source = source
items[index].Status = "confirmed"
continue
}
for index := range items {
title := innerCodePlaceholderTitle(record.ID, index+1)
if detail.ProductTitle != title {
continue
}
if items[index].DetailID != 0 {
return nil, nil, fmt.Errorf("第 %d 件占位明细在货运单中出现多次,停止自动回写", index+1)
}
if code != "" {
return nil, nil, fmt.Errorf("第 %d 件占位明细已有非目标快递单号,停止自动回写", index+1)
}
items[index].DetailID = detail.ID
items[index].Source = "existing_placeholder"
items[index].Title = title
items[index].Status = "created"
}
}
missing := make([]int, 0, len(items))
for index := range items {
if items[index].Status != "confirmed" {
missing = append(missing, index)
}
}
return items, missing, nil
}
func innerCodePlaceholderTitle(recordID int64, ordinal int) string {
return fmt.Sprintf("档口第%d件-IC%d", ordinal, recordID)
}
func containsInnerCode(codes []string, value string) bool {
for _, code := range codes {
if code == value {
return true
}
}
return false
}
func countInnerCodeInStock(stock syb.StockDetail, code string) int {
count := 0
for _, detail := range stock.Details {
if innerCodeRawText(detail.Raw["innerExpCode"]) == code {
count++
}
}
return count
}
func innerCodeDetailCode(stock syb.StockDetail, detailID int64) string {
for _, detail := range stock.Details {
if detail.ID == detailID {
return innerCodeRawText(detail.Raw["innerExpCode"])
}
}
return ""
}
func unknownOrFailedInnerCodeOutcome(err error, action, remoteCode string) innerCodeApplyOutcome {
status := model.InnerCodeFailed
message := action + "失败,系统不会自动重试:" + err.Error()
if errors.Is(err, syb.ErrWriteResultUnknown) {
status = model.InnerCodeNeedsCheck
message = action + "结果未知,禁止自动重试,请重新核对"
}
return innerCodeApplyOutcome{Status: status, Message: message, RemoteCode: remoteCode}
}
func withInnerCodeProgress(outcome innerCodeApplyOutcome, items []innerCodeRemoteItem) innerCodeApplyOutcome {
confirmed := 0
for _, item := range items {
if item.Status == "confirmed" {
confirmed++
}
}
outcome.Message = fmt.Sprintf("已确认 %d/%d 件;%s", confirmed, len(items), outcome.Message)
return outcome
} }
// RecheckInnerCode 只重新读取一条 needs_check 记录,不发送任何写请求。 // RecheckInnerCode 只重新读取一条 needs_check 记录,不发送任何写请求。
@@ -242,24 +421,35 @@ func RecheckInnerCode(ctx context.Context, db *sql.DB, reader InnerCodeDetailRea
if record.Status != model.InnerCodeNeedsCheck { if record.Status != model.InnerCodeNeedsCheck {
return record.Status, "当前记录不需要核对", nil return record.Status, "当前记录不需要核对", nil
} }
item, readErr := readCurrentInnerCodeDetail(ctx, reader, *record) stock, item, readErr := readCurrentInnerCodeStock(ctx, reader, *record)
status := model.InnerCodeNeedsCheck status := model.InnerCodeNeedsCheck
remoteCode := record.RemoteInnerCode remoteCode := record.RemoteInnerCode
remoteItemsJSON := record.RemoteItemsJSON
message := "重新读取失败,仍需人工核对:" + errorText(readErr) message := "重新读取失败,仍需人工核对:" + errorText(readErr)
if readErr == nil { if readErr == nil {
codes, splitErr := splitInnerCodes(record.InnerCode)
items, missing, planErr := planInnerCodeRemoteItems(*record, stock, codes)
if splitErr == nil && planErr == nil {
if raw, err := json.Marshal(items); err == nil {
remoteItemsJSON = string(raw)
}
}
remoteCode = innerCodeRawText(item.Raw["innerExpCode"]) remoteCode = innerCodeRawText(item.Raw["innerExpCode"])
if !innerCodeDetailIdentityMatches(*record, *item) { if !innerCodeDetailIdentityMatches(*record, *item) {
message = "重新读取到的商品身份与规划不一致;保持需核对,系统没有写入" message = "重新读取到的商品身份与规划不一致;保持需核对,系统没有写入"
} else if remoteCode == record.InnerCode { } else if splitErr != nil || planErr != nil {
message = "重新读取到的逐件入库码存在冲突;保持需核对,系统没有写入"
} else if len(missing) == 0 {
status = model.InnerCodeUpdated status = model.InnerCodeUpdated
message = "重新读取确认远端已是目标入库码;没有重复写入" remoteCode = strings.Join(codes, ",")
message = fmt.Sprintf("重新读取确认远端已存在全部 %d 个单件入库码;没有重复写入", len(codes))
} else { } else {
message = "重新读取后远端仍不是目标入库码;保持需核对,系统没有写入" message = fmt.Sprintf("重新读取后仍缺少 %d 个单件入库码;保持需核对,系统没有写入", len(missing))
} }
} }
checkedAt := model.NowISO() checkedAt := model.NowISO()
message = compactInnerCodeMessage(message) message = compactInnerCodeMessage(message)
if err := repository.SaveInnerCodeRecheck(db, id, status, message, remoteCode, checkedAt); err != nil { if err := repository.SaveInnerCodeRecheck(db, id, status, message, remoteCode, remoteItemsJSON, checkedAt); err != nil {
return "", "", err return "", "", err
} }
return status, message, nil return status, message, nil
+111 -22
View File
@@ -13,14 +13,17 @@ import (
) )
type fakeInnerCodeWriter struct { type fakeInnerCodeWriter struct {
stock syb.StockDetail stock syb.StockDetail
readErr error readErr error
deleteErr error deleteErr error
updateErr error createErr error
readCount int updateErr error
deleteCount int readCount int
updateCount int deleteCount int
updatedCode string createCount int
updateCount int
updatedCode string
nextDetailID int64
} }
func TestQueueInnerCodeApplyBatch_超过20条可一次排队(t *testing.T) { func TestQueueInnerCodeApplyBatch_超过20条可一次排队(t *testing.T) {
@@ -81,6 +84,20 @@ func (f *fakeInnerCodeWriter) DeleteInnerCode(_ context.Context, detailID int64)
return nil return nil
} }
func (f *fakeInnerCodeWriter) CreateInnerCodeDetail(_ context.Context, stockID int64, title string) (int64, error) {
f.createCount++
if f.createErr != nil {
return 0, f.createErr
}
if f.nextDetailID == 0 {
f.nextDetailID = 100
}
f.nextDetailID++
f.stock.Details = append(f.stock.Details, syb.DetailItem{ID: f.nextDetailID, ProductTitle: title,
ProductQty: 1, ProductPrice: 0, Raw: map[string]any{"innerExpCode": ""}})
return f.nextDetailID, nil
}
func (f *fakeInnerCodeWriter) UpdateDetailCode(_ context.Context, _, detailID int64, code string) error { func (f *fakeInnerCodeWriter) UpdateDetailCode(_ context.Context, _, detailID int64, code string) error {
f.updateCount++ f.updateCount++
f.updatedCode = code f.updatedCode = code
@@ -95,21 +112,93 @@ func (f *fakeInnerCodeWriter) UpdateDetailCode(_ context.Context, _, detailID in
return nil return nil
} }
func TestApplyClaimedInnerCode_聚合码只写一次并按完整值复读(t *testing.T) { func TestApplyClaimedInnerCode_两个单件码写入两个明细(t *testing.T) {
record := innerCodeApplyTestRecord("") record := innerCodeApplyTestRecord("")
record.InnerCode = "DK260815A160101,DK260815A160102" record.InnerCode = "DK260815A160101,DK260815A160102"
record.SourceDuplicateCount = 2
writer := innerCodeApplyTestWriter(record, "") writer := innerCodeApplyTestWriter(record, "")
outcome := applyClaimedInnerCode(context.Background(), writer, record) outcome := applyClaimedInnerCode(context.Background(), writer, record, nil)
if outcome.Status != model.InnerCodeUpdated || writer.updateCount != 1 || writer.updatedCode != record.InnerCode || if outcome.Status != model.InnerCodeUpdated || writer.updateCount != 2 || writer.createCount != 1 ||
outcome.RemoteCode != record.InnerCode || writer.readCount != 2 { outcome.RemoteCode != record.InnerCode || writer.readCount != 3 {
t.Fatalf("outcome=%+v update=%d code=%q read=%d", outcome, writer.updateCount, writer.updatedCode, writer.readCount) t.Fatalf("outcome=%+v update=%d create=%d read=%d", outcome, writer.updateCount, writer.createCount, writer.readCount)
}
}
func TestApplyClaimedInnerCode_三个单件码创建两条零价明细(t *testing.T) {
record := innerCodeApplyTestRecord("")
record.InnerCode = "DK-001,DK-002,DK-003"
record.SourceDuplicateCount = 3
writer := innerCodeApplyTestWriter(record, "")
outcome := applyClaimedInnerCode(context.Background(), writer, record, nil)
if outcome.Status != model.InnerCodeUpdated || writer.updateCount != 3 || writer.createCount != 2 {
t.Fatalf("outcome=%+v update=%d create=%d", outcome, writer.updateCount, writer.createCount)
}
for _, detail := range writer.stock.Details[1:] {
if detail.ProductQty != 1 || detail.ProductPrice != 0 {
t.Fatalf("占位明细必须数量 1、价格 0: %+v", detail)
}
}
}
func TestApplyClaimedInnerCode_复用人工明细已有目标码(t *testing.T) {
record := innerCodeApplyTestRecord("")
record.InnerCode = "DK-001,DK-002"
record.SourceDuplicateCount = 2
writer := innerCodeApplyTestWriter(record, "")
writer.stock.Details = append(writer.stock.Details, syb.DetailItem{ID: 21, ProductTitle: "人工明细", ProductQty: 1,
Raw: map[string]any{"innerExpCode": "DK-002"}})
outcome := applyClaimedInnerCode(context.Background(), writer, record, nil)
if outcome.Status != model.InnerCodeUpdated || writer.updateCount != 1 || writer.createCount != 0 || writer.deleteCount != 0 {
t.Fatalf("outcome=%+v update=%d create=%d delete=%d", outcome, writer.updateCount, writer.createCount, writer.deleteCount)
}
}
func TestApplyClaimedInnerCode_数量不一致零写入(t *testing.T) {
record := innerCodeApplyTestRecord("")
record.InnerCode = "DK-001,DK-002"
record.SourceDuplicateCount = 2
writer := innerCodeApplyTestWriter(record, "")
writer.stock.Details[0].ProductQty = 1
outcome := applyClaimedInnerCode(context.Background(), writer, record, nil)
if outcome.Status != model.InnerCodeFailed || writer.updateCount != 0 || writer.createCount != 0 || writer.deleteCount != 0 {
t.Fatalf("outcome=%+v update=%d create=%d delete=%d", outcome, writer.updateCount, writer.createCount, writer.deleteCount)
}
}
func TestApplyClaimedInnerCode_创建结果未知立即停止(t *testing.T) {
record := innerCodeApplyTestRecord("")
record.InnerCode = "DK-001,DK-002"
record.SourceDuplicateCount = 2
writer := innerCodeApplyTestWriter(record, "")
writer.createErr = fmtUnknownInnerCodeWriteError()
checkpoints := 0
outcome := applyClaimedInnerCode(context.Background(), writer, record, func([]innerCodeRemoteItem, string) error {
checkpoints++
return nil
})
if outcome.Status != model.InnerCodeNeedsCheck || writer.createCount != 1 || writer.updateCount != 1 || checkpoints < 3 {
t.Fatalf("outcome=%+v create=%d update=%d checkpoints=%d", outcome, writer.createCount, writer.updateCount, checkpoints)
}
}
func TestApplyClaimedInnerCode_崩溃遗留占位明细会复用而不重复创建(t *testing.T) {
record := innerCodeApplyTestRecord("")
record.InnerCode = "DK-001,DK-002"
record.SourceDuplicateCount = 2
writer := innerCodeApplyTestWriter(record, "")
writer.stock.Details = append(writer.stock.Details, syb.DetailItem{ID: 99,
ProductTitle: innerCodePlaceholderTitle(record.ID, 2), ProductQty: 1, ProductPrice: 0,
Raw: map[string]any{"innerExpCode": ""}})
outcome := applyClaimedInnerCode(context.Background(), writer, record, nil)
if outcome.Status != model.InnerCodeUpdated || writer.createCount != 0 || writer.updateCount != 2 {
t.Fatalf("outcome=%+v create=%d update=%d", outcome, writer.createCount, writer.updateCount)
} }
} }
func TestApplyClaimedInnerCode_空旧值直接写并复读确认(t *testing.T) { func TestApplyClaimedInnerCode_空旧值直接写并复读确认(t *testing.T) {
record := innerCodeApplyTestRecord("") record := innerCodeApplyTestRecord("")
writer := innerCodeApplyTestWriter(record, "") writer := innerCodeApplyTestWriter(record, "")
outcome := applyClaimedInnerCode(context.Background(), writer, record) outcome := applyClaimedInnerCode(context.Background(), writer, record, nil)
if outcome.Status != model.InnerCodeUpdated || writer.deleteCount != 0 || writer.updateCount != 1 || writer.readCount != 2 { 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) t.Fatalf("outcome=%+v counts read=%d delete=%d update=%d", outcome, writer.readCount, writer.deleteCount, writer.updateCount)
} }
@@ -118,7 +207,7 @@ func TestApplyClaimedInnerCode_空旧值直接写并复读确认(t *testing.T) {
func TestApplyClaimedInnerCode_有旧值先删后写(t *testing.T) { func TestApplyClaimedInnerCode_有旧值先删后写(t *testing.T) {
record := innerCodeApplyTestRecord("OLD") record := innerCodeApplyTestRecord("OLD")
writer := innerCodeApplyTestWriter(record, "OLD") writer := innerCodeApplyTestWriter(record, "OLD")
outcome := applyClaimedInnerCode(context.Background(), writer, record) outcome := applyClaimedInnerCode(context.Background(), writer, record, nil)
if outcome.Status != model.InnerCodeUpdated || writer.deleteCount != 1 || writer.updateCount != 1 { if outcome.Status != model.InnerCodeUpdated || writer.deleteCount != 1 || writer.updateCount != 1 {
t.Fatalf("outcome=%+v delete=%d update=%d", outcome, writer.deleteCount, writer.updateCount) t.Fatalf("outcome=%+v delete=%d update=%d", outcome, writer.deleteCount, writer.updateCount)
} }
@@ -127,7 +216,7 @@ func TestApplyClaimedInnerCode_有旧值先删后写(t *testing.T) {
func TestApplyClaimedInnerCode_规划后远端值变化不写入(t *testing.T) { func TestApplyClaimedInnerCode_规划后远端值变化不写入(t *testing.T) {
record := innerCodeApplyTestRecord("OLD") record := innerCodeApplyTestRecord("OLD")
writer := innerCodeApplyTestWriter(record, "OTHER") writer := innerCodeApplyTestWriter(record, "OTHER")
outcome := applyClaimedInnerCode(context.Background(), writer, record) outcome := applyClaimedInnerCode(context.Background(), writer, record, nil)
if outcome.Status != model.InnerCodeNeedsCheck || writer.deleteCount != 0 || writer.updateCount != 0 { if outcome.Status != model.InnerCodeNeedsCheck || writer.deleteCount != 0 || writer.updateCount != 0 {
t.Fatalf("outcome=%+v delete=%d update=%d", outcome, writer.deleteCount, writer.updateCount) t.Fatalf("outcome=%+v delete=%d update=%d", outcome, writer.deleteCount, writer.updateCount)
} }
@@ -137,7 +226,7 @@ func TestApplyClaimedInnerCode_删除明确失败不继续写(t *testing.T) {
record := innerCodeApplyTestRecord("OLD") record := innerCodeApplyTestRecord("OLD")
writer := innerCodeApplyTestWriter(record, "OLD") writer := innerCodeApplyTestWriter(record, "OLD")
writer.deleteErr = errors.New("已打单数据不能清除") writer.deleteErr = errors.New("已打单数据不能清除")
outcome := applyClaimedInnerCode(context.Background(), writer, record) outcome := applyClaimedInnerCode(context.Background(), writer, record, nil)
if outcome.Status != model.InnerCodeFailed || writer.deleteCount != 1 || writer.updateCount != 0 { if outcome.Status != model.InnerCodeFailed || writer.deleteCount != 1 || writer.updateCount != 0 {
t.Fatalf("outcome=%+v delete=%d update=%d", outcome, writer.deleteCount, writer.updateCount) t.Fatalf("outcome=%+v delete=%d update=%d", outcome, writer.deleteCount, writer.updateCount)
} }
@@ -147,7 +236,7 @@ func TestApplyClaimedInnerCode_写入未知结果不复读也不重试(t *testin
record := innerCodeApplyTestRecord("") record := innerCodeApplyTestRecord("")
writer := innerCodeApplyTestWriter(record, "") writer := innerCodeApplyTestWriter(record, "")
writer.updateErr = fmtUnknownInnerCodeWriteError() writer.updateErr = fmtUnknownInnerCodeWriteError()
outcome := applyClaimedInnerCode(context.Background(), writer, record) outcome := applyClaimedInnerCode(context.Background(), writer, record, nil)
if outcome.Status != model.InnerCodeNeedsCheck || writer.updateCount != 1 || writer.readCount != 1 { if outcome.Status != model.InnerCodeNeedsCheck || writer.updateCount != 1 || writer.readCount != 1 {
t.Fatalf("outcome=%+v update=%d read=%d", outcome, writer.updateCount, writer.readCount) t.Fatalf("outcome=%+v update=%d read=%d", outcome, writer.updateCount, writer.readCount)
} }
@@ -157,7 +246,7 @@ func TestApplyClaimedInnerCode_采购字段出现后停止(t *testing.T) {
record := innerCodeApplyTestRecord("") record := innerCodeApplyTestRecord("")
writer := innerCodeApplyTestWriter(record, "") writer := innerCodeApplyTestWriter(record, "")
writer.stock.Details[0].Raw["purchaseCode"] = "CG-1" writer.stock.Details[0].Raw["purchaseCode"] = "CG-1"
outcome := applyClaimedInnerCode(context.Background(), writer, record) outcome := applyClaimedInnerCode(context.Background(), writer, record, nil)
if outcome.Status != model.InnerCodeSkipped || writer.updateCount != 0 { if outcome.Status != model.InnerCodeSkipped || writer.updateCount != 0 {
t.Fatalf("outcome=%+v update=%d", outcome, writer.updateCount) t.Fatalf("outcome=%+v update=%d", outcome, writer.updateCount)
} }
@@ -168,7 +257,7 @@ func TestApplyClaimedInnerCode_档口身份变化后停止(t *testing.T) {
record.SybSKU = "A#1" record.SybSKU = "A#1"
writer := innerCodeApplyTestWriter(record, "") writer := innerCodeApplyTestWriter(record, "")
writer.stock.Details[0].Raw["sku"] = "B#2" writer.stock.Details[0].Raw["sku"] = "B#2"
outcome := applyClaimedInnerCode(context.Background(), writer, record) outcome := applyClaimedInnerCode(context.Background(), writer, record, nil)
if outcome.Status != model.InnerCodeSkipped || writer.updateCount != 0 { if outcome.Status != model.InnerCodeSkipped || writer.updateCount != 0 {
t.Fatalf("outcome=%+v update=%d", outcome, writer.updateCount) t.Fatalf("outcome=%+v update=%d", outcome, writer.updateCount)
} }
@@ -176,11 +265,11 @@ func TestApplyClaimedInnerCode_档口身份变化后停止(t *testing.T) {
func innerCodeApplyTestRecord(oldCode string) model.InnerCodeRecord { func innerCodeApplyTestRecord(oldCode string) model.InnerCodeRecord {
return model.InnerCodeRecord{ID: 1, StockID: 10, DetailID: 20, SybSpec: "黑色,M", return model.InnerCodeRecord{ID: 1, StockID: 10, DetailID: 20, SybSpec: "黑色,M",
InnerCode: "DK-001", RemoteInnerCode: oldCode, Status: model.InnerCodeApplying} InnerCode: "DK-001", SourceDuplicateCount: 1, RemoteInnerCode: oldCode, Status: model.InnerCodeApplying}
} }
func innerCodeApplyTestWriter(record model.InnerCodeRecord, currentCode string) *fakeInnerCodeWriter { func innerCodeApplyTestWriter(record model.InnerCodeRecord, currentCode string) *fakeInnerCodeWriter {
item := syb.DetailItem{ID: record.DetailID, ProductSpec: record.SybSpec, Raw: map[string]any{ item := syb.DetailItem{ID: record.DetailID, ProductSpec: record.SybSpec, ProductQty: record.SourceDuplicateCount, Raw: map[string]any{
"id": record.DetailID, "productSpec": record.SybSpec, "innerExpCode": currentCode, "id": record.DetailID, "productSpec": record.SybSpec, "innerExpCode": currentCode,
"purchasePlatform": "", "purchaseCode": "", "purchasePlatform": "", "purchaseCode": "",
}} }}
+17 -2
View File
@@ -277,6 +277,17 @@ func planInnerCodeRowsWithReserved(records []model.InnerCodeRecord, stockIDsByOr
continue continue
} }
matched := matches[0] matched := matches[0]
codes, codeErr := splitInnerCodes(record.InnerCode)
if codeErr != nil || len(codes) != record.SourceDuplicateCount {
plans = append(plans, innerCodeSkippedPlan(plan,
fmt.Sprintf("单件入库码数量与 Excel 源行数不一致(入库码 %d 个,源行 %d 行)", len(codes), record.SourceDuplicateCount)))
continue
}
if matched.ProductQty != len(codes) {
plans = append(plans, innerCodeSkippedPlan(plan,
fmt.Sprintf("顺运宝商品数量为 %d,单件入库码为 %d 个,数量不一致", matched.ProductQty, len(codes))))
continue
}
used[record.OrderNumber][matched.ID] = true used[record.OrderNumber][matched.ID] = true
plan.StockID = stockID plan.StockID = stockID
plan.DetailID = matched.ID plan.DetailID = matched.ID
@@ -286,9 +297,13 @@ func planInnerCodeRowsWithReserved(records []model.InnerCodeRecord, stockIDsByOr
plan.PurchasePlatform = innerCodeRawText(matched.Raw["purchasePlatform"]) plan.PurchasePlatform = innerCodeRawText(matched.Raw["purchasePlatform"])
plan.PurchaseCode = innerCodeRawText(matched.Raw["purchaseCode"]) plan.PurchaseCode = innerCodeRawText(matched.Raw["purchaseCode"])
plan.RemoteInnerCode = innerCodeRawText(matched.Raw["innerExpCode"]) plan.RemoteInnerCode = innerCodeRawText(matched.Raw["innerExpCode"])
if plan.RemoteInnerCode == record.InnerCode { _, missing, remoteErr := planInnerCodeRemoteItems(plan, stock, codes)
if remoteErr != nil {
plan.Status = model.InnerCodeSkipped
plan.ResultMessage = remoteErr.Error()
} else if len(missing) == 0 {
plan.Status = model.InnerCodeAlreadyFilled plan.Status = model.InnerCodeAlreadyFilled
plan.ResultMessage = "远端已是相同入库码,无需重复写入" plan.ResultMessage = fmt.Sprintf("远端已存在全部 %d 个单件入库码,无需重复写入", len(codes))
} else { } else {
plan.Status = model.InnerCodeReady plan.Status = model.InnerCodeReady
plan.ResultMessage = "唯一匹配,等待操作员确认回写" plan.ResultMessage = "唯一匹配,等待操作员确认回写"
+4 -4
View File
@@ -179,9 +179,9 @@ func TestPlanInnerCodeRows_精确与标准化规格均受档口约束(t *testing
} }
} }
func TestPlanInnerCodeRows_聚合码放行且其他异常继续阻断(t *testing.T) { func TestPlanInnerCodeRows_多件数量一致放行且其他异常继续阻断(t *testing.T) {
records := []model.InnerCodeRecord{ records := []model.InnerCodeRecord{
{ID: 1, OrderNumber: "DUP", SpecRaw: "黑色,M", InnerCode: "DK1", SourceDuplicateCount: 2}, {ID: 1, OrderNumber: "DUP", SpecRaw: "黑色,M", InnerCode: "DK1,DK2", SourceDuplicateCount: 2},
{ID: 2, OrderNumber: "EMPTY", SpecRaw: "", InnerCode: "DK2", SourceDuplicateCount: 1}, {ID: 2, OrderNumber: "EMPTY", SpecRaw: "", InnerCode: "DK2", SourceDuplicateCount: 1},
{ID: 3, OrderNumber: "PURCHASED", SpecRaw: "黑色,M", InnerCode: "DK3", SourceDuplicateCount: 1}, {ID: 3, OrderNumber: "PURCHASED", SpecRaw: "黑色,M", InnerCode: "DK3", SourceDuplicateCount: 1},
{ID: 4, OrderNumber: "AMBIGUOUS", SpecRaw: "黑色,M", InnerCode: "DK4", SourceDuplicateCount: 1}, {ID: 4, OrderNumber: "AMBIGUOUS", SpecRaw: "黑色,M", InnerCode: "DK4", SourceDuplicateCount: 1},
@@ -189,7 +189,7 @@ func TestPlanInnerCodeRows_聚合码放行且其他异常继续阻断(t *testing
} }
stocks := map[string][]int64{"DUP": {1}, "EMPTY": {2}, "PURCHASED": {3}, "AMBIGUOUS": {4}} stocks := map[string][]int64{"DUP": {1}, "EMPTY": {2}, "PURCHASED": {3}, "AMBIGUOUS": {4}}
details := map[int64]syb.StockDetail{ details := map[int64]syb.StockDetail{
1: {ID: 1, Details: []syb.DetailItem{innerCodeTestItem(1, "黑色,M", nil)}}, 1: {ID: 1, Details: []syb.DetailItem{func() syb.DetailItem { item := innerCodeTestItem(1, "黑色,M", nil); item.ProductQty = 2; return item }()}},
2: {ID: 2, Details: []syb.DetailItem{innerCodeTestItem(2, "黑色,M", nil)}}, 2: {ID: 2, Details: []syb.DetailItem{innerCodeTestItem(2, "黑色,M", nil)}},
3: {ID: 3, Details: []syb.DetailItem{innerCodeTestItem(3, "黑色,M", map[string]any{"purchasePlatform": "PDD"})}}, 3: {ID: 3, Details: []syb.DetailItem{innerCodeTestItem(3, "黑色,M", map[string]any{"purchasePlatform": "PDD"})}},
4: {ID: 4, Details: []syb.DetailItem{innerCodeTestItem(4, "黑色,M", nil), innerCodeTestItem(5, "黑色,M", nil)}}, 4: {ID: 4, Details: []syb.DetailItem{innerCodeTestItem(4, "黑色,M", nil), innerCodeTestItem(5, "黑色,M", nil)}},
@@ -275,5 +275,5 @@ func innerCodeTestItem(id int64, spec string, raw map[string]any) syb.DetailItem
} }
raw["id"] = id raw["id"] = id
raw["productSpec"] = spec raw["productSpec"] = spec
return syb.DetailItem{ID: id, ProductSpec: spec, Raw: raw} return syb.DetailItem{ID: id, ProductSpec: spec, ProductQty: 1, Raw: raw}
} }
+8
View File
@@ -49,6 +49,8 @@ type InnerCodeRowView struct {
CanMatch bool CanMatch bool
CanApply bool CanApply bool
HasRemoteOldCode bool HasRemoteOldCode bool
CodeCount int
ExpectedAddCount int
CanRecheck bool CanRecheck bool
} }
@@ -133,6 +135,10 @@ func innerCodeRowView(record model.InnerCodeRecord) InnerCodeRowView {
record.Status == model.InnerCodeSkipped || record.Status == model.InnerCodeSkipped ||
record.Status == model.InnerCodeFailed record.Status == model.InnerCodeFailed
canApply := record.Status == model.InnerCodeReady canApply := record.Status == model.InnerCodeReady
codeCount := record.SourceDuplicateCount
if codeCount < 1 {
codeCount = 1
}
return InnerCodeRowView{ return InnerCodeRowView{
ID: record.ID, BusinessDate: record.BusinessDate, OrderNumber: record.OrderNumber, ID: record.ID, BusinessDate: record.BusinessDate, OrderNumber: record.OrderNumber,
Stall: displayInnerCodeValue(record.Stall), SpecRaw: displayInnerCodeValue(record.SpecRaw), Stall: displayInnerCodeValue(record.Stall), SpecRaw: displayInnerCodeValue(record.SpecRaw),
@@ -143,6 +149,8 @@ func innerCodeRowView(record model.InnerCodeRecord) InnerCodeRowView {
CanMatch: canMatch, CanMatch: canMatch,
CanApply: canApply, CanApply: canApply,
HasRemoteOldCode: canApply && record.RemoteInnerCode != "", HasRemoteOldCode: canApply && record.RemoteInnerCode != "",
CodeCount: codeCount,
ExpectedAddCount: max(codeCount-1, 0),
CanRecheck: record.Status == model.InnerCodeNeedsCheck, CanRecheck: record.Status == model.InnerCodeNeedsCheck,
} }
} }
+10
View File
@@ -435,12 +435,22 @@
var replacements = applySelected.filter(function (box) { var replacements = applySelected.filter(function (box) {
return box.getAttribute("data-inner-code-old-value") === "1"; return box.getAttribute("data-inner-code-old-value") === "1";
}).length; }).length;
var codeTotal = applySelected.reduce(function (total, box) {
return total + Number(box.getAttribute("data-inner-code-code-count") || 0);
}, 0);
var addTotal = applySelected.reduce(function (total, box) {
return total + Number(box.getAttribute("data-inner-code-add-count") || 0);
}, 0);
document.querySelectorAll("[data-inner-code-match-count]") document.querySelectorAll("[data-inner-code-match-count]")
.forEach(function (node) { node.textContent = matchSelected.length; }); .forEach(function (node) { node.textContent = matchSelected.length; });
document.querySelectorAll("[data-inner-code-selected-count], [data-inner-code-confirm-count]") document.querySelectorAll("[data-inner-code-selected-count], [data-inner-code-confirm-count]")
.forEach(function (node) { node.textContent = applySelected.length; }); .forEach(function (node) { node.textContent = applySelected.length; });
document.querySelectorAll("[data-inner-code-replace-count]") document.querySelectorAll("[data-inner-code-replace-count]")
.forEach(function (node) { node.textContent = replacements; }); .forEach(function (node) { node.textContent = replacements; });
document.querySelectorAll("[data-inner-code-code-total]")
.forEach(function (node) { node.textContent = codeTotal; });
document.querySelectorAll("[data-inner-code-add-total]")
.forEach(function (node) { node.textContent = addTotal; });
document.querySelectorAll("[data-inner-code-delete-count]") document.querySelectorAll("[data-inner-code-delete-count]")
.forEach(function (node) { node.textContent = deleteSelected.length; }); .forEach(function (node) { node.textContent = deleteSelected.length; });
} }
+26 -2
View File
@@ -753,6 +753,30 @@ func (c *Client) DeleteInnerCode(ctx context.Context, detailID int64) error {
return classifyInnerCodeWriteError(err) return classifyInnerCodeWriteError(err)
} }
// CreateInnerCodeDetail 为同一货运单额外创建一件零价占位商品,并返回新的 detailId。
// 请求只发送一次;结果未知时返回 ErrWriteResultUnknown,调用方不得自动重试。
func (c *Client) CreateInnerCodeDetail(ctx context.Context, stockID int64, title string) (int64, error) {
title = strings.TrimSpace(title)
if stockID <= 0 {
return 0, fmt.Errorf("货运单 id 必须是正整数")
}
if title == "" || utf8.RuneCountInString(title) > 128 {
return 0, fmt.Errorf("占位商品标题不能为空且不能超过 128 个字符")
}
data, err := c.do(ctx, http.MethodPost, "/am/stock/detail/createDetail", nil, map[string]any{
"id": nil, "productTitle": title, "productSpec": nil,
"productQty": 1, "productPrice": 0, "stockId": stockID,
})
if err != nil {
return 0, classifyInnerCodeWriteError(err)
}
var detailID int64
if err := json.Unmarshal(data, &detailID); err != nil || detailID <= 0 {
return 0, fmt.Errorf("%w:新增明细响应缺少有效 detailId", ErrWriteResultUnknown)
}
return detailID, nil
}
// UpdateDetailCode 把档口入库码写入货运明细的 innerExpCode。 // UpdateDetailCode 把档口入库码写入货运明细的 innerExpCode。
// 请求只发送一次;结果未知时返回 ErrWriteResultUnknown,调用方不得重试。 // 请求只发送一次;结果未知时返回 ErrWriteResultUnknown,调用方不得重试。
func (c *Client) UpdateDetailCode(ctx context.Context, stockID, detailID int64, code string) error { func (c *Client) UpdateDetailCode(ctx context.Context, stockID, detailID int64, code string) error {
@@ -771,8 +795,8 @@ func (c *Client) UpdateDetailCode(ctx context.Context, stockID, detailID int64,
return fmt.Errorf("code 不能包含控制字符") return fmt.Errorf("code 不能包含控制字符")
} }
} }
_, err := c.do(ctx, http.MethodGet, "/am/stock/detail/updateDetailCode", url.Values{ _, err := c.do(ctx, http.MethodPost, "/am/stock/detail/updateDetailCode", url.Values{
"t": {"0"}, "id": {strconv.FormatInt(stockID, 10)}, "t": {strconv.FormatInt(time.Now().UnixMilli(), 10)}, "id": {strconv.FormatInt(stockID, 10)},
"detailId": {strconv.FormatInt(detailID, 10)}, "code": {code}, "detailId": {strconv.FormatInt(detailID, 10)}, "code": {code},
}, nil) }, nil)
return classifyInnerCodeWriteError(err) return classifyInnerCodeWriteError(err)
+25 -3
View File
@@ -569,8 +569,8 @@ func TestClient_DetailListByStock_超过100个id报错(t *testing.T) {
} }
} }
func TestClient_InnerCodeWrite_聚合码参数和路径正确且只发送一次(t *testing.T) { func TestClient_InnerCodeWrite_单件码参数和路径正确且只发送一次(t *testing.T) {
targetCode := "DK260815A160101,DK260815A160102" targetCode := "DK260815A160101"
var requests atomic.Int32 var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1) requests.Add(1)
@@ -582,7 +582,7 @@ func TestClient_InnerCodeWrite_聚合码参数和路径正确且只发送一次(
} }
case "/am/stock/detail/updateDetailCode": case "/am/stock/detail/updateDetailCode":
query := r.URL.Query() query := r.URL.Query()
if query.Get("t") != "0" || query.Get("id") != "11" || query.Get("detailId") != "22" || query.Get("code") != targetCode { if r.Method != http.MethodPost || query.Get("t") == "" || query.Get("id") != "11" || query.Get("detailId") != "22" || query.Get("code") != targetCode {
t.Errorf("update query=%v", query) t.Errorf("update query=%v", query)
} }
default: default:
@@ -606,6 +606,28 @@ func TestClient_InnerCodeWrite_聚合码参数和路径正确且只发送一次(
} }
} }
func TestClient_CreateInnerCodeDetail_使用零价单件请求并返回ID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/am/stock/detail/createDetail" {
t.Fatalf("request=%s %s", r.Method, r.URL.Path)
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body["stockId"] != float64(11) || body["productQty"] != float64(1) || body["productPrice"] != float64(0) || body["productTitle"] != "档口第2件-IC1" {
t.Fatalf("body=%v", body)
}
io.WriteString(w, `{"status":true,"msg":"创建成功","data":147372531,"code":null}`)
}))
defer server.Close()
client, _ := New(server.URL)
detailID, err := client.CreateInnerCodeDetail(context.Background(), 11, "档口第2件-IC1")
if err != nil || detailID != 147372531 {
t.Fatalf("detailID=%d err=%v", detailID, err)
}
}
func TestClient_InnerCodeWrite_未知结果与明确业务失败分开(t *testing.T) { func TestClient_InnerCodeWrite_未知结果与明确业务失败分开(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
name string name string
+8 -3
View File
@@ -129,6 +129,8 @@
<input type="checkbox" value="{{.ID}}" data-inner-code-id="{{.ID}}" <input type="checkbox" value="{{.ID}}" data-inner-code-id="{{.ID}}"
data-select-action="inner-code-delete {{if .CanMatch}}inner-code-match{{end}}{{if .CanApply}} inner-code-apply{{end}}" data-select-action="inner-code-delete {{if .CanMatch}}inner-code-match{{end}}{{if .CanApply}} inner-code-apply{{end}}"
data-inner-code-old-value="{{if .HasRemoteOldCode}}1{{else}}0{{end}}" data-inner-code-old-value="{{if .HasRemoteOldCode}}1{{else}}0{{end}}"
data-inner-code-code-count="{{.CodeCount}}"
data-inner-code-add-count="{{.ExpectedAddCount}}"
aria-label="选择订单 {{.OrderNumber}}"> aria-label="选择订单 {{.OrderNumber}}">
</td> </td>
<td>{{.BusinessDate}}</td> <td>{{.BusinessDate}}</td>
@@ -178,11 +180,14 @@
<button type="button" class="modal-x" data-modal-close aria-label="关闭">×</button> <button type="button" class="modal-x" data-modal-close aria-label="关闭">×</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p class="confirm-box">将回写 <strong data-inner-code-confirm-count>0</strong> 条记录,其中 <p class="confirm-box">将回写 <strong data-inner-code-confirm-count>0</strong> 条记录,共
<strong data-inner-code-replace-count>0</strong> 条有旧快递单号,需要先删除旧值再写入。</p> <strong data-inner-code-code-total>0</strong> 个单件入库码,预计最多新增
<strong data-inner-code-add-total>0</strong> 条零价明细;其中
<strong data-inner-code-replace-count>0</strong> 条有旧快递单号。</p>
<ul> <ul>
<li>每条写入前会重新读取顺运宝详情并核验商品。</li> <li>每条写入前会重新读取顺运宝详情并核验商品。</li>
<li>写入后会再次读取;只有远端值一致才标记“已回写”。</li> <li>每个入库码只写入一个商品明细;多件商品会逐件创建零价占位明细。</li>
<li>每件写入后都会再次读取;全部单件码一致才标记“已回写”。</li>
<li>提交后页面立即返回;后台每次读取最多 20 条,但仍会逐条安全回写。</li> <li>提交后页面立即返回;后台每次读取最多 20 条,但仍会逐条安全回写。</li>
<li class="missing">超时或结果不确定时只标记“需核对”,不会自动重复写入。</li> <li class="missing">超时或结果不确定时只标记“需核对”,不会自动重复写入。</li>
</ul> </ul>
+7 -1
View File
@@ -129,6 +129,8 @@ v19(#212)将产品概念收敛为唯一店铺名称和唯一启停状态。
则保持停用”的安全规则合并 v18 状态,重建由 `shops` 派生的兼容数据和两侧关联。 则保持停用”的安全规则合并 v18 状态,重建由 `shops` 派生的兼容数据和两侧关联。
v26(#254)新增 `purchase_spec_resolutions`,用一张表保存采购执行中的候选观察、 v26(#254)新增 `purchase_spec_resolutions`,用一张表保存采购执行中的候选观察、
幂等身份和最终规格决策;不修改历史 SQLite migration,也不覆盖 PDD 商品主数据。 幂等身份和最终规格决策;不修改历史 SQLite migration,也不覆盖 PDD 商品主数据。
v27(#250)为 `syb_inner_code_records` 增加可空 JSON 字段 `remote_items_json`,保存
多件商品逐个入库码对应的远端明细、来源和动作状态;仍不拆分新的业务表。
**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 语句
@@ -1025,7 +1027,7 @@ CREATE UNIQUE INDEX idx_client_assignment_current
`ai_spec_match_decisions`。Admin 重启把未完成明细改为 `interrupted` 并完成批次计数,已经 `ai_spec_match_decisions`。Admin 重启把未完成明细改为 `interrupted` 并完成批次计数,已经
成功写入的 `spec_mappings` 不回滚。 成功写入的 `spec_mappings` 不回滚。
## 17. `syb_inner_code_records` 档口入库码记录(MySQL v23,软删除 v24,后台回写 v25) ## 17. `syb_inner_code_records` 档口入库码记录(MySQL v23,软删除 v24,后台回写 v25,逐件检查点 v27)
档口入库码使用一张业务表完成导入、匹配、回写和异常恢复。Excel 只是导入载体,系统 档口入库码使用一张业务表完成导入、匹配、回写和异常恢复。Excel 只是导入载体,系统
不保存原文件、文件哈希,不再拆批次表或尝试记录表。 不保存原文件、文件哈希,不再拆批次表或尝试记录表。
@@ -1055,6 +1057,10 @@ CREATE UNIQUE INDEX idx_client_assignment_current
不新增批次表:提交时所选 `ready` 记录必须在同一事务内全部转为 `queued`;后台每次最多 不新增批次表:提交时所选 `ready` 记录必须在同一事务内全部转为 `queued`;后台每次最多
读取 20 条并逐条领取为 `applying`。页面按 `apply_batch_id` 聚合当前批次进度。Admin 读取 20 条并逐条领取为 `applying`。页面按 `apply_batch_id` 聚合当前批次进度。Admin
重启时,`queued` 恢复为 `ready` 且不会自动写远端,`applying` 转为 `needs_check`。 重启时,`queued` 恢复为 `ready` 且不会自动写远端,`applying` 转为 `needs_check`。
- v27 增加 `remote_items_json`。`inner_code` 继续保留英文逗号连接的本地聚合值,但远端
不再接收聚合字符串:原商品明细承载一个码,额外单件使用数量 1、价格 0 的占位明细。
JSON 数组按单件码保存 `detail_id/source/title/status` 检查点;新增或写入结果未知时立即
转为 `needs_check`,重启后只读扫描整张货运单,不自动重发请求。
## 18. `purchase_spec_resolutions` 采购运行时规格解析(MySQL v26) ## 18. `purchase_spec_resolutions` 采购运行时规格解析(MySQL v26)
+16 -7
View File
@@ -410,21 +410,30 @@ SKU(平均每商品 1.17 个),**查无此 SKU 是常态**,不是异常
`[建议]` 登录后**只调 `/am/user/get` 验证会话**,其余几个是网页自己的初始化请求, `[建议]` 登录后**只调 `/am/user/get` 验证会话**,其余几个是网页自己的初始化请求,
Go 侧不用跟着调。 Go 侧不用跟着调。
### 7.1 档口入库码写入(工单 #234) ### 7.1 档口入库码逐件写入(工单 #234、#250)
档口入库码最终写入货运明细的 `innerExpCode`(页面名称“快递单号”)。两个写接口来自 档口入库码最终写入货运明细的 `innerExpCode`(页面名称“快递单号”)。接口来自
现有 Python 流程和线上响应样本: 现有 Python 流程和 HAR 响应样本:
```text ```text
GET /am/stock/detail/deleteInnerCode?detailId={detailID} GET /am/stock/detail/deleteInnerCode?detailId={detailID}
GET /am/stock/detail/updateDetailCode?t=0&id={stockID}&detailId={detailID}&code={innerCode} POST /am/stock/detail/createDetail
POST /am/stock/detail/updateDetailCode?t={当前毫秒时间戳}&id={stockID}&detailId={detailID}&code={单件innerCode}
``` ```
- 旧值为空时不调用删除;旧值非空时,只有删除明确成功才发送写入。 `createDetail` 使用 JSON 请求体:`id=null`、稳定占位标题、`productSpec=null`、
`productQty=1`、`productPrice=0`、`stockId`;成功响应的 `data` 是新 `detailId`。
- 回写前必须同时核对目标码数量、Excel 合并源行数和原商品 `productQty`;任一不一致时
零写入。一个远端明细只写一个单件码,原明细为空时承载第一个缺失码,其余缺失码创建
零价占位明细。
- 同一货运单任意明细已有目标码时直接复用,不删除、不搬移。稳定占位标题可用于识别
“已创建但检查点未保存”的明细,防止崩溃后重复创建。
- 规划时的旧值为空时不调用删除;旧值非空且仍与规划快照一致时,只有删除明确成功才继续。
- 删除和写入请求都只允许发送一次,不使用自动重试。超时、5xx、响应读取失败或成功响应 - 删除和写入请求都只允许发送一次,不使用自动重试。超时、5xx、响应读取失败或成功响应
无法解析时,结果可能已经在远端生效,必须标为“需核对”。 无法解析时,结果可能已经在远端生效,必须标为“需核对”。
- 每条写入前重新调用 `listByStock`,核对 stock/detail、规格、采购平台、采购单号和规划时 - 每件动作前后把检查点写入 `remote_items_json`;每个单件码写入后重新读取整张货运单,
的旧值;写入后再次读取,只有 `innerExpCode` 与目标一致才标为已回写。 只有它唯一出现在预期明细中才继续。全部目标码确认后才标为已回写。
- “重新核对”只调用 `listByStock`,绝不能再次调用上述两个写接口。 - “重新核对”只调用 `listByStock`,绝不能再次调用上述两个写接口。
- 页面提交数量不限制为 20 条。所选记录先在 MySQL 同一业务表中进入 `queued`,HTTP 请求 - 页面提交数量不限制为 20 条。所选记录先在 MySQL 同一业务表中进入 `queued`,HTTP 请求
随即返回;后台每次最多读取 20 条,再逐条执行上述远端门禁。Admin 重启不会自动继续 随即返回;后台每次最多读取 20 条,再逐条执行上述远端门禁。Admin 重启不会自动继续