package service import ( "context" "database/sql" "encoding/json" "fmt" "sort" "strconv" "strings" "time" "unicode" "cmautobuy/admin/model" "cmautobuy/admin/repository" "cmautobuy/admin/syb" ) // InnerCodeDetailReader 只开放读取已知货运单详情的能力,供规划和回写前核验复用。 type InnerCodeDetailReader interface { DetailListByStock(context.Context, []int64) ([]syb.StockDetail, error) } // InnerCodePlanReader 是规划阶段需要的顺运宝只读能力。 // 货运单定位必须直接查询远端,不能依赖是否已同步进本地 syb_orders。 type InnerCodePlanReader interface { InnerCodeDetailReader ListByOrderNumber(context.Context, string) ([]syb.StockRow, error) } const innerCodeReadBatchSize = 100 // InnerCodePlanResult 是一次规划的逐状态统计。 type InnerCodePlanResult struct { Total int Ready int AlreadyFilled int Skipped int Failed int } // PlanInnerCodeRecords 对某个业务日期选中的可重规划记录读取最新远端详情并原子保存计划。 // 本函数绝不调用删除或更新顺运宝接口。 func PlanInnerCodeRecords(ctx context.Context, db *sql.DB, reader InnerCodePlanReader, businessDate string, selectedIDs []int64) (*InnerCodePlanResult, error) { if _, err := time.Parse("2006-01-02", businessDate); err != nil { return nil, fmt.Errorf("业务日期格式应为 YYYY-MM-DD") } selectedIDs = uniquePositiveInnerCodeIDs(selectedIDs) if len(selectedIDs) == 0 { return nil, fmt.Errorf("没有选中可匹配记录") } records, err := repository.ListInnerCodeRecordsForPlanning(db, businessDate, selectedIDs) if err != nil { return nil, err } if len(records) != len(selectedIDs) { return nil, fmt.Errorf("部分选中记录不存在、业务日期不一致或状态已变化,请刷新后重新勾选") } result := &InnerCodePlanResult{Total: len(records)} orders := uniqueInnerCodeOrders(records) contextRecords, err := repository.ListInnerCodePlanningContext(db, businessDate, orders) if err != nil { return nil, err } selectedSet := make(map[int64]bool, len(records)) for _, record := range records { selectedSet[record.ID] = true } reservedDetails := innerCodeReservedDetails(contextRecords, selectedSet) stockIDsByOrder, err := readInnerCodeStockIDs(ctx, reader, orders) if err != nil { return nil, err } requested := uniqueInnerCodeStockIDs(records, stockIDsByOrder) detailsByID, err := readInnerCodeDetails(ctx, reader, requested) if err != nil { return nil, err } plans := planInnerCodeRowsWithReserved(records, stockIDsByOrder, detailsByID, reservedDetails) for _, plan := range plans { switch plan.Status { case model.InnerCodeReady: result.Ready++ case model.InnerCodeAlreadyFilled: result.AlreadyFilled++ case model.InnerCodeFailed: result.Failed++ default: result.Skipped++ } } if err := repository.SaveInnerCodePlans(db, plans, model.NowISO()); err != nil { return nil, err } return result, nil } // readInnerCodeDetails 只限制单次顺运宝请求大小,不限制一次规划的总选择数量。 func readInnerCodeDetails(ctx context.Context, reader InnerCodeDetailReader, requested []int64) (map[int64]syb.StockDetail, error) { detailsByID := make(map[int64]syb.StockDetail, len(requested)) for start := 0; start < len(requested); start += innerCodeReadBatchSize { end := start + innerCodeReadBatchSize if end > len(requested) { end = len(requested) } batch := requested[start:end] details, err := reader.DetailListByStock(ctx, batch) if err != nil { return nil, fmt.Errorf("读取顺运宝最新商品详情失败,本次规划未保存: %w", err) } for _, detail := range details { if _, duplicate := detailsByID[detail.ID]; duplicate { return nil, fmt.Errorf("顺运宝重复返回货运单 id=%d,本次规划未保存", detail.ID) } detailsByID[detail.ID] = detail } for _, id := range batch { if _, exists := detailsByID[id]; !exists { return nil, fmt.Errorf("顺运宝响应缺少货运单 id=%d,本次规划未保存", id) } } } return detailsByID, nil } func uniqueInnerCodeOrders(records []model.InnerCodeRecord) []string { seen := make(map[string]bool, len(records)) result := make([]string, 0, len(records)) for _, record := range records { if !seen[record.OrderNumber] { seen[record.OrderNumber] = true result = append(result, record.OrderNumber) } } return result } // readInnerCodeStockIDs 对勾选记录的货运单号逐个直查顺运宝。 // 零命中和多命中交给逐行规划生成明确状态;网络或协议错误会中止整批, // 避免保存一半新计划、一半旧计划。 func readInnerCodeStockIDs(ctx context.Context, reader InnerCodePlanReader, orders []string) (map[string][]int64, error) { result := make(map[string][]int64, len(orders)) for _, order := range orders { rows, err := reader.ListByOrderNumber(ctx, order) if err != nil { return nil, fmt.Errorf("查询顺运宝货运单 %q 失败,本次规划未保存: %w", order, err) } ids := make([]int64, 0, len(rows)) seen := make(map[int64]bool, len(rows)) for _, row := range rows { if row.Code != order { return nil, fmt.Errorf("查询顺运宝货运单 %q 返回不一致的 code %q,本次规划未保存", order, row.Code) } if row.ID <= 0 { return nil, fmt.Errorf("查询顺运宝货运单 %q 返回非法 id=%d,本次规划未保存", order, row.ID) } if !seen[row.ID] { seen[row.ID] = true ids = append(ids, row.ID) } } sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) result[order] = ids } return result, nil } func uniqueInnerCodeStockIDs(records []model.InnerCodeRecord, byOrder map[string][]int64) []int64 { seen := make(map[int64]bool) result := make([]int64, 0) for _, record := range records { ids := byOrder[record.OrderNumber] if len(ids) != 1 || seen[ids[0]] { continue } seen[ids[0]] = true result = append(result, ids[0]) } sort.Slice(result, func(i, j int) bool { return result[i] < result[j] }) return result } func innerCodeReservedDetails(records []model.InnerCodeRecord, selected map[int64]bool) map[string]map[int64]bool { reserved := make(map[string]map[int64]bool) for _, record := range records { if selected[record.ID] || record.DetailID <= 0 || !innerCodeStatusReservesDetail(record.Status) { continue } if reserved[record.OrderNumber] == nil { reserved[record.OrderNumber] = make(map[int64]bool) } reserved[record.OrderNumber][record.DetailID] = true } return reserved } func innerCodeStatusReservesDetail(status model.InnerCodeStatus) bool { switch status { case model.InnerCodeReady, model.InnerCodeQueued, model.InnerCodeApplying, model.InnerCodeUpdated, model.InnerCodeAlreadyFilled, model.InnerCodeNeedsCheck: return true default: return false } } func planInnerCodeRows(records []model.InnerCodeRecord, stockIDsByOrder map[string][]int64, detailsByID map[int64]syb.StockDetail) []model.InnerCodeRecord { return planInnerCodeRowsWithReserved(records, stockIDsByOrder, detailsByID, nil) } func planInnerCodeRowsWithReserved(records []model.InnerCodeRecord, stockIDsByOrder map[string][]int64, detailsByID map[int64]syb.StockDetail, reserved map[string]map[int64]bool) []model.InnerCodeRecord { used := make(map[string]map[int64]bool, len(reserved)) for orderNumber, detailIDs := range reserved { used[orderNumber] = make(map[int64]bool, len(detailIDs)) for detailID := range detailIDs { used[orderNumber][detailID] = true } } plans := make([]model.InnerCodeRecord, 0, len(records)) for _, record := range records { plan := record 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 } stockIDs := stockIDsByOrder[record.OrderNumber] if len(stockIDs) == 0 { plan.Status = model.InnerCodeFailed plan.ResultMessage = "顺运宝未找到货运单" plans = append(plans, plan) continue } if len(stockIDs) > 1 { plans = append(plans, innerCodeSkippedPlan(plan, "同一订单号命中多张顺运宝货运单,不能自动选择")) continue } stockID := stockIDs[0] stock := detailsByID[stockID] if used[record.OrderNumber] == nil { used[record.OrderNumber] = make(map[int64]bool) } eligible := make([]syb.DetailItem, 0, len(stock.Details)) availableBeforeReservation := 0 for _, item := range stock.Details { if innerCodeRawText(item.Raw["purchasePlatform"]) == "" && innerCodeRawText(item.Raw["purchaseCode"]) == "" { availableBeforeReservation++ if used[record.OrderNumber][item.ID] { continue } eligible = append(eligible, item) } } if len(eligible) == 0 { message := "没有采购平台和采购单号都为空的顺运宝商品" if availableBeforeReservation > 0 { message = "符合条件的顺运宝商品已被同订单其他记录占用" } 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 } if len(matches) == 0 { reason := "候选商品中没有相同规格" if len(specMatches) > 0 { reason = "规格能匹配,但档口及货号与候选商品不一致" } plans = append(plans, innerCodeSkippedPlan(plan, reason)) continue } if len(matches) > 1 { plans = append(plans, innerCodeSkippedPlan(plan, "同一订单存在多条相同规格候选商品,不能自动选择")) continue } matched := matches[0] 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 plan.SybSpec = matched.ProductSpec plan.SybSKU = innerCodeRawText(matched.Raw["sku"]) plan.SybVariationSKU = innerCodeRawText(matched.Raw["variationSku"]) plan.PurchasePlatform = innerCodeRawText(matched.Raw["purchasePlatform"]) plan.PurchaseCode = innerCodeRawText(matched.Raw["purchaseCode"]) plan.RemoteInnerCode = innerCodeRawText(matched.Raw["innerExpCode"]) _, 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 = fmt.Sprintf("远端已存在全部 %d 个单件入库码,无需重复写入", len(codes)) } else { plan.Status = model.InnerCodeReady plan.ResultMessage = "唯一匹配,等待操作员确认回写" } plans = append(plans, plan) } 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 return plan } func matchInnerCodeItems(spec, stall string, eligible []syb.DetailItem) []syb.DetailItem { return filterInnerCodeItemsByStall(matchInnerCodeSpecItems(spec, eligible), stall) } func matchInnerCodeSpecItems(spec string, eligible []syb.DetailItem) []syb.DetailItem { matches := make([]syb.DetailItem, 0) for _, item := range eligible { if item.ProductSpec == spec { matches = append(matches, item) } } if len(matches) == 0 { key := NormalizeInnerCodeSpecKey(spec) if key == "" { return nil } for _, item := range eligible { if NormalizeInnerCodeSpecKey(item.ProductSpec) == key { matches = append(matches, item) } } } return matches } func matchInnerCodeItemsWithEvidence(stall, sourceSKURaw string, specMatches []syb.DetailItem) ([]syb.DetailItem, string) { sourceMatches := matchInnerCodeSourceSKUItems(sourceSKURaw, specMatches) if normalizeInnerCodeSourceSKU(sourceSKURaw) != "" { if len(sourceMatches) > 1 { return nil, innerCodeSourceSKUDuplicateReason } if len(sourceMatches) == 1 { stallMatches := filterInnerCodeItemsByStallStrict(specMatches, stall) if len(stallMatches) > 1 { return nil, "档口货号候选重复,不能自动选择" } if len(stallMatches) == 1 && stallMatches[0].ID != sourceMatches[0].ID { return nil, "原始 SKU 与档口货号冲突,不能自动选择" } return sourceMatches, "" } } 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 == "" { return items } matched := make([]syb.DetailItem, 0) for _, item := range items { if innerCodeStallMatches(stall, item) { matched = append(matched, item) } } if len(matched) > 0 { return matched } for _, item := range items { if innerCodeRawText(item.Raw["sku"]) == "" && innerCodeRawText(item.Raw["variationSku"]) == "" { matched = append(matched, item) } } return matched } func filterInnerCodeItemsByStallStrict(items []syb.DetailItem, stall string) []syb.DetailItem { stall = strings.TrimSpace(stall) if stall == "" { return nil } matched := make([]syb.DetailItem, 0) for _, item := range items { if innerCodeStallMatches(stall, item) { matched = append(matched, item) } } return matched } func innerCodeStallMatches(stall string, item syb.DetailItem) bool { sku := innerCodeRawText(item.Raw["sku"]) variation := innerCodeRawText(item.Raw["variationSku"]) blob := sku + " " + variation + " " + item.ProductSpec if strings.Contains(blob, stall) { return true } name, article, hasArticle := splitInnerCodeStall(stall) if !hasArticle { return false } if article == "" { return name != "" && (strings.Contains(sku, name) || strings.Contains(variation, name)) } // 纯数字货号容易在不同档口重复。只有候选也包含同一档口名称时, // 才允许把 067 与 67 视为同一个货号;不能只凭短数字自动选商品。 if isInnerCodeNumericArticle(article) { nameMatches := name != "" && (strings.Contains(sku, name) || strings.Contains(variation, name)) if !nameMatches { return false } return innerCodeTextHasNumericArticle(sku, article) || innerCodeTextHasNumericArticle(variation, article) || innerCodeProductSpecStartsWithArticle(item.ProductSpec, article, true) } return innerCodeTextHasExactArticle(sku, article) || innerCodeTextHasExactArticle(variation, article) || innerCodeProductSpecStartsWithArticle(item.ProductSpec, article, false) } // splitInnerCodeStall 从“档口名称#货号”取最后一个 #,避免档口名称本身含 # 时截错。 func splitInnerCodeStall(stall string) (name, article string, ok bool) { stall = strings.TrimSpace(stall) separator := strings.LastIndex(stall, "#") if separator < 0 { return stall, "", false } return strings.TrimSpace(stall[:separator]), strings.TrimSpace(stall[separator+1:]), true } func isInnerCodeNumericArticle(article string) bool { if article == "" { return false } for _, char := range article { if !unicode.IsDigit(char) { return false } } return true } func innerCodeTextHasNumericArticle(text, article string) bool { target := normalizeInnerCodeNumericArticle(article) for _, token := range innerCodeArticleTokens(text) { if isInnerCodeNumericArticle(token) && normalizeInnerCodeNumericArticle(token) == target { return true } } return false } func normalizeInnerCodeNumericArticle(article string) string { normalized := strings.TrimLeft(article, "0") if normalized == "" { return "0" } return normalized } func innerCodeTextHasExactArticle(text, article string) bool { for _, token := range innerCodeArticleTokens(text) { if token == article { return true } } return false } func innerCodeProductSpecStartsWithArticle(productSpec, article string, numeric bool) bool { productSpec = strings.TrimSpace(productSpec) separator := strings.IndexAny(productSpec, " ,,") if separator <= 0 { return false } prefix := strings.TrimSpace(productSpec[:separator]) if numeric { return isInnerCodeNumericArticle(prefix) && normalizeInnerCodeNumericArticle(prefix) == normalizeInnerCodeNumericArticle(article) } return prefix == article } // innerCodeArticleTokens 只把连续字母或数字视为货号候选。 // `【】`、`#`、横线、空格等标点自然成为边界,因此能识别 `067【档口】`, // 又不会把 `PDD256437` 中间的数字误认为独立货号。 func innerCodeArticleTokens(text string) []string { tokens := make([]string, 0) start := -1 runes := []rune(text) for index, char := range runes { if unicode.IsLetter(char) || unicode.IsDigit(char) { if start < 0 { start = index } continue } if start >= 0 { tokens = append(tokens, string(runes[start:index])) start = -1 } } if start >= 0 { tokens = append(tokens, string(runes[start:])) } return tokens } func innerCodeRawText(value any) string { switch typed := value.(type) { case nil: return "" case string: return strings.TrimSpace(typed) case json.Number: return strings.TrimSpace(typed.String()) case float64: if typed == float64(int64(typed)) { return strconv.FormatInt(int64(typed), 10) } return strconv.FormatFloat(typed, 'f', -1, 64) default: return strings.TrimSpace(fmt.Sprint(typed)) } }