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
PurchaseCode string
RemoteInnerCode string
RemoteItemsJSON string
Status InnerCodeStatus
ResultMessage 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=?,
status='pending',stock_id=NULL,detail_id=NULL,syb_spec=NULL,syb_sku=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,
deleted_at=NULL,deleted_by_user_id=NULL,updated_at=?
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_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_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,
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,
@@ -303,7 +304,7 @@ func SaveInnerCodePlans(db *sql.DB, plans []model.InnerCodeRecord, plannedAt str
UPDATE syb_inner_code_records
SET stock_id=?,detail_id=?,syb_spec=?,syb_sku=?,syb_variation_sku=?,
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=?
WHERE id=? AND deleted_at IS NULL AND status IN ('pending','ready','skipped','failed')`,
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(stock_id,0),COALESCE(detail_id,0),COALESCE(syb_spec,''),COALESCE(syb_sku,''),
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_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.StockID, &row.DetailID,
&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.AppliedAt, &row.DeletedAt, &row.DeletedByUserID, &row.CreatedAt, &row.UpdatedAt)
if err != nil {
@@ -654,6 +655,21 @@ func FinishInnerCodeApply(q Execer, id int64, status model.InnerCodeStatus, mess
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 读取一条需核对记录。
func GetInnerCodeForRecheck(q Execer, id int64) (*model.InnerCodeRecord, error) {
record, err := scanInnerCodeRecord(q.QueryRow(`SELECT `+innerCodeListColumns+
@@ -705,11 +721,11 @@ func SoftDeleteInnerCodeRecords(db *sql.DB, ids []int64, actorUserID, deletedAt
}
// 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
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=?
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 {
return fmt.Errorf("保存档口入库码核对结果失败: %w", err)
}
+43 -2
View File
@@ -20,7 +20,7 @@ import (
"cmautobuy/admin/spec"
)
const mysqlSchemaVersion = 26
const mysqlSchemaVersion = 27
// OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。
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)
}
}
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)
}
// 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 建立采购运行时规格解析审计。一个记录同时保存候选观察和最终决策,
// 不覆盖 PDD 商品主数据,也不通过外键阻止任务的既有硬删除流程。
func migrateMySQLV26(db *sql.DB) error {
@@ -2320,7 +2345,23 @@ func CheckMySQLSchema(db *sql.DB) error {
if err := checkMySQLV25Shape(db); err != nil {
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 {
+241 -51
View File
@@ -5,6 +5,7 @@ import (
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
@@ -19,6 +20,7 @@ import (
type InnerCodeWriter interface {
InnerCodeDetailReader
DeleteInnerCode(context.Context, int64) error
CreateInnerCodeDetail(context.Context, int64, string) (int64, error)
UpdateDetailCode(context.Context, int64, int64, string) error
}
@@ -46,6 +48,16 @@ type innerCodeApplyOutcome struct {
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 把任意数量的已选记录原子排队,远端请求由后台执行器处理。
func QueueInnerCodeApplyBatch(db *sql.DB, ids []int64, actorUserID string) (*InnerCodeApplyBatch, error) {
ids = uniquePositiveInnerCodeIDs(ids)
@@ -95,7 +107,14 @@ func RunInnerCodeApplyBatch(ctx context.Context, db *sql.DB, writer InnerCodeWri
result.Skipped++
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()
if err := repository.FinishInnerCodeApply(db, id, outcome.Status, compactInnerCodeMessage(outcome.Message), outcome.RemoteCode, finishedAt); err != nil {
return result, err
@@ -138,80 +157,121 @@ func newInnerCodeApplyBatchID() (string, error) {
return "ICB-" + time.Now().UTC().Format("20060102T150405") + "-" + hex.EncodeToString(random), nil
}
func applyClaimedInnerCode(ctx context.Context, writer InnerCodeWriter, record model.InnerCodeRecord) innerCodeApplyOutcome {
item, err := readCurrentInnerCodeDetail(ctx, writer, record)
func applyClaimedInnerCode(ctx context.Context, writer InnerCodeWriter, record model.InnerCodeRecord, checkpoint innerCodeCheckpoint) innerCodeApplyOutcome {
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 {
return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck,
Message: "写入前重新读取失败,没有发送删除或写入请求:" + err.Error(), RemoteCode: record.RemoteInnerCode}
Message: "写入前重新读取失败,没有发送远端写请求:" + err.Error(), RemoteCode: record.RemoteInnerCode}
}
currentCode := innerCodeRawText(item.Raw["innerExpCode"])
if !innerCodeDetailIdentityMatches(record, *item) {
if original.ProductQty != len(codes) {
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,
Message: "顺运宝商品规格或档口身份已变化,停止回写,请重新匹配", RemoteCode: currentCode}
Message: "顺运宝商品规格或档口身份已变化,停止回写,请重新匹配", RemoteCode: innerCodeRawText(original.Raw["innerExpCode"])}
}
platform := innerCodeRawText(item.Raw["purchasePlatform"])
purchaseCode := innerCodeRawText(item.Raw["purchaseCode"])
if platform != "" || purchaseCode != "" {
if innerCodeRawText(original.Raw["purchasePlatform"]) != "" || innerCodeRawText(original.Raw["purchaseCode"]) != "" {
return innerCodeApplyOutcome{Status: model.InnerCodeSkipped,
Message: "顺运宝商品已有采购平台或采购单号,停止回写", RemoteCode: currentCode}
Message: "顺运宝商品已有采购平台或采购单号,停止回写", RemoteCode: innerCodeRawText(original.Raw["innerExpCode"])}
}
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, ",")}
}
if currentCode == record.InnerCode {
return innerCodeApplyOutcome{Status: model.InnerCodeAlreadyFilled,
Message: "写入前核验发现远端已是目标入库码,无需重复写入", RemoteCode: currentCode}
Message: fmt.Sprintf("远端已存在全部 %d 个单件入库码,无需重复写入", len(codes)), RemoteCode: strings.Join(codes, ",")}
}
currentCode := innerCodeRawText(original.Raw["innerExpCode"])
if currentCode != "" && !containsInnerCode(codes, currentCode) {
if currentCode != record.RemoteInnerCode {
return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck,
Message: "远端快递单号在规划后发生变化,已停止回写,请人工核对", RemoteCode: currentCode}
Message: "原商品快递单号在规划后发生变化,已停止回写,请人工核对", RemoteCode: currentCode}
}
if err := checkpoint(items, "准备清除原商品上的旧快递单号"); err != nil {
return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck, Message: err.Error(), 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}
return withInnerCodeProgress(unknownOrFailedInnerCodeOutcome(err, "删除旧快递单号", currentCode), items)
}
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 = "写入结果未知,禁止自动重试,请重新核对"
for _, codeIndex := range missing {
item := &items[codeIndex]
if item.DetailID == 0 && currentCode == "" {
item.DetailID = record.DetailID
item.Source = "original"
currentCode = "reserved"
}
return innerCodeApplyOutcome{Status: status, Message: message, RemoteCode: currentCode}
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}
}
verified, err := readCurrentInnerCodeDetail(ctx, writer, record)
detailID, err := writer.CreateInnerCodeDetail(ctx, record.StockID, item.Title)
if err != nil {
return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck,
Message: "写入请求已成功响应,但重新读取失败,请核对远端结果", RemoteCode: currentCode}
return withInnerCodeProgress(unknownOrFailedInnerCodeOutcome(err, fmt.Sprintf("创建第 %d 件明细", codeIndex+1), record.RemoteInnerCode), items)
}
verifiedCode := innerCodeRawText(verified.Raw["innerExpCode"])
if !innerCodeDetailIdentityMatches(record, *verified) {
return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck,
Message: "写入后商品规格或档口身份发生变化,请人工核对", RemoteCode: verifiedCode}
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}
}
if verifiedCode != record.InnerCode {
return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck,
Message: "写入后远端值与目标入库码不一致,禁止自动重试,请人工核对", RemoteCode: verifiedCode}
}
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) {
_, 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 {
return nil, fmt.Errorf("记录缺少有效的货运单或商品明细 ID")
return syb.StockDetail{}, nil, fmt.Errorf("记录缺少有效的货运单或商品明细 ID")
}
stocks, err := reader.DetailListByStock(ctx, []int64{record.StockID})
if err != nil {
return nil, err
return syb.StockDetail{}, nil, err
}
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
for index := range stocks[0].Details {
@@ -219,15 +279,134 @@ func readCurrentInnerCodeDetail(ctx context.Context, reader InnerCodeDetailReade
continue
}
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]
found = &item
}
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 记录,不发送任何写请求。
@@ -242,24 +421,35 @@ func RecheckInnerCode(ctx context.Context, db *sql.DB, reader InnerCodeDetailRea
if record.Status != model.InnerCodeNeedsCheck {
return record.Status, "当前记录不需要核对", nil
}
item, readErr := readCurrentInnerCodeDetail(ctx, reader, *record)
stock, item, readErr := readCurrentInnerCodeStock(ctx, reader, *record)
status := model.InnerCodeNeedsCheck
remoteCode := record.RemoteInnerCode
remoteItemsJSON := record.RemoteItemsJSON
message := "重新读取失败,仍需人工核对:" + errorText(readErr)
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"])
if !innerCodeDetailIdentityMatches(*record, *item) {
message = "重新读取到的商品身份与规划不一致;保持需核对,系统没有写入"
} else if remoteCode == record.InnerCode {
} else if splitErr != nil || planErr != nil {
message = "重新读取到的逐件入库码存在冲突;保持需核对,系统没有写入"
} else if len(missing) == 0 {
status = model.InnerCodeUpdated
message = "重新读取确认远端已是目标入库码;没有重复写入"
remoteCode = strings.Join(codes, ",")
message = fmt.Sprintf("重新读取确认远端已存在全部 %d 个单件入库码;没有重复写入", len(codes))
} else {
message = "重新读取后远端仍不是目标入库码;保持需核对,系统没有写入"
message = fmt.Sprintf("重新读取后仍缺少 %d 个单件入库码;保持需核对,系统没有写入", len(missing))
}
}
checkedAt := model.NowISO()
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 status, message, nil
+103 -14
View File
@@ -16,11 +16,14 @@ type fakeInnerCodeWriter struct {
stock syb.StockDetail
readErr error
deleteErr error
createErr error
updateErr error
readCount int
deleteCount int
createCount int
updateCount int
updatedCode string
nextDetailID int64
}
func TestQueueInnerCodeApplyBatch_超过20条可一次排队(t *testing.T) {
@@ -81,6 +84,20 @@ func (f *fakeInnerCodeWriter) DeleteInnerCode(_ context.Context, detailID int64)
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 {
f.updateCount++
f.updatedCode = code
@@ -95,21 +112,93 @@ func (f *fakeInnerCodeWriter) UpdateDetailCode(_ context.Context, _, detailID in
return nil
}
func TestApplyClaimedInnerCode_聚合码只写一次并按完整值复读(t *testing.T) {
func TestApplyClaimedInnerCode_两个单件码写入两个明细(t *testing.T) {
record := innerCodeApplyTestRecord("")
record.InnerCode = "DK260815A160101,DK260815A160102"
record.SourceDuplicateCount = 2
writer := innerCodeApplyTestWriter(record, "")
outcome := applyClaimedInnerCode(context.Background(), writer, record)
if outcome.Status != model.InnerCodeUpdated || writer.updateCount != 1 || writer.updatedCode != record.InnerCode ||
outcome.RemoteCode != record.InnerCode || writer.readCount != 2 {
t.Fatalf("outcome=%+v update=%d code=%q read=%d", outcome, writer.updateCount, writer.updatedCode, writer.readCount)
outcome := applyClaimedInnerCode(context.Background(), writer, record, nil)
if outcome.Status != model.InnerCodeUpdated || writer.updateCount != 2 || writer.createCount != 1 ||
outcome.RemoteCode != record.InnerCode || writer.readCount != 3 {
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) {
record := innerCodeApplyTestRecord("")
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 {
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) {
record := innerCodeApplyTestRecord("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 {
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) {
record := innerCodeApplyTestRecord("OLD")
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 {
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")
writer := innerCodeApplyTestWriter(record, "OLD")
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 {
t.Fatalf("outcome=%+v delete=%d update=%d", outcome, writer.deleteCount, writer.updateCount)
}
@@ -147,7 +236,7 @@ func TestApplyClaimedInnerCode_写入未知结果不复读也不重试(t *testin
record := innerCodeApplyTestRecord("")
writer := innerCodeApplyTestWriter(record, "")
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 {
t.Fatalf("outcome=%+v update=%d read=%d", outcome, writer.updateCount, writer.readCount)
}
@@ -157,7 +246,7 @@ func TestApplyClaimedInnerCode_采购字段出现后停止(t *testing.T) {
record := innerCodeApplyTestRecord("")
writer := innerCodeApplyTestWriter(record, "")
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 {
t.Fatalf("outcome=%+v update=%d", outcome, writer.updateCount)
}
@@ -168,7 +257,7 @@ func TestApplyClaimedInnerCode_档口身份变化后停止(t *testing.T) {
record.SybSKU = "A#1"
writer := innerCodeApplyTestWriter(record, "")
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 {
t.Fatalf("outcome=%+v update=%d", outcome, writer.updateCount)
}
@@ -176,11 +265,11 @@ func TestApplyClaimedInnerCode_档口身份变化后停止(t *testing.T) {
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}
InnerCode: "DK-001", SourceDuplicateCount: 1, 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{
item := syb.DetailItem{ID: record.DetailID, ProductSpec: record.SybSpec, ProductQty: record.SourceDuplicateCount, Raw: map[string]any{
"id": record.DetailID, "productSpec": record.SybSpec, "innerExpCode": currentCode,
"purchasePlatform": "", "purchaseCode": "",
}}
+17 -2
View File
@@ -277,6 +277,17 @@ func planInnerCodeRowsWithReserved(records []model.InnerCodeRecord, stockIDsByOr
continue
}
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
plan.StockID = stockID
plan.DetailID = matched.ID
@@ -286,9 +297,13 @@ func planInnerCodeRowsWithReserved(records []model.InnerCodeRecord, stockIDsByOr
plan.PurchasePlatform = innerCodeRawText(matched.Raw["purchasePlatform"])
plan.PurchaseCode = innerCodeRawText(matched.Raw["purchaseCode"])
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.ResultMessage = "远端已是相同入库码,无需重复写入"
plan.ResultMessage = fmt.Sprintf("远端已存在全部 %d 个单件入库码,无需重复写入", len(codes))
} else {
plan.Status = model.InnerCodeReady
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{
{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: 3, OrderNumber: "PURCHASED", SpecRaw: "黑色,M", InnerCode: "DK3", 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}}
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)}},
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)}},
@@ -275,5 +275,5 @@ func innerCodeTestItem(id int64, spec string, raw map[string]any) syb.DetailItem
}
raw["id"] = id
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
CanApply bool
HasRemoteOldCode bool
CodeCount int
ExpectedAddCount int
CanRecheck bool
}
@@ -133,6 +135,10 @@ func innerCodeRowView(record model.InnerCodeRecord) InnerCodeRowView {
record.Status == model.InnerCodeSkipped ||
record.Status == model.InnerCodeFailed
canApply := record.Status == model.InnerCodeReady
codeCount := record.SourceDuplicateCount
if codeCount < 1 {
codeCount = 1
}
return InnerCodeRowView{
ID: record.ID, BusinessDate: record.BusinessDate, OrderNumber: record.OrderNumber,
Stall: displayInnerCodeValue(record.Stall), SpecRaw: displayInnerCodeValue(record.SpecRaw),
@@ -143,6 +149,8 @@ func innerCodeRowView(record model.InnerCodeRecord) InnerCodeRowView {
CanMatch: canMatch,
CanApply: canApply,
HasRemoteOldCode: canApply && record.RemoteInnerCode != "",
CodeCount: codeCount,
ExpectedAddCount: max(codeCount-1, 0),
CanRecheck: record.Status == model.InnerCodeNeedsCheck,
}
}
+10
View File
@@ -435,12 +435,22 @@
var replacements = applySelected.filter(function (box) {
return box.getAttribute("data-inner-code-old-value") === "1";
}).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]")
.forEach(function (node) { node.textContent = matchSelected.length; });
document.querySelectorAll("[data-inner-code-selected-count], [data-inner-code-confirm-count]")
.forEach(function (node) { node.textContent = applySelected.length; });
document.querySelectorAll("[data-inner-code-replace-count]")
.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]")
.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)
}
// 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。
// 请求只发送一次;结果未知时返回 ErrWriteResultUnknown,调用方不得重试。
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 不能包含控制字符")
}
}
_, err := c.do(ctx, http.MethodGet, "/am/stock/detail/updateDetailCode", url.Values{
"t": {"0"}, "id": {strconv.FormatInt(stockID, 10)},
_, err := c.do(ctx, http.MethodPost, "/am/stock/detail/updateDetailCode", url.Values{
"t": {strconv.FormatInt(time.Now().UnixMilli(), 10)}, "id": {strconv.FormatInt(stockID, 10)},
"detailId": {strconv.FormatInt(detailID, 10)}, "code": {code},
}, nil)
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) {
targetCode := "DK260815A160101,DK260815A160102"
func TestClient_InnerCodeWrite_单件码参数和路径正确且只发送一次(t *testing.T) {
targetCode := "DK260815A160101"
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
@@ -582,7 +582,7 @@ func TestClient_InnerCodeWrite_聚合码参数和路径正确且只发送一次(
}
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") != 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)
}
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) {
for _, tc := range []struct {
name string
+8 -3
View File
@@ -129,6 +129,8 @@
<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-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}}">
</td>
<td>{{.BusinessDate}}</td>
@@ -178,11 +180,14 @@
<button type="button" class="modal-x" data-modal-close aria-label="关闭">×</button>
</div>
<div class="modal-body">
<p class="confirm-box">将回写 <strong data-inner-code-confirm-count>0</strong> 条记录,其中
<strong data-inner-code-replace-count>0</strong> 条有旧快递单号,需要先删除旧值再写入。</p>
<p class="confirm-box">将回写 <strong data-inner-code-confirm-count>0</strong> 条记录,共
<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>
<li>每条写入前会重新读取顺运宝详情并核验商品。</li>
<li>写入后会再次读取;只有远端值一致才标记“已回写”。</li>
<li>每个入库码只写入一个商品明细;多件商品会逐件创建零价占位明细。</li>
<li>每件写入后都会再次读取;全部单件码一致才标记“已回写”。</li>
<li>提交后页面立即返回;后台每次读取最多 20 条,但仍会逐条安全回写。</li>
<li class="missing">超时或结果不确定时只标记“需核对”,不会自动重复写入。</li>
</ul>
+7 -1
View File
@@ -129,6 +129,8 @@ v19(#212)将产品概念收敛为唯一店铺名称和唯一启停状态。
则保持停用”的安全规则合并 v18 状态,重建由 `shops` 派生的兼容数据和两侧关联。
v26(#254)新增 `purchase_spec_resolutions`,用一张表保存采购执行中的候选观察、
幂等身份和最终规格决策;不修改历史 SQLite migration,也不覆盖 PDD 商品主数据。
v27(#250)为 `syb_inner_code_records` 增加可空 JSON 字段 `remote_items_json`,保存
多件商品逐个入库码对应的远端明细、来源和动作状态;仍不拆分新的业务表。
**v3 为什么丢弃旧 `sku_mappings` 数据(见 #20):** 新主键需要 `pdd_option_key`,
这是 Go 的 `service.OptionKey()` 用 `json.Marshal` 算出来的规范化键,SQL 语句
@@ -1025,7 +1027,7 @@ CREATE UNIQUE INDEX idx_client_assignment_current
`ai_spec_match_decisions`。Admin 重启把未完成明细改为 `interrupted` 并完成批次计数,已经
成功写入的 `spec_mappings` 不回滚。
## 17. `syb_inner_code_records` 档口入库码记录(MySQL v23,软删除 v24,后台回写 v25)
## 17. `syb_inner_code_records` 档口入库码记录(MySQL v23,软删除 v24,后台回写 v25,逐件检查点 v27)
档口入库码使用一张业务表完成导入、匹配、回写和异常恢复。Excel 只是导入载体,系统
不保存原文件、文件哈希,不再拆批次表或尝试记录表。
@@ -1055,6 +1057,10 @@ CREATE UNIQUE INDEX idx_client_assignment_current
不新增批次表:提交时所选 `ready` 记录必须在同一事务内全部转为 `queued`;后台每次最多
读取 20 条并逐条领取为 `applying`。页面按 `apply_batch_id` 聚合当前批次进度。Admin
重启时,`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)
+16 -7
View File
@@ -410,21 +410,30 @@ SKU(平均每商品 1.17 个),**查无此 SKU 是常态**,不是异常
`[建议]` 登录后**只调 `/am/user/get` 验证会话**,其余几个是网页自己的初始化请求,
Go 侧不用跟着调。
### 7.1 档口入库码写入(工单 #234)
### 7.1 档口入库码逐件写入(工单 #234、#250)
档口入库码最终写入货运明细的 `innerExpCode`(页面名称“快递单号”)。两个写接口来自
现有 Python 流程和线上响应样本:
档口入库码最终写入货运明细的 `innerExpCode`(页面名称“快递单号”)。接口来自
现有 Python 流程和 HAR 响应样本:
```text
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、响应读取失败或成功响应
无法解析时,结果可能已经在远端生效,必须标为“需核对”。
- 每条写入前重新调用 `listByStock`,核对 stock/detail、规格、采购平台、采购单号和规划时
的旧值;写入后再次读取,只有 `innerExpCode` 与目标一致才标为已回写。
- 每件动作前后把检查点写入 `remote_items_json`;每个单件码写入后重新读取整张货运单,
只有它唯一出现在预期明细中才继续。全部目标码确认后才标为已回写。
- “重新核对”只调用 `listByStock`,绝不能再次调用上述两个写接口。
- 页面提交数量不限制为 20 条。所选记录先在 MySQL 同一业务表中进入 `queued`,HTTP 请求
随即返回;后台每次最多读取 20 条,再逐条执行上述远端门禁。Admin 重启不会自动继续