fix: 档口入库码按货运单号直查顺运宝 (#245)

This commit is contained in:
chengma
2026-08-15 16:27:03 +08:00
parent 4d8434b499
commit b47e7ed9a9
5 changed files with 412 additions and 111 deletions
+36 -49
View File
@@ -16,11 +16,18 @@ import (
"cmautobuy/admin/syb"
)
// InnerCodeDetailReader 只开放规划所需的顺运宝只读调用,方便无网络测试。
// 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 是一次规划的逐状态统计。
@@ -34,7 +41,7 @@ type InnerCodePlanResult struct {
// PlanInnerCodeRecords 对某个业务日期选中的可重规划记录读取最新远端详情并原子保存计划。
// 本函数绝不调用删除或更新顺运宝接口。
func PlanInnerCodeRecords(ctx context.Context, db *sql.DB, reader InnerCodeDetailReader, businessDate string, selectedIDs []int64) (*InnerCodePlanResult, error) {
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")
}
@@ -61,11 +68,10 @@ func PlanInnerCodeRecords(ctx context.Context, db *sql.DB, reader InnerCodeDetai
selectedSet[record.ID] = true
}
reservedDetails := innerCodeReservedDetails(contextRecords, selectedSet)
snapshots, err := repository.ListInnerCodeSybSnapshots(db, orders)
stockIDsByOrder, err := readInnerCodeStockIDs(ctx, reader, orders)
if err != nil {
return nil, err
}
stockIDsByOrder := innerCodeStockIDsByOrder(snapshots)
requested := uniqueInnerCodeStockIDs(records, stockIDsByOrder)
detailsByID, err := readInnerCodeDetails(ctx, reader, requested)
if err != nil {
@@ -131,38 +137,34 @@ func uniqueInnerCodeOrders(records []model.InnerCodeRecord) []string {
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
// 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)
}
if sets[snapshot.OrderNumber] == nil {
sets[snapshot.OrderNumber] = make(map[int64]bool)
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)
}
}
sets[snapshot.OrderNumber][stockID] = true
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
result[order] = ids
}
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"])
return result, nil
}
func uniqueInnerCodeStockIDs(records []model.InnerCodeRecord, byOrder map[string][]int64) []int64 {
@@ -229,7 +231,7 @@ func planInnerCodeRowsWithReserved(records []model.InnerCodeRecord, stockIDsByOr
stockIDs := stockIDsByOrder[record.OrderNumber]
if len(stockIDs) == 0 {
plan.Status = model.InnerCodeFailed
plan.ResultMessage = "本地未找到对应的顺运宝货运单"
plan.ResultMessage = "顺运宝未找到货运单"
plans = append(plans, plan)
continue
}
@@ -384,18 +386,3 @@ func innerCodeRawText(value any) string {
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
}
}
+121 -9
View File
@@ -2,9 +2,12 @@ package service
import (
"context"
"errors"
"reflect"
"testing"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
"cmautobuy/admin/syb"
)
@@ -41,17 +44,123 @@ func TestReadInnerCodeDetails_总量不限且每批最多100(t *testing.T) {
}
}
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)
type orderQueryInnerCodeReader struct {
rowsByOrder map[string][]syb.StockRow
detailsByID map[int64]syb.StockDetail
queries []string
detailCalls [][]int64
err error
}
func (r *orderQueryInnerCodeReader) ListByOrderNumber(_ context.Context, order string) ([]syb.StockRow, error) {
r.queries = append(r.queries, order)
if r.err != nil {
return nil, r.err
}
return r.rowsByOrder[order], nil
}
func (r *orderQueryInnerCodeReader) DetailListByStock(_ context.Context, ids []int64) ([]syb.StockDetail, error) {
r.detailCalls = append(r.detailCalls, append([]int64(nil), ids...))
result := make([]syb.StockDetail, 0, len(ids))
for _, id := range ids {
if detail, exists := r.detailsByID[id]; exists {
result = append(result, detail)
}
}
if _, ok := innerCodeStockIDFromJSON(`{"stock":{}}`); ok {
t.Fatal("缺少 stock.id 时不应成功")
return result, nil
}
func TestReadInnerCodeStockIDs_按去重订单直查远端并保留零和多命中(t *testing.T) {
reader := &orderQueryInnerCodeReader{rowsByOrder: map[string][]syb.StockRow{
"ORDER-1": {{ID: 20, Code: "ORDER-1"}, {ID: 10, Code: "ORDER-1"}},
"ORDER-2": {},
}}
got, err := readInnerCodeStockIDs(context.Background(), reader, []string{"ORDER-1", "ORDER-2"})
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(reader.queries, []string{"ORDER-1", "ORDER-2"}) ||
!reflect.DeepEqual(got["ORDER-1"], []int64{10, 20}) || len(got["ORDER-2"]) != 0 {
t.Fatalf("远端货运单定位结果不正确 queries=%v got=%v", reader.queries, got)
}
}
func TestReadInnerCodeStockIDs_协议或网络错误中止整批(t *testing.T) {
reader := &orderQueryInnerCodeReader{err: errors.New("network down")}
if _, err := readInnerCodeStockIDs(context.Background(), reader, []string{"ORDER"}); err == nil {
t.Fatal("远端查询失败时必须中止整批")
}
}
func TestUniqueInnerCodeOrders_同一货运单只查询一次(t *testing.T) {
orders := uniqueInnerCodeOrders([]model.InnerCodeRecord{
{OrderNumber: "ORDER-1"}, {OrderNumber: "ORDER-1"}, {OrderNumber: "ORDER-2"},
})
if !reflect.DeepEqual(orders, []string{"ORDER-1", "ORDER-2"}) {
t.Fatalf("订单号去重结果不正确: %v", orders)
}
}
func TestPlanInnerCodeRecords_本地没有SybOrder仍可直查远端生成计划(t *testing.T) {
db := newTestDB(t)
now := model.NowISO()
user := model.User{
UserID: "INNER-CODE-REMOTE-USER", Username: "inner-code-remote-user", PasswordHash: "test",
Role: model.RoleAdmin, Status: model.UserActive, PasswordChangedAt: now, CreatedAt: now, UpdatedAt: now,
}
if err := repository.CreateUser(db, user); err != nil {
t.Fatal(err)
}
tx, err := db.Begin()
if err != nil {
t.Fatal(err)
}
_, err = repository.UpsertInnerCodeImportRow(tx, model.InnerCodeImportRow{
BusinessDate: "2026-08-15", SourceRow: 4, OrderNumber: "REMOTE-ORDER",
Stall: "", SpecRaw: "黑色,M", SpecKey: NormalizeInnerCodeSpecKey("黑色,M"),
InnerCode: "DK260815TEST01", SourceDuplicateCount: 1, CreatedByUserID: user.UserID,
}, now)
if err != nil {
tx.Rollback()
t.Fatal(err)
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
var recordID int64
if err := db.QueryRow(`SELECT id FROM syb_inner_code_records WHERE order_number='REMOTE-ORDER'`).Scan(&recordID); err != nil {
t.Fatal(err)
}
var localSybCount int
if err := db.QueryRow(`SELECT COUNT(*) FROM syb_orders WHERE order_no='REMOTE-ORDER'`).Scan(&localSybCount); err != nil {
t.Fatal(err)
}
if localSybCount != 0 {
t.Fatal("测试前提失败:本地不应存在该货运单")
}
item := innerCodeTestItem(9002, "黑色,M", nil)
reader := &orderQueryInnerCodeReader{
rowsByOrder: map[string][]syb.StockRow{"REMOTE-ORDER": {{ID: 9001, Code: "REMOTE-ORDER"}}},
detailsByID: map[int64]syb.StockDetail{9001: {ID: 9001, Code: "REMOTE-ORDER", Details: []syb.DetailItem{item}}},
}
result, err := PlanInnerCodeRecords(context.Background(), db, reader, "2026-08-15", []int64{recordID})
if err != nil {
t.Fatal(err)
}
if result.Ready != 1 || !reflect.DeepEqual(reader.queries, []string{"REMOTE-ORDER"}) ||
!reflect.DeepEqual(reader.detailCalls, [][]int64{{9001}}) {
t.Fatalf("远端规划编排不正确 result=%+v queries=%v detailCalls=%v", result, reader.queries, reader.detailCalls)
}
var status model.InnerCodeStatus
var stockID, detailID int64
if err := db.QueryRow(`SELECT status,stock_id,detail_id FROM syb_inner_code_records WHERE id=?`, recordID).
Scan(&status, &stockID, &detailID); err != nil {
t.Fatal(err)
}
if status != model.InnerCodeReady || stockID != 9001 || detailID != 9002 {
t.Fatalf("保存计划不正确 status=%s stock=%d detail=%d", status, stockID, detailID)
}
}
@@ -92,6 +201,9 @@ func TestPlanInnerCodeRows_聚合码放行且其他异常继续阻断(t *testing
t.Errorf("第 %d 条 status=%s message=%s,期望 %s", index, plans[index].Status, plans[index].ResultMessage, want)
}
}
if plans[4].ResultMessage != "顺运宝未找到货运单" {
t.Fatalf("零命中提示不正确: %q", plans[4].ResultMessage)
}
}
func TestPlanInnerCodeRows_同一远端明细不会占用两次(t *testing.T) {