fix: 兼容相同顺运宝明细逐件回写 (#289)

This commit is contained in:
chengma
2026-08-22 11:46:23 +08:00
parent de10fb6cf4
commit 18ad85d3cb
7 changed files with 460 additions and 32 deletions
+2 -2
View File
@@ -304,13 +304,13 @@ 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=?,
remote_items_json=NULL,result_message=?,planned_at=?,apply_batch_id=NULL,apply_queued_at=NULL,
remote_items_json=?,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),
nullableString(plan.SybSpec), nullableString(plan.SybSKU), nullableString(plan.SybVariationSKU),
nullableString(plan.PurchasePlatform), nullableString(plan.PurchaseCode),
nullableString(plan.RemoteInnerCode), plan.Status, nullableString(plan.ResultMessage),
nullableString(plan.RemoteInnerCode), plan.Status, nullableString(plan.RemoteItemsJSON), nullableString(plan.ResultMessage),
plannedAt, plannedAt, plan.ID)
if err != nil {
return fmt.Errorf("保存档口入库码记录 %d 规划失败: %w", plan.ID, err)
+138 -13
View File
@@ -168,19 +168,35 @@ func applyClaimedInnerCode(ctx context.Context, writer InnerCodeWriter, record m
return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck,
Message: "写入前重新读取失败,没有发送远端写请求:" + err.Error(), RemoteCode: record.RemoteInnerCode}
}
if original.ProductQty != len(codes) {
plannedExisting, hasExistingPlan, plannedErr := parseExistingMatchedInnerCodePlan(record, codes)
if plannedErr != nil {
return innerCodeApplyOutcome{Status: model.InnerCodeFailed,
Message: fmt.Sprintf("顺运宝商品数量为 %d,单件入库码为 %d 个,数量不一致;没有发送远端写请求", original.ProductQty, len(codes)), RemoteCode: innerCodeRawText(original.Raw["innerExpCode"])}
Message: "现成多明细逐件规划无效,没有发送远端写请求:" + plannedErr.Error(), RemoteCode: record.RemoteInnerCode}
}
if !innerCodeDetailIdentityMatches(record, *original) {
return innerCodeApplyOutcome{Status: model.InnerCodeSkipped,
Message: "顺运宝商品规格或档口身份已变化,停止回写,请重新匹配", RemoteCode: innerCodeRawText(original.Raw["innerExpCode"])}
var items []innerCodeRemoteItem
var missing []int
var prepareErr error
if hasExistingPlan {
items, missing, prepareErr = reconcileExistingMatchedInnerCodePlan(record, stock, plannedExisting)
if prepareErr != nil {
return innerCodeApplyOutcome{Status: model.InnerCodeSkipped,
Message: "现成多明细规划在写入前已变化,停止回写,请重新匹配:" + prepareErr.Error(), RemoteCode: record.RemoteInnerCode}
}
} else {
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: innerCodeRawText(original.Raw["innerExpCode"])}
}
if innerCodeRawText(original.Raw["purchasePlatform"]) != "" || innerCodeRawText(original.Raw["purchaseCode"]) != "" {
return innerCodeApplyOutcome{Status: model.InnerCodeSkipped,
Message: "顺运宝商品已有采购平台或采购单号,停止回写", RemoteCode: innerCodeRawText(original.Raw["innerExpCode"])}
}
items, missing, prepareErr = planInnerCodeRemoteItems(record, stock, codes)
}
if innerCodeRawText(original.Raw["purchasePlatform"]) != "" || innerCodeRawText(original.Raw["purchaseCode"]) != "" {
return innerCodeApplyOutcome{Status: model.InnerCodeSkipped,
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}
}
@@ -196,7 +212,7 @@ func applyClaimedInnerCode(ctx context.Context, writer InnerCodeWriter, record m
}
currentCode := innerCodeRawText(original.Raw["innerExpCode"])
if currentCode != "" && !containsInnerCode(codes, currentCode) {
if !hasExistingPlan && currentCode != "" && !containsInnerCode(codes, currentCode) {
if currentCode != record.RemoteInnerCode {
return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck,
Message: "原商品快递单号在规划后发生变化,已停止回写,请人工核对", RemoteCode: currentCode}
@@ -212,6 +228,10 @@ func applyClaimedInnerCode(ctx context.Context, writer InnerCodeWriter, record m
for _, codeIndex := range missing {
item := &items[codeIndex]
if hasExistingPlan && item.DetailID <= 0 {
return withInnerCodeProgress(innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck,
Message: fmt.Sprintf("第 %d 件现成明细绑定丢失,停止回写", codeIndex+1), RemoteCode: record.RemoteInnerCode}, items)
}
if item.DetailID == 0 && currentCode == "" {
item.DetailID = record.DetailID
item.Source = "original"
@@ -245,7 +265,14 @@ func applyClaimedInnerCode(ctx context.Context, writer InnerCodeWriter, record m
if err != nil {
return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck, Message: "写入成功响应后重新读取失败,禁止自动重试", RemoteCode: record.RemoteInnerCode}
}
if countInnerCodeInStock(verifiedStock, item.Code) != 1 || innerCodeDetailCode(verifiedStock, item.DetailID) != item.Code {
if hasExistingPlan {
verifiedItems, _, verifyErr := reconcileExistingMatchedInnerCodePlan(record, verifiedStock, items)
if verifyErr != nil || verifiedItems[codeIndex].Status != "confirmed" {
return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck, Message: "写入后现成多明细绑定或单件入库码核验失败,禁止自动重试", RemoteCode: record.RemoteInnerCode}
}
items = verifiedItems
item = &items[codeIndex]
} else if countInnerCodeInStock(verifiedStock, item.Code) != 1 || innerCodeDetailCode(verifiedStock, item.DetailID) != item.Code {
return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck, Message: "写入后单件入库码未唯一出现在预期明细,禁止自动重试", RemoteCode: record.RemoteInnerCode}
}
item.Status = "confirmed"
@@ -356,6 +383,94 @@ func planInnerCodeRemoteItems(record model.InnerCodeRecord, stock syb.StockDetai
return items, missing, nil
}
// parseExistingMatchedInnerCodePlan 识别匹配阶段保存的“现成多明细逐件绑定”。
// 普通 #250 检查点没有 existing_matched 来源,继续走原有创建/复用路径。
func parseExistingMatchedInnerCodePlan(record model.InnerCodeRecord, codes []string) ([]innerCodeRemoteItem, bool, error) {
raw := strings.TrimSpace(record.RemoteItemsJSON)
if raw == "" {
return nil, false, nil
}
var items []innerCodeRemoteItem
if err := json.Unmarshal([]byte(raw), &items); err != nil {
return nil, false, fmt.Errorf("逐件规划 JSON 无法解析")
}
hasExistingMatched := false
for _, item := range items {
if item.Source == innerCodeRemoteSourceExistingMatched {
hasExistingMatched = true
break
}
}
if !hasExistingMatched {
return nil, false, nil
}
if len(items) != len(codes) || len(items) == 0 {
return nil, false, fmt.Errorf("逐件绑定数量与单件入库码数量不一致")
}
seenDetailIDs := make(map[int64]bool, len(items))
for index, item := range items {
if item.Source != innerCodeRemoteSourceExistingMatched || item.Code != codes[index] || item.DetailID <= 0 {
return nil, false, fmt.Errorf("第 %d 件逐件绑定身份无效", index+1)
}
if seenDetailIDs[item.DetailID] {
return nil, false, fmt.Errorf("商品明细 id=%d 在逐件绑定中重复", item.DetailID)
}
seenDetailIDs[item.DetailID] = true
}
if record.DetailID != items[0].DetailID {
return nil, false, fmt.Errorf("主商品明细与第一件逐件绑定不一致")
}
return items, true, nil
}
// reconcileExistingMatchedInnerCodePlan 用远端最新整单状态复核全部现成明细。
// 任一明细身份、数量、采购状态或快递单号不符合规划时,调用方必须停止写入。
func reconcileExistingMatchedInnerCodePlan(record model.InnerCodeRecord, stock syb.StockDetail, planned []innerCodeRemoteItem) ([]innerCodeRemoteItem, []int, error) {
details := make(map[int64]syb.DetailItem, len(stock.Details))
for _, detail := range stock.Details {
if _, duplicate := details[detail.ID]; duplicate {
return nil, nil, fmt.Errorf("顺运宝重复返回商品明细 id=%d", detail.ID)
}
details[detail.ID] = detail
}
items := append([]innerCodeRemoteItem(nil), planned...)
missing := make([]int, 0, len(items))
for index := range items {
item := &items[index]
detail, ok := details[item.DetailID]
if !ok {
return nil, nil, fmt.Errorf("顺运宝未返回已规划商品明细 id=%d", item.DetailID)
}
if detail.ProductQty != 1 {
return nil, nil, fmt.Errorf("商品明细 id=%d 数量已变为 %d", detail.ID, detail.ProductQty)
}
if !innerCodeDetailIdentityMatches(record, detail) {
return nil, nil, fmt.Errorf("商品明细 id=%d 的规格或 SKU 身份已变化", detail.ID)
}
if innerCodeRawText(detail.Raw["purchasePlatform"]) != "" || innerCodeRawText(detail.Raw["purchaseCode"]) != "" {
return nil, nil, fmt.Errorf("商品明细 id=%d 已有采购平台或采购单号", detail.ID)
}
remoteCode := innerCodeRawText(detail.Raw["innerExpCode"])
if remoteCode != "" && remoteCode != item.Code {
return nil, nil, fmt.Errorf("商品明细 id=%d 已有非目标快递单号", detail.ID)
}
count := countInnerCodeInStock(stock, item.Code)
if remoteCode == item.Code {
if count != 1 {
return nil, nil, fmt.Errorf("单件入库码 %s 在货运单中不是唯一值", item.Code)
}
item.Status = "confirmed"
continue
}
if count != 0 {
return nil, nil, fmt.Errorf("单件入库码 %s 出现在非规划明细", item.Code)
}
item.Status = "planned"
missing = append(missing, index)
}
return items, missing, nil
}
func innerCodePlaceholderTitle(recordID int64, ordinal int) string {
return fmt.Sprintf("档口第%d件-IC%d", ordinal, recordID)
}
@@ -428,7 +543,17 @@ func RecheckInnerCode(ctx context.Context, db *sql.DB, reader InnerCodeDetailRea
message := "重新读取失败,仍需人工核对:" + errorText(readErr)
if readErr == nil {
codes, splitErr := splitInnerCodes(record.InnerCode)
items, missing, planErr := planInnerCodeRemoteItems(*record, stock, codes)
plannedExisting, hasExistingPlan, parseErr := parseExistingMatchedInnerCodePlan(*record, codes)
var items []innerCodeRemoteItem
var missing []int
var planErr error
if parseErr != nil {
planErr = parseErr
} else if hasExistingPlan {
items, missing, planErr = reconcileExistingMatchedInnerCodePlan(*record, stock, plannedExisting)
} else {
items, missing, planErr = planInnerCodeRemoteItems(*record, stock, codes)
}
if splitErr == nil && planErr == nil {
if raw, err := json.Marshal(items); err == nil {
remoteItemsJSON = string(raw)
+52
View File
@@ -124,6 +124,58 @@ func TestApplyClaimedInnerCode_两个单件码写入两个明细(t *testing.T) {
}
}
func TestApplyClaimedInnerCode_两个单件码写入两条现成相同明细(t *testing.T) {
record := innerCodeApplyTestRecord("")
record.InnerCode = "DK-001,DK-002"
record.SourceDuplicateCount = 2
record.SybSKU = "A档#1-黑色-M"
record.SybVariationSKU = "A档#1"
record.RemoteItemsJSON = `[{"code":"DK-001","detail_id":20,"source":"existing_matched","status":"planned"},{"code":"DK-002","detail_id":21,"source":"existing_matched","status":"planned"}]`
writer := innerCodeApplyTestWriter(record, "")
writer.stock.Details[0].ProductQty = 1
writer.stock.Details[0].Raw["sku"] = record.SybSKU
writer.stock.Details[0].Raw["variationSku"] = record.SybVariationSKU
writer.stock.Details = append(writer.stock.Details, syb.DetailItem{
ID: 21, ProductSpec: record.SybSpec, ProductQty: 1,
Raw: map[string]any{"sku": record.SybSKU, "variationSku": record.SybVariationSKU,
"innerExpCode": "", "purchasePlatform": "", "purchaseCode": ""},
})
outcome := applyClaimedInnerCode(context.Background(), writer, record, nil)
if outcome.Status != model.InnerCodeUpdated || writer.updateCount != 2 || writer.createCount != 0 ||
writer.deleteCount != 0 || writer.readCount != 3 {
t.Fatalf("outcome=%+v update=%d create=%d delete=%d read=%d",
outcome, writer.updateCount, writer.createCount, writer.deleteCount, writer.readCount)
}
if got := innerCodeDetailCode(writer.stock, 20); got != "DK-001" {
t.Fatalf("第一条现成明细写码错误: %q", got)
}
if got := innerCodeDetailCode(writer.stock, 21); got != "DK-002" {
t.Fatalf("第二条现成明细写码错误: %q", got)
}
}
func TestApplyClaimedInnerCode_现成多明细身份变化时零写入(t *testing.T) {
record := innerCodeApplyTestRecord("")
record.InnerCode = "DK-001,DK-002"
record.SourceDuplicateCount = 2
record.SybSKU = "A档#1-黑色-M"
record.RemoteItemsJSON = `[{"code":"DK-001","detail_id":20,"source":"existing_matched","status":"planned"},{"code":"DK-002","detail_id":21,"source":"existing_matched","status":"planned"}]`
writer := innerCodeApplyTestWriter(record, "")
writer.stock.Details[0].ProductQty = 1
writer.stock.Details[0].Raw["sku"] = record.SybSKU
writer.stock.Details = append(writer.stock.Details, syb.DetailItem{
ID: 21, ProductSpec: record.SybSpec, ProductQty: 1,
Raw: map[string]any{"sku": "B档#2", "innerExpCode": "", "purchasePlatform": "", "purchaseCode": ""},
})
outcome := applyClaimedInnerCode(context.Background(), writer, record, nil)
if outcome.Status != model.InnerCodeSkipped || 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,DK-003"
+148 -16
View File
@@ -224,6 +224,7 @@ func planInnerCodeRowsWithReserved(records []model.InnerCodeRecord, stockIDsByOr
plan.StockID, plan.DetailID = 0, 0
plan.SybSpec, plan.SybSKU, plan.SybVariationSKU = "", "", ""
plan.PurchasePlatform, plan.PurchaseCode, plan.RemoteInnerCode = "", "", ""
plan.RemoteItemsJSON = ""
if strings.TrimSpace(record.SpecRaw) == "" {
plans = append(plans, innerCodeSkippedPlan(plan, "Excel 清洗后规格为空,跳过"))
continue
@@ -263,9 +264,48 @@ func planInnerCodeRowsWithReserved(records []model.InnerCodeRecord, stockIDsByOr
plans = append(plans, innerCodeSkippedPlan(plan, message))
continue
}
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
}
specMatches := matchInnerCodeSpecItems(record.SpecRaw, eligible)
sourceMatches := matchInnerCodeSourceSKUItems(record.SourceSKURaw, specMatches)
matches, evidenceReason := matchInnerCodeItemsWithEvidence(record.Stall, record.SourceSKURaw, specMatches)
if evidenceReason != "" {
if evidenceReason == innerCodeSourceSKUDuplicateReason {
items, primary, multipleErr := planExistingMatchedInnerCodeItems(record, codes, sourceMatches)
if multipleErr == nil {
raw, marshalErr := json.Marshal(items)
if marshalErr != nil {
plans = append(plans, innerCodeSkippedPlan(plan, "保存多明细逐件规划失败"))
continue
}
for _, item := range items {
used[record.OrderNumber][item.DetailID] = true
}
plan.StockID = stockID
plan.DetailID = primary.ID
plan.SybSpec = primary.ProductSpec
plan.SybSKU = innerCodeRawText(primary.Raw["sku"])
plan.SybVariationSKU = innerCodeRawText(primary.Raw["variationSku"])
plan.RemoteInnerCode = innerCodeRawText(primary.Raw["innerExpCode"])
plan.RemoteItemsJSON = string(raw)
if allInnerCodeRemoteItemsConfirmed(items) {
plan.Status = model.InnerCodeAlreadyFilled
plan.ResultMessage = fmt.Sprintf("远端已存在全部 %d 个单件入库码,无需重复写入", len(codes))
} else {
plan.Status = model.InnerCodeReady
plan.ResultMessage = "多条现成商品已逐件唯一匹配,等待操作员确认回写"
}
plans = append(plans, plan)
continue
}
if multipleErr != nil {
evidenceReason = multipleErr.Error()
}
}
plans = append(plans, innerCodeSkippedPlan(plan, evidenceReason))
continue
}
@@ -282,12 +322,6 @@ 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))))
@@ -318,6 +352,96 @@ func planInnerCodeRowsWithReserved(records []model.InnerCodeRecord, stockIDsByOr
return plans
}
const (
innerCodeSourceSKUDuplicateReason = "原始 SKU 候选重复,不能自动选择"
innerCodeRemoteSourceExistingMatched = "existing_matched"
)
// planExistingMatchedInnerCodeItems 只接受“多个单件码恰好对应多个现成数量 1 明细”的确定场景。
// 返回项按 Excel 单件码顺序排列,明细候选使用稳定 ID 排序;已经存在的目标码优先保留原绑定。
func planExistingMatchedInnerCodeItems(record model.InnerCodeRecord, codes []string, matches []syb.DetailItem) ([]innerCodeRemoteItem, syb.DetailItem, error) {
if len(codes) < 2 || len(matches) != len(codes) {
return nil, syb.DetailItem{}, fmt.Errorf("原始 SKU 命中 %d 条相同商品明细,但单件入库码为 %d 个,数量不一致", len(matches), len(codes))
}
candidates := append([]syb.DetailItem(nil), matches...)
sort.Slice(candidates, func(i, j int) bool { return candidates[i].ID < candidates[j].ID })
first := candidates[0]
wantSpec := first.ProductSpec
wantSKU := innerCodeRawText(first.Raw["sku"])
wantVariationSKU := innerCodeRawText(first.Raw["variationSku"])
codeIndex := make(map[string]int, len(codes))
items := make([]innerCodeRemoteItem, len(codes))
for index, code := range codes {
codeIndex[code] = index
items[index] = innerCodeRemoteItem{Code: code, Source: innerCodeRemoteSourceExistingMatched, Status: "planned"}
}
blank := make([]syb.DetailItem, 0, len(candidates))
seenDetailIDs := make(map[int64]bool, len(candidates))
for _, candidate := range candidates {
if candidate.ID <= 0 || seenDetailIDs[candidate.ID] {
return nil, syb.DetailItem{}, fmt.Errorf("重复候选包含无效或重复的商品明细 ID,不能自动逐件分配")
}
seenDetailIDs[candidate.ID] = true
if candidate.ProductQty != 1 {
return nil, syb.DetailItem{}, fmt.Errorf("相同候选商品明细 id=%d 的数量为 %d,不能按现成明细逐件分配", candidate.ID, candidate.ProductQty)
}
if candidate.ProductSpec != wantSpec || innerCodeRawText(candidate.Raw["sku"]) != wantSKU ||
innerCodeRawText(candidate.Raw["variationSku"]) != wantVariationSKU {
return nil, syb.DetailItem{}, fmt.Errorf("重复候选的规格或 SKU 身份不一致,不能自动逐件分配")
}
if strings.TrimSpace(record.Stall) != "" && !innerCodeStallMatches(record.Stall, candidate) {
return nil, syb.DetailItem{}, fmt.Errorf("重复候选的档口及货号不一致,不能自动逐件分配")
}
if innerCodeRawText(candidate.Raw["purchasePlatform"]) != "" || innerCodeRawText(candidate.Raw["purchaseCode"]) != "" {
return nil, syb.DetailItem{}, fmt.Errorf("重复候选中存在已有采购信息的商品,不能自动逐件分配")
}
remoteCode := innerCodeRawText(candidate.Raw["innerExpCode"])
if remoteCode == "" {
blank = append(blank, candidate)
continue
}
index, ok := codeIndex[remoteCode]
if !ok {
return nil, syb.DetailItem{}, fmt.Errorf("重复候选中存在非目标快递单号,不能自动逐件分配")
}
if items[index].DetailID != 0 {
return nil, syb.DetailItem{}, fmt.Errorf("单件入库码 %s 在重复候选中出现多次,不能自动逐件分配", remoteCode)
}
items[index].DetailID = candidate.ID
items[index].Status = "confirmed"
}
blankIndex := 0
for index := range items {
if items[index].DetailID != 0 {
continue
}
if blankIndex >= len(blank) {
return nil, syb.DetailItem{}, fmt.Errorf("现成空白明细不足,不能完成逐件分配")
}
items[index].DetailID = blank[blankIndex].ID
blankIndex++
}
if blankIndex != len(blank) {
return nil, syb.DetailItem{}, fmt.Errorf("现成空白明细多于待写入单件码,不能自动逐件分配")
}
primaryID := items[0].DetailID
for _, candidate := range candidates {
if candidate.ID == primaryID {
return items, candidate, nil
}
}
return nil, syb.DetailItem{}, fmt.Errorf("多明细逐件规划缺少主商品明细")
}
func allInnerCodeRemoteItemsConfirmed(items []innerCodeRemoteItem) bool {
for _, item := range items {
if item.Status != "confirmed" {
return false
}
}
return len(items) > 0
}
func innerCodeSkippedPlan(plan model.InnerCodeRecord, message string) model.InnerCodeRecord {
plan.Status = model.InnerCodeSkipped
plan.ResultMessage = message
@@ -350,17 +474,10 @@ func matchInnerCodeSpecItems(spec string, eligible []syb.DetailItem) []syb.Detai
}
func matchInnerCodeItemsWithEvidence(stall, sourceSKURaw string, specMatches []syb.DetailItem) ([]syb.DetailItem, string) {
sourceSKU := normalizeInnerCodeSourceSKU(sourceSKURaw)
if sourceSKU != "" {
sourceMatches := make([]syb.DetailItem, 0)
for _, item := range specMatches {
if normalizeInnerCodeSourceSKU(innerCodeRawText(item.Raw["sku"])) == sourceSKU ||
normalizeInnerCodeSourceSKU(innerCodeRawText(item.Raw["variationSku"])) == sourceSKU {
sourceMatches = append(sourceMatches, item)
}
}
sourceMatches := matchInnerCodeSourceSKUItems(sourceSKURaw, specMatches)
if normalizeInnerCodeSourceSKU(sourceSKURaw) != "" {
if len(sourceMatches) > 1 {
return nil, "原始 SKU 候选重复,不能自动选择"
return nil, innerCodeSourceSKUDuplicateReason
}
if len(sourceMatches) == 1 {
stallMatches := filterInnerCodeItemsByStallStrict(specMatches, stall)
@@ -376,6 +493,21 @@ func matchInnerCodeItemsWithEvidence(stall, sourceSKURaw string, specMatches []s
return filterInnerCodeItemsByStall(specMatches, stall), ""
}
func matchInnerCodeSourceSKUItems(sourceSKURaw string, items []syb.DetailItem) []syb.DetailItem {
sourceSKU := normalizeInnerCodeSourceSKU(sourceSKURaw)
if sourceSKU == "" {
return nil
}
matches := make([]syb.DetailItem, 0)
for _, item := range items {
if normalizeInnerCodeSourceSKU(innerCodeRawText(item.Raw["sku"])) == sourceSKU ||
normalizeInnerCodeSourceSKU(innerCodeRawText(item.Raw["variationSku"])) == sourceSKU {
matches = append(matches, item)
}
}
return matches
}
func filterInnerCodeItemsByStall(items []syb.DetailItem, stall string) []syb.DetailItem {
stall = strings.TrimSpace(stall)
if stall == "" {
+110
View File
@@ -2,8 +2,10 @@ package service
import (
"context"
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"cmautobuy/admin/model"
@@ -164,6 +166,58 @@ func TestPlanInnerCodeRecords_本地没有SybOrder仍可直查远端生成计划
}
}
func TestPlanInnerCodeRecords_持久化现成多明细逐件绑定(t *testing.T) {
db := newTestDB(t)
now := model.NowISO()
user := model.User{UserID: "INNER-CODE-MULTI-USER", Username: "inner-code-multi-user", PasswordHash: "test",
Role: model.RoleAdmin, Status: model.UserActive, PasswordChangedAt: now, CreatedAt: now, UpdatedAt: now}
if err := repository.CreateUser(db, user); err != nil {
t.Fatal(err)
}
record := model.InnerCodeImportRow{
BusinessDate: "2026-08-22", SourceRow: 19, OrderNumber: "ORDER-MULTI-EXISTING",
Stall: "A档#1", SourceSKURaw: "A档#1-黑色-M", SpecRaw: "黑色,M",
SpecKey: NormalizeInnerCodeSpecKey("黑色,M"), InnerCode: "DK-1,DK-2",
SourceDuplicateCount: 2, CreatedByUserID: user.UserID,
}
tx, err := db.Begin()
if err != nil {
t.Fatal(err)
}
if _, err := repository.UpsertInnerCodeImportRow(tx, record, now); err != nil {
tx.Rollback()
t.Fatal(err)
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
var recordID int64
if err := db.QueryRow(`SELECT id FROM syb_inner_code_records WHERE order_number=?`, record.OrderNumber).Scan(&recordID); err != nil {
t.Fatal(err)
}
first := innerCodeTestItem(201, record.SpecRaw, map[string]any{"sku": record.SourceSKURaw, "variationSku": "A档#1", "innerExpCode": ""})
second := innerCodeTestItem(202, record.SpecRaw, map[string]any{"sku": record.SourceSKURaw, "variationSku": "A档#1", "innerExpCode": ""})
first.ProductQty, second.ProductQty = 1, 1
reader := &orderQueryInnerCodeReader{
rowsByOrder: map[string][]syb.StockRow{record.OrderNumber: {{ID: 200, Code: record.OrderNumber}}},
detailsByID: map[int64]syb.StockDetail{200: {ID: 200, Code: record.OrderNumber, Details: []syb.DetailItem{second, first}}},
}
result, err := PlanInnerCodeRecords(context.Background(), db, reader, record.BusinessDate, []int64{recordID})
if err != nil {
t.Fatal(err)
}
var status model.InnerCodeStatus
var detailID int64
var remoteItems string
if err := db.QueryRow(`SELECT status,detail_id,remote_items_json FROM syb_inner_code_records WHERE id=?`, recordID).
Scan(&status, &detailID, &remoteItems); err != nil {
t.Fatal(err)
}
if result.Ready != 1 || status != model.InnerCodeReady || detailID != first.ID || !strings.Contains(remoteItems, `"source":"existing_matched"`) {
t.Fatalf("逐件绑定未持久化 result=%+v status=%s detail=%d items=%s", result, status, detailID, remoteItems)
}
}
func TestPlanInnerCodeRows_精确与标准化规格均受档口约束(t *testing.T) {
records := []model.InnerCodeRecord{
{ID: 1, OrderNumber: "ORDER-1", Stall: "A档#027", SpecRaw: "A-027 黑色,M碼 建議45-55公斤", InnerCode: "DK1", SourceDuplicateCount: 1},
@@ -223,6 +277,62 @@ func TestMatchInnerCodeItemsWithEvidence_重复和证据冲突均阻断(t *testi
}
}
func TestPlanInnerCodeRows_两个单件码复用两条相同现成明细(t *testing.T) {
record := model.InnerCodeRecord{
ID: 41, OrderNumber: "260820SS6E61GG", Stall: "優褲網購#0907",
SourceSKURaw: "優褲網購#0907-灰色【小個子145-155CM】-2XL 【建議62-72kg】",
SpecRaw: "灰色【小個子145-155CM】,2XL 【建議62-72kg】",
InnerCode: "DK260822A130101,DK260822A130301", SourceDuplicateCount: 2,
}
first := innerCodeTestItem(147679521, record.SpecRaw, map[string]any{
"sku": record.SourceSKURaw, "variationSku": "上寮-优裤网购#907#(&17)",
"innerExpCode": "", "purchasePlatform": "", "purchaseCode": "",
})
first.ProductQty = 1
second := innerCodeTestItem(147679527, record.SpecRaw, map[string]any{
"sku": record.SourceSKURaw, "variationSku": "上寮-优裤网购#907#(&17)",
"innerExpCode": "", "purchasePlatform": "", "purchaseCode": "",
})
second.ProductQty = 1
plans := planInnerCodeRows([]model.InnerCodeRecord{record},
map[string][]int64{record.OrderNumber: {76544812}},
map[int64]syb.StockDetail{76544812: {ID: 76544812, Details: []syb.DetailItem{second, first}}})
if len(plans) != 1 || plans[0].Status != model.InnerCodeReady || plans[0].DetailID != first.ID {
t.Fatalf("多明细逐件规划失败: %+v", plans)
}
var items []innerCodeRemoteItem
if err := json.Unmarshal([]byte(plans[0].RemoteItemsJSON), &items); err != nil {
t.Fatalf("逐件规划 JSON 无效: %v raw=%q", err, plans[0].RemoteItemsJSON)
}
want := []innerCodeRemoteItem{
{Code: "DK260822A130101", DetailID: first.ID, Source: innerCodeRemoteSourceExistingMatched, Status: "planned"},
{Code: "DK260822A130301", DetailID: second.ID, Source: innerCodeRemoteSourceExistingMatched, Status: "planned"},
}
if !reflect.DeepEqual(items, want) {
t.Fatalf("逐件绑定不稳定: got=%+v want=%+v", items, want)
}
}
func TestPlanInnerCodeRows_相同候选数与单件码数不一致仍阻断(t *testing.T) {
record := model.InnerCodeRecord{ID: 42, OrderNumber: "ORDER-DUP", Stall: "A档#1",
SourceSKURaw: "A档#1-黑色-M", SpecRaw: "黑色,M",
InnerCode: "DK-1,DK-2", SourceDuplicateCount: 2}
details := make([]syb.DetailItem, 3)
for index := range details {
details[index] = innerCodeTestItem(int64(index+1), record.SpecRaw, map[string]any{
"sku": record.SourceSKURaw, "variationSku": "A档#1", "innerExpCode": "",
})
details[index].ProductQty = 1
}
plans := planInnerCodeRows([]model.InnerCodeRecord{record},
map[string][]int64{record.OrderNumber: {100}},
map[int64]syb.StockDetail{100: {ID: 100, Details: details}})
if len(plans) != 1 || plans[0].Status != model.InnerCodeSkipped || plans[0].RemoteItemsJSON != "" {
t.Fatalf("真实重复候选不应自动规划: %+v", plans)
}
}
func TestInnerCodeStallMatches_中文括号货号与数字前导零(t *testing.T) {
tests := []struct {
name string
+4
View File
@@ -1065,6 +1065,10 @@ CREATE UNIQUE INDEX idx_client_assignment_current
不再接收聚合字符串:原商品明细承载一个码,额外单件使用数量 1、价格 0 的占位明细。
JSON 数组按单件码保存 `detail_id/source/title/status` 检查点;新增或写入结果未知时立即
转为 `needs_check`,重启后只读扫描整张货运单,不自动重发请求。
- #289 复用同一个 JSON 字段保存另一种远端形态:当聚合记录的多个单件码恰好对应多条
已存在、数量均为 1 且规格和 SKU 身份完全相同的空白商品明细时,`source` 记为
`existing_matched`,按单件码顺序保存每个现成 `detail_id`。该规划在 `ready` 阶段已经
持久化,排队和回写前必须复读全部绑定明细;它不创建占位明细,也不改变数据库结构。
- v28 增加可空 `source_sku_raw VARCHAR(500)`。数据库保留 Excel 单元格原文;匹配时仅
去除两端空白后,与规格候选的 `sku` 或 `variationSku` 完全相等比较。唯一命中时它是
高优先级确定性证据;重复命中或与档口货号唯一证据冲突时停止自动选择。缺失或零命中
+6 -1
View File
@@ -410,7 +410,7 @@ SKU(平均每商品 1.17 个),**查无此 SKU 是常态**,不是异常
`[建议]` 登录后**只调 `/am/user/get` 验证会话**,其余几个是网页自己的初始化请求,
Go 侧不用跟着调。
### 7.1 档口入库码逐件写入(工单 #234、#250、#278)
### 7.1 档口入库码逐件写入(工单 #234、#250、#278、#289)
档口入库码最终写入货运明细的 `innerExpCode`(页面名称“快递单号”)。接口来自
现有 Python 流程和 HAR 响应样本:
@@ -433,6 +433,11 @@ POST /am/stock/detail/updateDetailCode?t=0&id={stockID}&detailId={detailID}&code
- 回写前必须同时核对目标码数量、Excel 合并源行数和原商品 `productQty`;任一不一致时
零写入。一个远端明细只写一个单件码,原明细为空时承载第一个缺失码,其余缺失码创建
零价占位明细。
- 顺运宝也可能已经用多条数量 1 的相同商品明细表达同一聚合记录。只有原始 SKU 重复
候选数、候选总件数、Excel 源行数和单件码数完全一致,并且全部候选的规格、`sku`、
`variationSku`、档口身份和采购状态一致时,才按稳定 `detailId` 与 Excel 单件码顺序形成
`existing_matched` 逐件规划。回写复用这些现成明细,不再创建占位明细;任一绑定在写前
变化时整条记录零写入并要求重新匹配。其他重复候选仍按真实歧义阻断。
- 同一货运单任意明细已有目标码时直接复用,不删除、不搬移。稳定占位标题可用于识别
“已创建但检查点未保存”的明细,防止崩溃后重复创建。
- 规划时的旧值为空时不调用删除;旧值非空且仍与规划快照一致时,只有删除明确成功才继续。