624 lines
22 KiB
Go
624 lines
22 KiB
Go
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
|
||
}
|