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 {
+248 -58
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"])}
}
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,
Message: "写入前核验发现远端已是目标入库码,无需重复写入", RemoteCode: currentCode}
Message: fmt.Sprintf("远端已存在全部 %d 个单件入库码,无需重复写入", len(codes)), RemoteCode: strings.Join(codes, ",")}
}
if currentCode != record.RemoteInnerCode {
return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck,
Message: "远端快递单号在规划后发生变化,已停止回写,请人工核对", RemoteCode: currentCode}
}
if currentCode != "" {
currentCode := innerCodeRawText(original.Raw["innerExpCode"])
if currentCode != "" && !containsInnerCode(codes, currentCode) {
if currentCode != record.RemoteInnerCode {
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 {
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"
}
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,
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
+111 -22
View File
@@ -13,14 +13,17 @@ import (
)
type fakeInnerCodeWriter struct {
stock syb.StockDetail
readErr error
deleteErr error
updateErr error
readCount int
deleteCount int
updateCount int
updatedCode string
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>