package service import ( "context" "database/sql" "encoding/json" "fmt" "regexp" "sort" "strconv" "strings" "time" "cmautobuy/admin/model" "cmautobuy/admin/repository" "cmautobuy/admin/syb" ) // InnerCodeDetailReader 只开放规划所需的顺运宝只读调用,方便无网络测试。 type InnerCodeDetailReader interface { DetailListByStock(context.Context, []int64) ([]syb.StockDetail, 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 InnerCodeDetailReader, 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) snapshots, err := repository.ListInnerCodeSybSnapshots(db, orders) if err != nil { return nil, err } stockIDsByOrder := innerCodeStockIDsByOrder(snapshots) 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 } func innerCodeStockIDsByOrder(snapshots []repository.InnerCodeSybSnapshot) map[string][]int64 { sets := make(map[string]map[int64]bool) for _, snapshot := range snapshots { stockID, ok := innerCodeStockIDFromJSON(snapshot.SybData) if !ok || stockID <= 0 { continue } if sets[snapshot.OrderNumber] == nil { sets[snapshot.OrderNumber] = make(map[int64]bool) } sets[snapshot.OrderNumber][stockID] = true } result := make(map[string][]int64, len(sets)) for order, set := range sets { for id := range set { result[order] = append(result[order], id) } sort.Slice(result[order], func(i, j int) bool { return result[order][i] < result[order][j] }) } return result } func innerCodeStockIDFromJSON(raw string) (int64, bool) { var envelope struct { Stock map[string]any `json:"stock"` } decoder := json.NewDecoder(strings.NewReader(raw)) decoder.UseNumber() if err := decoder.Decode(&envelope); err != nil { return 0, false } return innerCodeInt64(envelope.Stock["id"]) } 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.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 = "", "", "" 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 } matches := matchInnerCodeItems(record.SpecRaw, record.Stall, eligible) if len(matches) == 0 { reason := "候选商品中没有相同规格" if len(matchInnerCodeItems(record.SpecRaw, "", eligible)) > 0 { reason = "规格能匹配,但档口及货号与候选商品不一致" } plans = append(plans, innerCodeSkippedPlan(plan, reason)) continue } if len(matches) > 1 { plans = append(plans, innerCodeSkippedPlan(plan, "同一订单存在多条相同规格候选商品,不能自动选择")) continue } matched := matches[0] 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"]) if plan.RemoteInnerCode == record.InnerCode { plan.Status = model.InnerCodeAlreadyFilled plan.ResultMessage = "远端已是相同入库码,无需重复写入" } else { plan.Status = model.InnerCodeReady plan.ResultMessage = "唯一匹配,等待操作员确认回写" } plans = append(plans, plan) } return plans } 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 { 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 filterInnerCodeItemsByStall(matches, stall) } 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 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 := strings.Cut(stall, "#") if !hasArticle { return false } name, article = strings.TrimSpace(name), strings.TrimSpace(article) if article != "" { if strings.Contains(blob, "#"+article) || strings.HasPrefix(item.ProductSpec, article+" ") || strings.HasPrefix(item.ProductSpec, article+",") { return true } pattern := regexp.MustCompile(`(?:^|[#\-_/\s])` + regexp.QuoteMeta(article) + `(?:$|[#\-_/,\s])`) return pattern.MatchString(sku) || pattern.MatchString(variation) } return name != "" && (strings.Contains(sku, name) || strings.Contains(variation, name)) } 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)) } } func innerCodeInt64(value any) (int64, bool) { switch typed := value.(type) { case json.Number: result, err := typed.Int64() return result, err == nil case float64: return int64(typed), true case string: result, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64) return result, err == nil default: return 0, false } }