fix: 使用原始SKU辅助档口匹配 (#259)
This commit is contained in:
@@ -19,10 +19,11 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
MaxInnerCodeUploadBytes = 10 * 1024 * 1024
|
||||
MaxInnerCodeImportRows = 5000
|
||||
MaxInnerCodeValueRunes = 128
|
||||
innerCodeSheetName = "标签入库码映射"
|
||||
MaxInnerCodeUploadBytes = 10 * 1024 * 1024
|
||||
MaxInnerCodeImportRows = 5000
|
||||
MaxInnerCodeValueRunes = 128
|
||||
MaxInnerCodeSourceSKURunes = 500
|
||||
innerCodeSheetName = "标签入库码映射"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -270,6 +271,7 @@ func parseInnerCodeRows(rows *excelize.Rows, originalFilename, actorUserID strin
|
||||
specRaw := strings.ReplaceAll(innerCodeCell(columns, specColumn), "\r", " ")
|
||||
specRaw = strings.TrimSpace(strings.ReplaceAll(specRaw, "\n", " "))
|
||||
stall := innerCodeStall(columns, headers)
|
||||
sourceSKURaw := innerCodeRawNamedCell(columns, headers, "原始SKU")
|
||||
shop := innerCodeNamedCell(columns, headers, "店铺名称")
|
||||
printSequence := 0
|
||||
if raw := innerCodeNamedCell(columns, headers, "标签打印序号"); raw != "" {
|
||||
@@ -277,7 +279,7 @@ func parseInnerCodeRows(rows *excelize.Rows, originalFilename, actorUserID strin
|
||||
printSequence = value
|
||||
}
|
||||
}
|
||||
if err := validateInnerCodeFields(rowNumber, orderNumber, shop, stall, specRaw, innerCode); err != nil {
|
||||
if err := validateInnerCodeFields(rowNumber, orderNumber, shop, stall, sourceSKURaw, specRaw, innerCode); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
specKey := NormalizeInnerCodeSpecKey(specRaw)
|
||||
@@ -285,7 +287,8 @@ func parseInnerCodeRows(rows *excelize.Rows, originalFilename, actorUserID strin
|
||||
parsed = append(parsed, innerCodeParsedRow{InnerCodeImportRow: model.InnerCodeImportRow{
|
||||
BusinessDate: businessDate, SourceRow: rowNumber, PrintSequence: printSequence,
|
||||
OrderNumber: orderNumber, ShopName: shop, Stall: stall, SpecRaw: specRaw,
|
||||
SpecKey: specKey, InnerCode: innerCode, SourceDuplicateCount: 1,
|
||||
SourceSKURaw: sourceSKURaw,
|
||||
SpecKey: specKey, InnerCode: innerCode, SourceDuplicateCount: 1,
|
||||
CreatedByUserID: actorUserID,
|
||||
}, businessKey: key})
|
||||
}
|
||||
@@ -318,6 +321,20 @@ func parseInnerCodeRows(rows *excelize.Rows, originalFilename, actorUserID strin
|
||||
seen[original.businessKey] = true
|
||||
group := groups[original.businessKey]
|
||||
row := original.InnerCodeImportRow
|
||||
var sourceSKUCompare string
|
||||
for _, member := range group {
|
||||
candidate := normalizeInnerCodeSourceSKU(member.SourceSKURaw)
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
if sourceSKUCompare != "" && candidate != sourceSKUCompare {
|
||||
return nil, nil, fmt.Errorf("第 %d 行起的同业务键包含不同原始 SKU,请拆分或修正源数据", row.SourceRow)
|
||||
}
|
||||
if sourceSKUCompare == "" {
|
||||
sourceSKUCompare = candidate
|
||||
row.SourceSKURaw = member.SourceSKURaw
|
||||
}
|
||||
}
|
||||
codes := make([]string, 0, len(group))
|
||||
seenCodes := make(map[string]bool, len(group))
|
||||
for _, member := range group {
|
||||
@@ -389,6 +406,10 @@ func innerCodeHeaderMap(columns []string) (map[string]int, int) {
|
||||
for index, raw := range columns {
|
||||
name := strings.TrimSpace(raw)
|
||||
result[name] = index
|
||||
compactName := strings.ToLower(innerCodeSpaces.ReplaceAllString(name, ""))
|
||||
if strings.HasPrefix(compactName, "原始sku") {
|
||||
result["原始SKU"] = index
|
||||
}
|
||||
if name == "清洗后规格" || (specColumn < 0 && strings.HasPrefix(name, "原始产品规格")) {
|
||||
specColumn = index
|
||||
}
|
||||
@@ -416,6 +437,18 @@ func innerCodeNamedCell(columns []string, headers map[string]int, name string) s
|
||||
return innerCodeCell(columns, index)
|
||||
}
|
||||
|
||||
func innerCodeRawNamedCell(columns []string, headers map[string]int, name string) string {
|
||||
index, ok := headers[name]
|
||||
if !ok || index < 0 || index >= len(columns) {
|
||||
return ""
|
||||
}
|
||||
return columns[index]
|
||||
}
|
||||
|
||||
func normalizeInnerCodeSourceSKU(value string) string {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func innerCodeCell(columns []string, index int) string {
|
||||
if index < 0 || index >= len(columns) {
|
||||
return ""
|
||||
@@ -423,7 +456,7 @@ func innerCodeCell(columns []string, index int) string {
|
||||
return strings.TrimSpace(columns[index])
|
||||
}
|
||||
|
||||
func validateInnerCodeFields(row int, order, shop, stall, spec, code string) error {
|
||||
func validateInnerCodeFields(row int, order, shop, stall, sourceSKU, spec, code string) error {
|
||||
if strings.Contains(code, ",") {
|
||||
return fmt.Errorf("第 %d 行内部档口入库码不能包含英文逗号", row)
|
||||
}
|
||||
@@ -437,6 +470,7 @@ func validateInnerCodeFields(row int, order, shop, stall, spec, code string) err
|
||||
max int
|
||||
}{
|
||||
{"Shopee订单编号", order, 64}, {"店铺名称", shop, 191}, {"档口及货号", stall, 191},
|
||||
{"原始 SKU", sourceSKU, MaxInnerCodeSourceSKURunes},
|
||||
{"规格", spec, 500}, {"内部档口入库码", code, MaxInnerCodeValueRunes},
|
||||
} {
|
||||
if utf8.RuneCountInString(field.value) > field.max {
|
||||
|
||||
@@ -63,6 +63,35 @@ func TestParseInnerCodeWorkbook_表头前置行与重复业务组(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInnerCodeWorkbook_识别原始SKU说明表头并保留原文(t *testing.T) {
|
||||
book := excelize.NewFile()
|
||||
defaultSheet := book.GetSheetList()[0]
|
||||
if err := book.SetSheetName(defaultSheet, innerCodeSheetName); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sourceSKU := " 上寮-青春网购工厂店8916##(&9)\n"
|
||||
setInnerCodeWorkbookRows(t, book, [][]string{
|
||||
{"内部档口入库码", "Shopee订单编号", "清洗后规格", "档口名称", "档口货号", "生成日期", "原始 SKU(源Excel C列,原样直拷)"},
|
||||
{"DK001", "26081387A2EC2N", "紫色,XL", "上寮-青春网购工厂店", "8916", "2026-08-17", sourceSKU},
|
||||
})
|
||||
_, rows, err := parseInnerCodeWorkbook(book, "labels.xlsx", "user-1")
|
||||
if err != nil || len(rows) != 1 {
|
||||
t.Fatalf("rows=%+v err=%v", rows, err)
|
||||
}
|
||||
if rows[0].SourceSKURaw != sourceSKU || normalizeInnerCodeSourceSKU(rows[0].SourceSKURaw) != "上寮-青春网购工厂店8916##(&9)" {
|
||||
t.Fatalf("原始 SKU 未按要求保留或比较: %q", rows[0].SourceSKURaw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInnerCodeHeaderMap_兼容原始SKU大小写和空格(t *testing.T) {
|
||||
for _, header := range []string{"原始SKU", "原始sku", "原始 SKU", "原始sku(源Excel C列,原样直拷)"} {
|
||||
headers, _ := innerCodeHeaderMap([]string{header})
|
||||
if headers["原始SKU"] != 0 {
|
||||
t.Fatalf("未识别表头 %q: %+v", header, headers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInnerCodeWorkbook_同日入库码指向不同业务键时报错(t *testing.T) {
|
||||
book := excelize.NewFile()
|
||||
defaultSheet := book.GetSheetList()[0]
|
||||
|
||||
@@ -263,10 +263,15 @@ func planInnerCodeRowsWithReserved(records []model.InnerCodeRecord, stockIDsByOr
|
||||
plans = append(plans, innerCodeSkippedPlan(plan, message))
|
||||
continue
|
||||
}
|
||||
matches := matchInnerCodeItems(record.SpecRaw, record.Stall, eligible)
|
||||
specMatches := matchInnerCodeSpecItems(record.SpecRaw, eligible)
|
||||
matches, evidenceReason := matchInnerCodeItemsWithEvidence(record.Stall, record.SourceSKURaw, specMatches)
|
||||
if evidenceReason != "" {
|
||||
plans = append(plans, innerCodeSkippedPlan(plan, evidenceReason))
|
||||
continue
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
reason := "候选商品中没有相同规格"
|
||||
if len(matchInnerCodeItems(record.SpecRaw, "", eligible)) > 0 {
|
||||
if len(specMatches) > 0 {
|
||||
reason = "规格能匹配,但档口及货号与候选商品不一致"
|
||||
}
|
||||
plans = append(plans, innerCodeSkippedPlan(plan, reason))
|
||||
@@ -320,6 +325,10 @@ func innerCodeSkippedPlan(plan model.InnerCodeRecord, message string) model.Inne
|
||||
}
|
||||
|
||||
func matchInnerCodeItems(spec, stall string, eligible []syb.DetailItem) []syb.DetailItem {
|
||||
return filterInnerCodeItemsByStall(matchInnerCodeSpecItems(spec, eligible), stall)
|
||||
}
|
||||
|
||||
func matchInnerCodeSpecItems(spec string, eligible []syb.DetailItem) []syb.DetailItem {
|
||||
matches := make([]syb.DetailItem, 0)
|
||||
for _, item := range eligible {
|
||||
if item.ProductSpec == spec {
|
||||
@@ -337,7 +346,34 @@ func matchInnerCodeItems(spec, stall string, eligible []syb.DetailItem) []syb.De
|
||||
}
|
||||
}
|
||||
}
|
||||
return filterInnerCodeItemsByStall(matches, stall)
|
||||
return matches
|
||||
}
|
||||
|
||||
func matchInnerCodeItemsWithEvidence(stall, sourceSKURaw string, specMatches []syb.DetailItem) ([]syb.DetailItem, string) {
|
||||
sourceSKU := normalizeInnerCodeSourceSKU(sourceSKURaw)
|
||||
if sourceSKU != "" {
|
||||
sourceMatches := make([]syb.DetailItem, 0)
|
||||
for _, item := range specMatches {
|
||||
if normalizeInnerCodeSourceSKU(innerCodeRawText(item.Raw["sku"])) == sourceSKU ||
|
||||
normalizeInnerCodeSourceSKU(innerCodeRawText(item.Raw["variationSku"])) == sourceSKU {
|
||||
sourceMatches = append(sourceMatches, item)
|
||||
}
|
||||
}
|
||||
if len(sourceMatches) > 1 {
|
||||
return nil, "原始 SKU 候选重复,不能自动选择"
|
||||
}
|
||||
if len(sourceMatches) == 1 {
|
||||
stallMatches := filterInnerCodeItemsByStallStrict(specMatches, stall)
|
||||
if len(stallMatches) > 1 {
|
||||
return nil, "档口货号候选重复,不能自动选择"
|
||||
}
|
||||
if len(stallMatches) == 1 && stallMatches[0].ID != sourceMatches[0].ID {
|
||||
return nil, "原始 SKU 与档口货号冲突,不能自动选择"
|
||||
}
|
||||
return sourceMatches, ""
|
||||
}
|
||||
}
|
||||
return filterInnerCodeItemsByStall(specMatches, stall), ""
|
||||
}
|
||||
|
||||
func filterInnerCodeItemsByStall(items []syb.DetailItem, stall string) []syb.DetailItem {
|
||||
@@ -362,6 +398,20 @@ func filterInnerCodeItemsByStall(items []syb.DetailItem, stall string) []syb.Det
|
||||
return matched
|
||||
}
|
||||
|
||||
func filterInnerCodeItemsByStallStrict(items []syb.DetailItem, stall string) []syb.DetailItem {
|
||||
stall = strings.TrimSpace(stall)
|
||||
if stall == "" {
|
||||
return nil
|
||||
}
|
||||
matched := make([]syb.DetailItem, 0)
|
||||
for _, item := range items {
|
||||
if innerCodeStallMatches(stall, item) {
|
||||
matched = append(matched, item)
|
||||
}
|
||||
}
|
||||
return matched
|
||||
}
|
||||
|
||||
func innerCodeStallMatches(stall string, item syb.DetailItem) bool {
|
||||
sku := innerCodeRawText(item.Raw["sku"])
|
||||
variation := innerCodeRawText(item.Raw["variationSku"])
|
||||
|
||||
@@ -179,6 +179,50 @@ func TestPlanInnerCodeRows_精确与标准化规格均受档口约束(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanInnerCodeRows_原始SKU精确命中VariationSKU(t *testing.T) {
|
||||
record := model.InnerCodeRecord{ID: 31, OrderNumber: "26081387A2EC2N",
|
||||
Stall: "上寮-青春网购工厂店#8916", SourceSKURaw: "上寮-青春网购工厂店8916##(&9)",
|
||||
SpecRaw: "紫色,XL", InnerCode: "DK-31", SourceDuplicateCount: 1}
|
||||
item := innerCodeTestItem(310, "紫色,XL", map[string]any{"variationSku": "上寮-青春网购工厂店8916##(&9)"})
|
||||
plans := planInnerCodeRows([]model.InnerCodeRecord{record},
|
||||
map[string][]int64{record.OrderNumber: {300}}, map[int64]syb.StockDetail{300: {ID: 300, Details: []syb.DetailItem{item}}})
|
||||
if len(plans) != 1 || plans[0].Status != model.InnerCodeReady || plans[0].DetailID != 310 {
|
||||
t.Fatalf("原始 SKU 未生成可回写计划: %+v", plans)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchInnerCodeItemsWithEvidence_原始SKU可命中SKU且保留旧规则兜底(t *testing.T) {
|
||||
items := []syb.DetailItem{
|
||||
innerCodeTestItem(1, "黑色,M", map[string]any{"sku": "源SKU-1"}),
|
||||
innerCodeTestItem(2, "黑色,M", map[string]any{"sku": "B档#2"}),
|
||||
}
|
||||
matched, reason := matchInnerCodeItemsWithEvidence("不匹配#9", " 源SKU-1\n", items)
|
||||
if reason != "" || len(matched) != 1 || matched[0].ID != 1 {
|
||||
t.Fatalf("原始 SKU 精确命中失败 matched=%+v reason=%q", matched, reason)
|
||||
}
|
||||
matched, reason = matchInnerCodeItemsWithEvidence("B档#2", "不存在", items)
|
||||
if reason != "" || len(matched) != 1 || matched[0].ID != 2 {
|
||||
t.Fatalf("旧档口规则兜底失败 matched=%+v reason=%q", matched, reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchInnerCodeItemsWithEvidence_重复和证据冲突均阻断(t *testing.T) {
|
||||
duplicate := []syb.DetailItem{
|
||||
innerCodeTestItem(1, "黑色,M", map[string]any{"sku": "同一SKU"}),
|
||||
innerCodeTestItem(2, "黑色,M", map[string]any{"variationSku": "同一SKU"}),
|
||||
}
|
||||
if matched, reason := matchInnerCodeItemsWithEvidence("", "同一SKU", duplicate); len(matched) != 0 || reason != "原始 SKU 候选重复,不能自动选择" {
|
||||
t.Fatalf("重复候选未阻断 matched=%+v reason=%q", matched, reason)
|
||||
}
|
||||
conflict := []syb.DetailItem{
|
||||
innerCodeTestItem(1, "黑色,M", map[string]any{"sku": "源SKU"}),
|
||||
innerCodeTestItem(2, "黑色,M", map[string]any{"sku": "B档#2"}),
|
||||
}
|
||||
if matched, reason := matchInnerCodeItemsWithEvidence("B档#2", "源SKU", conflict); len(matched) != 0 || reason != "原始 SKU 与档口货号冲突,不能自动选择" {
|
||||
t.Fatalf("证据冲突未阻断 matched=%+v reason=%q", matched, reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanInnerCodeRows_多件数量一致放行且其他异常继续阻断(t *testing.T) {
|
||||
records := []model.InnerCodeRecord{
|
||||
{ID: 1, OrderNumber: "DUP", SpecRaw: "黑色,M", InnerCode: "DK1,DK2", SourceDuplicateCount: 2},
|
||||
|
||||
Reference in New Issue
Block a user