Files
goauto/server/app/goauto/sybinnercode/match.go
T

624 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package sybinnercode
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
"unicode"
"go-admin/app/goauto/models"
"go-admin/app/goauto/sybclient"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type MatchReader interface {
ListByOrderNumber(context.Context, string) ([]sybclient.StockRow, error)
DetailListByStock(context.Context, []int64) ([]sybclient.StockDetail, error)
}
type MatchReaderFactory func(context.Context, *gorm.DB) (MatchReader, error)
type Matcher struct{ Factory MatchReaderFactory }
func (m Matcher) Start(_ context.Context, db *gorm.DB, jobID string) error {
if uuid.Validate(jobID) != nil {
return invalid("matchJobId 无效")
}
if m.Factory == nil {
return internal(errors.New("match reader factory not configured"))
}
go m.runBackground(db, jobID)
return nil
}
func (m Matcher) runBackground(db *gorm.DB, jobID string) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
defer cancel()
reader, err := m.Factory(ctx, db)
if err == nil {
err = RunMatchJob(ctx, db, reader, jobID)
}
if err != nil {
_ = failMatchJob(db, jobID, "匹配任务中断,请重新匹配")
}
}
func RunMatchJob(ctx context.Context, db *gorm.DB, reader MatchReader, jobID string) (runErr error) {
claimedJob := false
defer func() {
if runErr != nil && claimedJob {
if err := failMatchJob(db, jobID, "匹配任务中断,请重新匹配"); err != nil {
runErr = errors.Join(runErr, err)
}
}
}()
now := time.Now().UTC()
claimed := db.WithContext(ctx).Model(&models.SYBInnerCodeMatchJob{}).Where("id = ? AND status = ?", jobID, "pending").Updates(map[string]any{"status": "running", "started_at": now})
if claimed.Error != nil {
return claimed.Error
}
if claimed.RowsAffected == 0 {
var existing models.SYBInnerCodeMatchJob
if err := db.WithContext(ctx).First(&existing, "id = ?", jobID).Error; err != nil {
return err
}
if existing.Status == "succeeded" || existing.Status == "running" {
return nil
}
return conflict("匹配任务状态不允许执行")
}
claimedJob = true
var job models.SYBInnerCodeMatchJob
if err := db.WithContext(ctx).First(&job, "id = ?", jobID).Error; err != nil {
return err
}
var recordIDs []uint64
if err := json.Unmarshal([]byte(job.RecordIDsJSON), &recordIDs); err != nil || len(recordIDs) == 0 {
return fmt.Errorf("匹配任务记录范围无效")
}
var records []models.SYBInnerCodeRecord
if err := db.WithContext(ctx).Preload("Items", func(q *gorm.DB) *gorm.DB { return q.Order("ordinal ASC") }).Where("id IN ? AND business_date = ? AND status IN ?", recordIDs, job.BusinessDate, []string{models.SYBInnerCodePending, models.SYBInnerCodeMatching, models.SYBInnerCodeFailed, models.SYBInnerCodeSkipped}).Order("source_row,id").Find(&records).Error; err != nil {
return err
}
used, err := loadReservedDetails(ctx, db, records)
if err != nil {
return err
}
ready, failed := 0, 0
for _, record := range records {
if err := ctx.Err(); err != nil {
return err
}
plan, status, message, planErr := safePlanRecord(ctx, reader, record, used)
if planErr != nil {
status = models.SYBInnerCodeFailed
message = "读取 SYB 失败:" + compact(planErr.Error(), 900)
}
saveErr := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("record_id = ?", record.ID).Delete(&models.SYBInnerCodePlan{}).Error; err != nil {
return err
}
if plan != nil {
plan.RecordID = record.ID
plan.MatchJobID = jobID
plan.PlannedAt = time.Now().UTC()
if err := tx.Create(plan).Error; err != nil {
return err
}
}
return tx.Model(&models.SYBInnerCodeRecord{}).Where("id = ? AND status IN ?", record.ID, []string{models.SYBInnerCodePending, models.SYBInnerCodeFailed, models.SYBInnerCodeSkipped, models.SYBInnerCodeMatching}).Updates(map[string]any{"status": status, "result_message": message}).Error
})
if saveErr != nil {
return saveErr
}
if status == models.SYBInnerCodeReady || status == models.SYBInnerCodeAlreadyFilled {
ready++
} else {
failed++
}
if err := db.WithContext(ctx).Model(&models.SYBInnerCodeMatchJob{}).Where("id = ?", jobID).Updates(map[string]any{"processed": gorm.Expr("processed + 1"), "ready": ready, "failed": failed}).Error; err != nil {
return err
}
}
finished := time.Now().UTC()
return db.WithContext(ctx).Model(&models.SYBInnerCodeMatchJob{}).Where("id = ? AND status = ?", jobID, "running").Updates(map[string]any{"status": "succeeded", "finished_at": finished, "ready": ready, "failed": failed}).Error
}
// Only the read/plan step is isolated: no remote write is retried here.
func safePlanRecord(ctx context.Context, reader MatchReader, record models.SYBInnerCodeRecord, used map[int64]bool) (plan *models.SYBInnerCodePlan, status, message string, err error) {
defer func() {
if recover() != nil {
plan, status, message = nil, models.SYBInnerCodeFailed, "匹配处理异常,请重新匹配"
err = nil
}
}()
return planRecord(ctx, reader, record, used)
}
// Cleanup must not inherit an expired job or HTTP request context.
func failMatchJob(db *gorm.DB, jobID, message string) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var job models.SYBInnerCodeMatchJob
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&job, "id = ?", jobID).Error; err != nil {
return err
}
if job.Status != "pending" && job.Status != "running" {
return nil
}
var ids []uint64
if err := json.Unmarshal([]byte(job.RecordIDsJSON), &ids); err != nil {
return err
}
if err := tx.Model(&models.SYBInnerCodeRecord{}).Where("id IN ? AND business_date = ? AND status IN ?", ids, job.BusinessDate, []string{models.SYBInnerCodePending, models.SYBInnerCodeMatching}).Updates(map[string]any{"status": models.SYBInnerCodeFailed, "result_message": message}).Error; err != nil {
return err
}
return tx.Model(&job).Updates(map[string]any{"status": "failed", "error_message": message, "finished_at": time.Now().UTC()}).Error
})
}
// Called at startup before new jobs can be submitted; never resumes remote writes.
func RecoverInterruptedMatches(db *gorm.DB) error {
var jobs []models.SYBInnerCodeMatchJob
if err := db.Where("status IN ?", []string{"pending", "running"}).Find(&jobs).Error; err != nil {
return err
}
for _, job := range jobs {
if err := failMatchJob(db, job.ID, "服务重启,匹配任务中断,请重新匹配"); err != nil {
return err
}
}
return nil
}
func loadReservedDetails(ctx context.Context, db *gorm.DB, selected []models.SYBInnerCodeRecord) (map[int64]bool, error) {
selectedIDs := map[uint64]bool{}
for _, r := range selected {
selectedIDs[r.ID] = true
}
var plans []models.SYBInnerCodePlan
if err := db.WithContext(ctx).Joins("JOIN syb_inner_code_record r ON r.id = syb_inner_code_plan.record_id").Where("r.status IN ?", []string{models.SYBInnerCodeReady, models.SYBInnerCodeQueued, models.SYBInnerCodeApplying, models.SYBInnerCodeUpdated, models.SYBInnerCodeAlreadyFilled, models.SYBInnerCodeNeedsCheck}).Find(&plans).Error; err != nil {
return nil, err
}
used := map[int64]bool{}
for _, p := range plans {
if !selectedIDs[p.RecordID] {
used[p.DetailID] = true
var items []plannedRemoteItem
_ = json.Unmarshal([]byte(p.RemoteItemsJSON), &items)
for _, item := range items {
if item.DetailID > 0 {
used[item.DetailID] = true
}
}
}
}
return used, nil
}
type plannedRemoteItem struct {
Code string `json:"code"`
DetailID int64 `json:"detailId"`
Source string `json:"source"`
RemoteCode string `json:"remoteCode"`
}
func planRecord(ctx context.Context, reader MatchReader, record models.SYBInnerCodeRecord, used map[int64]bool) (*models.SYBInnerCodePlan, string, string, error) {
rows, err := reader.ListByOrderNumber(ctx, record.OrderNumber)
if err != nil {
return nil, "", "", err
}
if len(rows) == 0 {
return nil, models.SYBInnerCodeFailed, "SYB 未找到货运单", nil
}
if len(rows) != 1 {
return nil, models.SYBInnerCodeSkipped, "同一订单号命中多张 SYB 货运单,不能自动选择", nil
}
stocks, err := reader.DetailListByStock(ctx, []int64{rows[0].ID})
if err != nil {
return nil, "", "", err
}
if len(stocks) != 1 || stocks[0].ID != rows[0].ID {
return nil, models.SYBInnerCodeFailed, "SYB 没有唯一返回货运单详情", nil
}
stock := stocks[0]
eligible := make([]sybclient.DetailItem, 0)
for _, item := range stock.Details {
if used[item.ID] || rawText(item.Raw["purchasePlatform"]) != "" || rawText(item.Raw["purchaseCode"]) != "" {
continue
}
eligible = append(eligible, item)
}
if len(eligible) == 0 {
return nil, models.SYBInnerCodeSkipped, "没有可用的 SYB 商品明细", nil
}
specMatches := matchSpec(record.SpecRaw, eligible)
if len(specMatches) == 0 {
return nil, models.SYBInnerCodeSkipped, "候选商品中没有相同规格", nil
}
matches, reason := matchEvidence(record.Stall, record.SourceSKURaw, specMatches)
if reason != "" {
return nil, models.SYBInnerCodeSkipped, reason, nil
}
if len(matches) == 0 {
return nil, models.SYBInnerCodeSkipped, "相同规格候选的原始 SKU 或档口货号未匹配", nil
}
count := len(record.Items)
if count == 0 {
return nil, models.SYBInnerCodeSkipped, "记录没有入库码", nil
}
var chosen []sybclient.DetailItem
if len(matches) == 1 && matches[0].ProductQty == count {
chosen = []sybclient.DetailItem{matches[0]}
} else if len(matches) == count && count > 1 {
for _, item := range matches {
if item.ProductQty != 1 {
return nil, models.SYBInnerCodeSkipped, "相同规格候选数量不明确,不能自动分配", nil
}
}
codes := make([]string, count)
for i, it := range record.Items {
codes[i] = it.Code
}
assigned, reason := assignExistingBoundItems(record.Stall, codes, matches)
if reason != "" {
return nil, models.SYBInnerCodeSkipped, reason, nil
}
chosen = assigned
} else if len(matches) > 1 {
return nil, models.SYBInnerCodeSkipped, "同一订单存在多条相同规格候选商品,不能自动选择", nil
} else {
return nil, models.SYBInnerCodeSkipped, fmt.Sprintf("SYB 商品数量与入库码数量不一致(%d/%d)", matches[0].ProductQty, count), nil
}
if len(chosen) == 0 {
return nil, models.SYBInnerCodeSkipped, "未形成唯一的商品分配,不能自动选择", nil
}
primary := chosen[0]
items := make([]plannedRemoteItem, 0, count)
placeholder := 0
allFilled := true
for i, code := range record.Items {
detailID := int64(0)
source := "placeholder"
remote := ""
if i < len(chosen) {
detailID = chosen[i].ID
source = "existing"
remote = rawText(chosen[i].Raw["innerExpCode"])
} else if i == 0 {
detailID = primary.ID
source = "original"
remote = rawText(primary.Raw["innerExpCode"])
} else {
placeholder++
}
if remote != code.Code {
allFilled = false
}
items = append(items, plannedRemoteItem{Code: code.Code, DetailID: detailID, Source: source, RemoteCode: remote})
}
for _, item := range chosen {
used[item.ID] = true
}
rawItems, _ := json.Marshal(items)
evidence, _ := json.Marshal(map[string]any{"stockId": stock.ID, "detailId": primary.ID, "spec": primary.ProductSpec, "sku": rawText(primary.Raw["sku"]), "variationSku": rawText(primary.Raw["variationSku"]), "items": items})
sum := sha256.Sum256(evidence)
replace := 0
if old := rawText(primary.Raw["innerExpCode"]); old != "" && !containsCode(record.Items, old) {
replace = 1
}
plan := &models.SYBInnerCodePlan{StockID: stock.ID, DetailID: primary.ID, SYBSpec: primary.ProductSpec, SYBSKU: rawText(primary.Raw["sku"]), SYBVariationSKU: rawText(primary.Raw["variationSku"]), PurchasePlatform: rawText(primary.Raw["purchasePlatform"]), PurchaseCode: rawText(primary.Raw["purchaseCode"]), RemoteInnerCode: rawText(primary.Raw["innerExpCode"]), RemoteItemsJSON: string(rawItems), PlaceholderCount: placeholder, ReplaceOldCodeCount: replace, EvidenceHash: hex.EncodeToString(sum[:])}
if allFilled {
return plan, models.SYBInnerCodeAlreadyFilled, "远端已存在全部入库码,无需重复写入", nil
}
return plan, models.SYBInnerCodeReady, "唯一匹配,等待确认回写", nil
}
// assignExistingBoundItems 把 N 个待写入入库码按顺序分配给 N 个数量为 1 的候选商品明细。
// 修复 #289:候选明细的匹配顺序(按 ID 排序)未必与目标码顺序一致,若单纯按下标
// 对应,会把已经正确绑定某个目标码的明细错误地重新分配给另一个码。这里先按“候选
// 明细已有的入库码值”精确匹配对应的目标码,保留既有正确绑定不动;再把剩余尚未
// 写入任何码的空白明细(按 ID 排序)依次填充给还没有候选的目标码位置。
// 移植自 cmautobuy `planExistingMatchedInnerCodeItems` 的一致性护栏(代码评审补充):
// 候选来自 NormalizeSpecKey 归一化匹配,原始 ProductSpec/sku/variationSku 可能在
// 归一化后相同但原始值不同,必须逐一比对最低 ID 候选,避免跨真正不同商品自动分配;
// 无 SKU 回退路径可能返回从未做过档口校验的候选,这里逐一重新校验;同时拒绝无效
// 或重复的商品明细 ID。
func assignExistingBoundItems(stall string, codes []string, matches []sybclient.DetailItem) ([]sybclient.DetailItem, string) {
sorted := append([]sybclient.DetailItem(nil), matches...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].ID < sorted[j].ID })
seenIDs := make(map[int64]bool, len(sorted))
for _, item := range sorted {
if item.ID <= 0 || seenIDs[item.ID] {
return nil, "重复候选包含无效或重复的商品明细 ID,不能自动逐件分配"
}
seenIDs[item.ID] = true
}
first := sorted[0]
wantSpec := first.ProductSpec
wantSKU := rawText(first.Raw["sku"])
wantVariationSKU := rawText(first.Raw["variationSku"])
for _, item := range sorted {
if item.ProductSpec != wantSpec || rawText(item.Raw["sku"]) != wantSKU || rawText(item.Raw["variationSku"]) != wantVariationSKU {
return nil, "重复候选的规格或 SKU 身份不一致,不能自动逐件分配"
}
}
if strings.TrimSpace(stall) != "" {
for _, item := range sorted {
if !stallMatches(stall, item) {
return nil, "重复候选的档口及货号不一致,不能自动逐件分配"
}
}
}
assigned := make([]sybclient.DetailItem, len(codes))
taken := make([]bool, len(codes))
codeIndex := make(map[string]int, len(codes))
for i, code := range codes {
codeIndex[code] = i
}
blanks := make([]int, 0, len(sorted))
for si, item := range sorted {
remote := rawText(item.Raw["innerExpCode"])
if remote == "" {
blanks = append(blanks, si)
continue
}
idx, ok := codeIndex[remote]
if !ok {
return nil, "候选商品明细存在非目标入库码,不能自动逐件分配"
}
if taken[idx] {
return nil, "同一入库码在候选商品中出现多次,不能自动逐件分配"
}
assigned[idx] = item
taken[idx] = true
}
bi := 0
for i := range assigned {
if taken[i] {
continue
}
if bi >= len(blanks) {
return nil, "现成空白明细不足,不能完成逐件分配"
}
assigned[i] = sorted[blanks[bi]]
taken[i] = true
bi++
}
if bi != len(blanks) {
return nil, "现成空白明细多于待写入入库码,不能自动逐件分配"
}
return assigned, ""
}
func matchSpec(spec string, items []sybclient.DetailItem) []sybclient.DetailItem {
result := []sybclient.DetailItem{}
for _, item := range items {
if item.ProductSpec == spec {
result = append(result, item)
}
}
if len(result) > 0 {
return result
}
key := NormalizeSpecKey(spec)
for _, item := range items {
if NormalizeSpecKey(item.ProductSpec) == key {
result = append(result, item)
}
}
return result
}
// Compare layout-only controls without changing stored evidence or ordinary spaces.
func normalizeMatchLayout(value string) string {
return strings.TrimSpace(strings.NewReplacer("\r", "", "\n", "", "\t", "").Replace(value))
}
func matchEvidence(stall, sourceSKU string, items []sybclient.DetailItem) ([]sybclient.DetailItem, string) {
sourceSKU = normalizeMatchLayout(sourceSKU)
if sourceSKU != "" {
skuMatches := []sybclient.DetailItem{}
for _, item := range items {
if normalizeMatchLayout(rawText(item.Raw["sku"])) == sourceSKU || normalizeMatchLayout(rawText(item.Raw["variationSku"])) == sourceSKU {
skuMatches = append(skuMatches, item)
}
}
if len(skuMatches) > 1 {
return nil, "原始 SKU 候选重复,不能自动选择"
}
if len(skuMatches) == 1 {
stallMatches := strictStall(stall, items)
if len(stallMatches) > 1 {
return nil, "档口货号候选重复,不能自动选择"
}
if len(stallMatches) == 1 && stallMatches[0].ID != skuMatches[0].ID {
return nil, "原始 SKU 与档口货号冲突,不能自动选择"
}
return skuMatches, ""
}
}
stallMatches := strictStall(stall, items)
if len(stallMatches) > 0 {
return stallMatches, ""
}
fallback := []sybclient.DetailItem{}
for _, item := range items {
if rawText(item.Raw["sku"]) == "" && rawText(item.Raw["variationSku"]) == "" {
fallback = append(fallback, item)
}
}
return fallback, ""
}
func strictStall(stall string, items []sybclient.DetailItem) []sybclient.DetailItem {
stall = normalizeMatchLayout(stall)
if stall == "" {
return nil
}
result := []sybclient.DetailItem{}
for _, item := range items {
if stallMatches(stall, item) {
result = append(result, item)
}
}
return result
}
// stallMatches 移植自 cmautobuy `innerCodeStallMatches`(#259/#273 修复):
// 档口名与货号以最后一个 `#` 切分;货号只与字母数字 token 比较;纯数字货号要求
// 候选中同时包含档口名才允许前导零等价(如 "067"≡"67");非数字货号要求精确
// token 匹配;ProductSpec 只在以货号开头时才算命中;货号为空时回退为档口名包含判断。
func stallMatches(stall string, item sybclient.DetailItem) bool {
stall = normalizeMatchLayout(stall)
if stall == "" {
return false
}
sku := normalizeMatchLayout(rawText(item.Raw["sku"]))
variation := normalizeMatchLayout(rawText(item.Raw["variationSku"]))
blob := sku + " " + variation + " " + item.ProductSpec
if strings.Contains(blob, stall) {
return true
}
name, article, hasArticle := splitStall(stall)
if !hasArticle {
return false
}
if article == "" {
return name != "" && (strings.Contains(sku, name) || strings.Contains(variation, name))
}
if isNumericArticle(article) {
nameMatches := name != "" && (strings.Contains(sku, name) || strings.Contains(variation, name))
if !nameMatches {
return false
}
return textHasNumericArticle(sku, article) ||
textHasNumericArticle(variation, article) ||
productSpecStartsWithArticle(item.ProductSpec, article, true)
}
return textHasExactArticle(sku, article) ||
textHasExactArticle(variation, article) ||
productSpecStartsWithArticle(item.ProductSpec, article, false)
}
// splitStall 从档口名称#货号取最后一个 #,避免档口名称本身含 # 时截错。
func splitStall(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 isNumericArticle(article string) bool {
if article == "" {
return false
}
for _, char := range article {
if !unicode.IsDigit(char) {
return false
}
}
return true
}
func textHasNumericArticle(text, article string) bool {
target := normalizeNumericArticle(article)
for _, token := range articleTokens(text) {
if isNumericArticle(token) && normalizeNumericArticle(token) == target {
return true
}
}
return false
}
func normalizeNumericArticle(article string) string {
normalized := strings.TrimLeft(article, "0")
if normalized == "" {
return "0"
}
return normalized
}
func textHasExactArticle(text, article string) bool {
for _, token := range articleTokens(text) {
if token == article {
return true
}
}
return false
}
func productSpecStartsWithArticle(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 isNumericArticle(prefix) && normalizeNumericArticle(prefix) == normalizeNumericArticle(article)
}
return prefix == article
}
// articleTokens 只把连续字母或数字视为货号候选,标点、【】、#、横线、空格自然成为
// 边界:能识别 "067【档口】",又不会把 "PDD256437" 中间的数字误认为独立货号。
func articleTokens(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 rawText(value any) string {
switch v := value.(type) {
case string:
return strings.TrimSpace(v)
case json.Number:
return v.String()
case float64:
return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.6f", v), "0"), ".")
default:
return ""
}
}
func containsCode(items []models.SYBInnerCodeItem, value string) bool {
for _, item := range items {
if item.Code == value {
return true
}
}
return false
}
func compact(value string, max int) string {
value = strings.TrimSpace(value)
runes := []rune(value)
if len(runes) > max {
return string(runes[:max])
}
return value
}