feat: 规划档口入库码确定性匹配 (#232)

This commit is contained in:
chengma
2026-08-15 08:51:38 +08:00
parent a4ffa27ba9
commit 82ab80d1c1
3 changed files with 539 additions and 0 deletions
+107
View File
@@ -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
+341
View File
@@ -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
}
}
+91
View File
@@ -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}
}