diff --git a/admin/repository/inner_code.go b/admin/repository/inner_code.go index 78c4a16..1197663 100644 --- a/admin/repository/inner_code.go +++ b/admin/repository/inner_code.go @@ -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) diff --git a/admin/service/inner_code_apply.go b/admin/service/inner_code_apply.go index 429b6fc..d20810b 100644 --- a/admin/service/inner_code_apply.go +++ b/admin/service/inner_code_apply.go @@ -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) diff --git a/admin/service/inner_code_apply_test.go b/admin/service/inner_code_apply_test.go index e76b8fa..30cc208 100644 --- a/admin/service/inner_code_apply_test.go +++ b/admin/service/inner_code_apply_test.go @@ -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" diff --git a/admin/service/inner_code_match.go b/admin/service/inner_code_match.go index 5d089b2..8a81067 100644 --- a/admin/service/inner_code_match.go +++ b/admin/service/inner_code_match.go @@ -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 == "" { diff --git a/admin/service/inner_code_match_test.go b/admin/service/inner_code_match_test.go index 96efc0f..c7e3fc3 100644 --- a/admin/service/inner_code_match_test.go +++ b/admin/service/inner_code_match_test.go @@ -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 diff --git a/docs/admin/03-data-model.md b/docs/admin/03-data-model.md index 67c9dda..3fab05b 100644 --- a/docs/admin/03-data-model.md +++ b/docs/admin/03-data-model.md @@ -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` 完全相等比较。唯一命中时它是 高优先级确定性证据;重复命中或与档口货号唯一证据冲突时停止自动选择。缺失或零命中 diff --git a/docs/admin/08-顺运宝接口.md b/docs/admin/08-顺运宝接口.md index 27f3b5d..92ead93 100644 --- a/docs/admin/08-顺运宝接口.md +++ b/docs/admin/08-顺运宝接口.md @@ -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` 逐件规划。回写复用这些现成明细,不再创建占位明细;任一绑定在写前 + 变化时整条记录零写入并要求重新匹配。其他重复候选仍按真实歧义阻断。 - 同一货运单任意明细已有目标码时直接复用,不删除、不搬移。稳定占位标题可用于识别 “已创建但检查点未保存”的明细,防止崩溃后重复创建。 - 规划时的旧值为空时不调用删除;旧值非空且仍与规划快照一致时,只有删除明确成功才继续。