Files
cmautobuy/admin/service/inner_code_match.go
T

404 lines
14 KiB
Go

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)
}
// 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 = "", "", ""
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]
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
}
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
}
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))
}
}