diff --git a/admin/repository/inner_code.go b/admin/repository/inner_code.go index 2a571fd..b9f5202 100644 --- a/admin/repository/inner_code.go +++ b/admin/repository/inner_code.go @@ -4,10 +4,17 @@ import ( "database/sql" "errors" "fmt" + "strings" "cmautobuy/admin/model" ) +// InnerCodeSybSnapshot 是本地顺运宝明细用于解析货运单 stock id 的最小快照。 +type InnerCodeSybSnapshot struct { + OrderNumber string + SybData string +} + // InnerCodeImportOutcome 说明幂等导入是新增还是更新。 type InnerCodeImportOutcome string @@ -69,6 +76,99 @@ func UpsertInnerCodeImportRow(tx *sql.Tx, row model.InnerCodeImportRow, now stri return InnerCodeImportUpdated, nil } +// ListInnerCodeRecordsForPlanning 返回某日允许重新规划的记录。 +func ListInnerCodeRecordsForPlanning(q Execer, businessDate string) ([]model.InnerCodeRecord, error) { + rows, err := q.Query(` + SELECT id,business_date,source_row,COALESCE(print_sequence,0),order_number, + COALESCE(shop_name,''),stall,spec_raw,spec_key,inner_code,source_duplicate_count, + status,COALESCE(result_message,''),created_by_user_id,created_at,updated_at + FROM syb_inner_code_records + WHERE business_date=? AND status IN ('pending','ready','skipped','failed') + ORDER BY source_row,id`, businessDate) + if err != nil { + return nil, fmt.Errorf("查询待规划档口入库码失败: %w", err) + } + defer rows.Close() + result := make([]model.InnerCodeRecord, 0) + for rows.Next() { + var row model.InnerCodeRecord + if err := rows.Scan(&row.ID, &row.BusinessDate, &row.SourceRow, &row.PrintSequence, + &row.OrderNumber, &row.ShopName, &row.Stall, &row.SpecRaw, &row.SpecKey, + &row.InnerCode, &row.SourceDuplicateCount, &row.Status, &row.ResultMessage, + &row.CreatedByUserID, &row.CreatedAt, &row.UpdatedAt); err != nil { + return nil, fmt.Errorf("读取待规划档口入库码失败: %w", err) + } + result = append(result, row) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("遍历待规划档口入库码失败: %w", err) + } + return result, nil +} + +// ListInnerCodeSybSnapshots 查询订单号对应的本地 SYB 原始快照。 +func ListInnerCodeSybSnapshots(q Execer, orderNumbers []string) ([]InnerCodeSybSnapshot, error) { + if len(orderNumbers) == 0 { + return nil, nil + } + placeholders := make([]string, len(orderNumbers)) + args := make([]any, len(orderNumbers)) + for index, orderNumber := range orderNumbers { + placeholders[index] = "?" + args[index] = orderNumber + } + rows, err := q.Query(`SELECT order_no,syb_data FROM syb_orders WHERE order_no IN (`+ + strings.Join(placeholders, ",")+`) ORDER BY order_no,syb_id`, args...) + if err != nil { + return nil, fmt.Errorf("查询档口入库码对应顺运宝快照失败: %w", err) + } + defer rows.Close() + result := make([]InnerCodeSybSnapshot, 0) + for rows.Next() { + var row InnerCodeSybSnapshot + if err := rows.Scan(&row.OrderNumber, &row.SybData); err != nil { + return nil, fmt.Errorf("读取顺运宝快照失败: %w", err) + } + result = append(result, row) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("遍历顺运宝快照失败: %w", err) + } + return result, nil +} + +// SaveInnerCodePlans 在同一事务中保存一批只读规划结果。 +func SaveInnerCodePlans(db *sql.DB, plans []model.InnerCodeRecord, plannedAt string) error { + tx, err := db.Begin() + if err != nil { + return fmt.Errorf("开始保存档口入库码规划事务失败: %w", err) + } + defer tx.Rollback() + for _, plan := range plans { + result, err := tx.Exec(` + 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=?,updated_at=? + WHERE id=? 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), + plannedAt, plannedAt, plan.ID) + if err != nil { + return fmt.Errorf("保存档口入库码记录 %d 规划失败: %w", plan.ID, err) + } + if affected, err := result.RowsAffected(); err != nil || affected != 1 { + return fmt.Errorf("档口入库码记录 %d 状态已变化,规划整体未保存", plan.ID) + } + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("提交档口入库码规划失败: %w", err) + } + return nil +} + func nullablePositiveInt(value int) any { if value <= 0 { return nil @@ -76,6 +176,13 @@ func nullablePositiveInt(value int) any { return value } +func nullablePositiveInt64(value int64) any { + if value <= 0 { + return nil + } + return value +} + func nullableString(value string) any { if value == "" { return nil diff --git a/admin/service/inner_code_match.go b/admin/service/inner_code_match.go new file mode 100644 index 0000000..131440a --- /dev/null +++ b/admin/service/inner_code_match.go @@ -0,0 +1,341 @@ +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) +} + +// 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) (*InnerCodePlanResult, error) { + if _, err := time.Parse("2006-01-02", businessDate); err != nil { + return nil, fmt.Errorf("业务日期格式应为 YYYY-MM-DD") + } + records, err := repository.ListInnerCodeRecordsForPlanning(db, businessDate) + if err != nil { + return nil, err + } + result := &InnerCodePlanResult{Total: len(records)} + if len(records) == 0 { + return result, nil + } + + orders := uniqueInnerCodeOrders(records) + snapshots, err := repository.ListInnerCodeSybSnapshots(db, orders) + if err != nil { + return nil, err + } + stockIDsByOrder := innerCodeStockIDsByOrder(snapshots) + requested := uniqueInnerCodeStockIDs(records, stockIDsByOrder) + detailsByID := make(map[int64]syb.StockDetail, len(requested)) + for start := 0; start < len(requested); start += 100 { + end := start + 100 + 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) + } + } + } + + plans := planInnerCodeRows(records, stockIDsByOrder, detailsByID) + 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 +} + +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 planInnerCodeRows(records []model.InnerCodeRecord, stockIDsByOrder map[string][]int64, detailsByID map[int64]syb.StockDetail) []model.InnerCodeRecord { + used := make(map[string]map[int64]bool) + 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 + } + if record.SourceDuplicateCount > 1 { + 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)) + for _, item := range stock.Details { + if used[record.OrderNumber][item.ID] { + continue + } + if innerCodeRawText(item.Raw["purchasePlatform"]) == "" && innerCodeRawText(item.Raw["purchaseCode"]) == "" { + eligible = append(eligible, item) + } + } + if len(eligible) == 0 { + plans = append(plans, innerCodeSkippedPlan(plan, "没有采购平台和采购单号都为空的顺运宝商品")) + 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 + } +} diff --git a/admin/service/inner_code_match_test.go b/admin/service/inner_code_match_test.go new file mode 100644 index 0000000..5b2be3d --- /dev/null +++ b/admin/service/inner_code_match_test.go @@ -0,0 +1,91 @@ +package service + +import ( + "testing" + + "cmautobuy/admin/model" + "cmautobuy/admin/syb" +) + +func TestInnerCodeStockIDFromJSON(t *testing.T) { + for _, raw := range []string{ + `{"stock":{"id":75104587},"detail":{"id":1}}`, + `{"stock":{"id":"75104587"},"detail":{"id":1}}`, + } { + if got, ok := innerCodeStockIDFromJSON(raw); !ok || got != 75104587 { + t.Fatalf("解析 stock id 失败 got=%d ok=%v", got, ok) + } + } + if _, ok := innerCodeStockIDFromJSON(`{"stock":{}}`); ok { + t.Fatal("缺少 stock.id 时不应成功") + } +} + +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}, + {ID: 2, OrderNumber: "ORDER-1", Stall: "B档#102", SpecRaw: "杏色,XL", InnerCode: "DK2", SourceDuplicateCount: 1}, + } + detail := syb.StockDetail{ID: 100, Details: []syb.DetailItem{ + innerCodeTestItem(11, "A-027 黑色,M", map[string]any{"sku": "A档#027"}), + innerCodeTestItem(12, "杏色,XL", map[string]any{"variationSku": "B档-102"}), + }} + plans := planInnerCodeRows(records, map[string][]int64{"ORDER-1": {100}}, map[int64]syb.StockDetail{100: detail}) + if len(plans) != 2 || plans[0].Status != model.InnerCodeReady || plans[0].DetailID != 11 || plans[1].DetailID != 12 { + t.Fatalf("规划结果不正确: %+v", plans) + } +} + +func TestPlanInnerCodeRows_重复空规格采购占用和歧义全部阻断(t *testing.T) { + records := []model.InnerCodeRecord{ + {ID: 1, OrderNumber: "DUP", SpecRaw: "黑色,M", InnerCode: "DK1", 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}, + {ID: 5, OrderNumber: "MISSING", SpecRaw: "黑色,M", InnerCode: "DK5", SourceDuplicateCount: 1}, + } + 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)}}, + 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)}}, + } + plans := planInnerCodeRows(records, stocks, details) + wants := []model.InnerCodeStatus{model.InnerCodeSkipped, model.InnerCodeSkipped, model.InnerCodeSkipped, model.InnerCodeSkipped, model.InnerCodeFailed} + for index, want := range wants { + if plans[index].Status != want || plans[index].Status == model.InnerCodeReady { + t.Errorf("第 %d 条 status=%s message=%s,期望 %s", index, plans[index].Status, plans[index].ResultMessage, want) + } + } +} + +func TestPlanInnerCodeRows_同一远端明细不会占用两次(t *testing.T) { + records := []model.InnerCodeRecord{ + {ID: 1, OrderNumber: "ORDER", SpecRaw: "黑色,M", InnerCode: "DK1", SourceDuplicateCount: 1}, + {ID: 2, OrderNumber: "ORDER", SpecRaw: "黑色,M", InnerCode: "DK2", SourceDuplicateCount: 1}, + } + detail := syb.StockDetail{ID: 10, Details: []syb.DetailItem{innerCodeTestItem(99, "黑色,M", nil)}} + plans := planInnerCodeRows(records, map[string][]int64{"ORDER": {10}}, map[int64]syb.StockDetail{10: detail}) + if plans[0].Status != model.InnerCodeReady || plans[1].Status != model.InnerCodeSkipped { + t.Fatalf("占用门禁失败: %+v", plans) + } +} + +func TestPlanInnerCodeRows_远端已是目标码(t *testing.T) { + record := model.InnerCodeRecord{ID: 1, OrderNumber: "ORDER", SpecRaw: "黑色,M", InnerCode: "DK1", SourceDuplicateCount: 1} + item := innerCodeTestItem(1, "黑色,M", map[string]any{"innerExpCode": "DK1"}) + plans := planInnerCodeRows([]model.InnerCodeRecord{record}, map[string][]int64{"ORDER": {10}}, map[int64]syb.StockDetail{10: {ID: 10, Details: []syb.DetailItem{item}}}) + if plans[0].Status != model.InnerCodeAlreadyFilled || plans[0].RemoteInnerCode != "DK1" { + t.Fatalf("已存在判断失败: %+v", plans[0]) + } +} + +func innerCodeTestItem(id int64, spec string, raw map[string]any) syb.DetailItem { + if raw == nil { + raw = make(map[string]any) + } + raw["id"] = id + raw["productSpec"] = spec + return syb.DetailItem{ID: id, ProductSpec: spec, Raw: raw} +}