fix: 档口入库码按货运单号直查顺运宝 (#245)
This commit is contained in:
@@ -21,12 +21,6 @@ var ErrInnerCodeRestoreConflict = errors.New("已回写或需核对的删除记
|
|||||||
// ErrInnerCodeDeleteConflict 表示批量删除时记录已经不可见或不存在,整批不会部分删除。
|
// ErrInnerCodeDeleteConflict 表示批量删除时记录已经不可见或不存在,整批不会部分删除。
|
||||||
var ErrInnerCodeDeleteConflict = errors.New("部分档口入库码记录已删除或不存在")
|
var ErrInnerCodeDeleteConflict = errors.New("部分档口入库码记录已删除或不存在")
|
||||||
|
|
||||||
// InnerCodeSybSnapshot 是本地顺运宝明细用于解析货运单 stock id 的最小快照。
|
|
||||||
type InnerCodeSybSnapshot struct {
|
|
||||||
OrderNumber string
|
|
||||||
SybData string
|
|
||||||
}
|
|
||||||
|
|
||||||
// InnerCodeListFilter 是独立页面可组合的查询条件。
|
// InnerCodeListFilter 是独立页面可组合的查询条件。
|
||||||
type InnerCodeListFilter struct {
|
type InnerCodeListFilter struct {
|
||||||
BusinessDate string
|
BusinessDate string
|
||||||
@@ -265,37 +259,6 @@ func ListInnerCodePlanningContext(q Execer, businessDate string, orderNumbers []
|
|||||||
return result, nil
|
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 在同一事务中保存一批只读规划结果。
|
// SaveInnerCodePlans 在同一事务中保存一批只读规划结果。
|
||||||
func SaveInnerCodePlans(db *sql.DB, plans []model.InnerCodeRecord, plannedAt string) error {
|
func SaveInnerCodePlans(db *sql.DB, plans []model.InnerCodeRecord, plannedAt string) error {
|
||||||
tx, err := db.Begin()
|
tx, err := db.Begin()
|
||||||
|
|||||||
@@ -16,11 +16,18 @@ import (
|
|||||||
"cmautobuy/admin/syb"
|
"cmautobuy/admin/syb"
|
||||||
)
|
)
|
||||||
|
|
||||||
// InnerCodeDetailReader 只开放规划所需的顺运宝只读调用,方便无网络测试。
|
// InnerCodeDetailReader 只开放读取已知货运单详情的能力,供规划和回写前核验复用。
|
||||||
type InnerCodeDetailReader interface {
|
type InnerCodeDetailReader interface {
|
||||||
DetailListByStock(context.Context, []int64) ([]syb.StockDetail, error)
|
DetailListByStock(context.Context, []int64) ([]syb.StockDetail, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InnerCodePlanReader 是规划阶段需要的顺运宝只读能力。
|
||||||
|
// 货运单定位必须直接查询远端,不能依赖是否已同步进本地 syb_orders。
|
||||||
|
type InnerCodePlanReader interface {
|
||||||
|
InnerCodeDetailReader
|
||||||
|
ListByOrderNumber(context.Context, string) ([]syb.StockRow, error)
|
||||||
|
}
|
||||||
|
|
||||||
const innerCodeReadBatchSize = 100
|
const innerCodeReadBatchSize = 100
|
||||||
|
|
||||||
// InnerCodePlanResult 是一次规划的逐状态统计。
|
// InnerCodePlanResult 是一次规划的逐状态统计。
|
||||||
@@ -34,7 +41,7 @@ type InnerCodePlanResult struct {
|
|||||||
|
|
||||||
// PlanInnerCodeRecords 对某个业务日期选中的可重规划记录读取最新远端详情并原子保存计划。
|
// 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 {
|
if _, err := time.Parse("2006-01-02", businessDate); err != nil {
|
||||||
return nil, fmt.Errorf("业务日期格式应为 YYYY-MM-DD")
|
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
|
selectedSet[record.ID] = true
|
||||||
}
|
}
|
||||||
reservedDetails := innerCodeReservedDetails(contextRecords, selectedSet)
|
reservedDetails := innerCodeReservedDetails(contextRecords, selectedSet)
|
||||||
snapshots, err := repository.ListInnerCodeSybSnapshots(db, orders)
|
stockIDsByOrder, err := readInnerCodeStockIDs(ctx, reader, orders)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
stockIDsByOrder := innerCodeStockIDsByOrder(snapshots)
|
|
||||||
requested := uniqueInnerCodeStockIDs(records, stockIDsByOrder)
|
requested := uniqueInnerCodeStockIDs(records, stockIDsByOrder)
|
||||||
detailsByID, err := readInnerCodeDetails(ctx, reader, requested)
|
detailsByID, err := readInnerCodeDetails(ctx, reader, requested)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -131,38 +137,34 @@ func uniqueInnerCodeOrders(records []model.InnerCodeRecord) []string {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func innerCodeStockIDsByOrder(snapshots []repository.InnerCodeSybSnapshot) map[string][]int64 {
|
// readInnerCodeStockIDs 对勾选记录的货运单号逐个直查顺运宝。
|
||||||
sets := make(map[string]map[int64]bool)
|
// 零命中和多命中交给逐行规划生成明确状态;网络或协议错误会中止整批,
|
||||||
for _, snapshot := range snapshots {
|
// 避免保存一半新计划、一半旧计划。
|
||||||
stockID, ok := innerCodeStockIDFromJSON(snapshot.SybData)
|
func readInnerCodeStockIDs(ctx context.Context, reader InnerCodePlanReader, orders []string) (map[string][]int64, error) {
|
||||||
if !ok || stockID <= 0 {
|
result := make(map[string][]int64, len(orders))
|
||||||
continue
|
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 {
|
ids := make([]int64, 0, len(rows))
|
||||||
sets[snapshot.OrderNumber] = make(map[int64]bool)
|
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)
|
||||||
}
|
}
|
||||||
sets[snapshot.OrderNumber][stockID] = true
|
if row.ID <= 0 {
|
||||||
|
return nil, fmt.Errorf("查询顺运宝货运单 %q 返回非法 id=%d,本次规划未保存", order, row.ID)
|
||||||
}
|
}
|
||||||
result := make(map[string][]int64, len(sets))
|
if !seen[row.ID] {
|
||||||
for order, set := range sets {
|
seen[row.ID] = true
|
||||||
for id := range set {
|
ids = append(ids, row.ID)
|
||||||
result[order] = append(result[order], id)
|
|
||||||
}
|
}
|
||||||
sort.Slice(result[order], func(i, j int) bool { return result[order][i] < result[order][j] })
|
|
||||||
}
|
}
|
||||||
return result
|
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||||
|
result[order] = ids
|
||||||
}
|
}
|
||||||
|
return result, nil
|
||||||
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 {
|
func uniqueInnerCodeStockIDs(records []model.InnerCodeRecord, byOrder map[string][]int64) []int64 {
|
||||||
@@ -229,7 +231,7 @@ func planInnerCodeRowsWithReserved(records []model.InnerCodeRecord, stockIDsByOr
|
|||||||
stockIDs := stockIDsByOrder[record.OrderNumber]
|
stockIDs := stockIDsByOrder[record.OrderNumber]
|
||||||
if len(stockIDs) == 0 {
|
if len(stockIDs) == 0 {
|
||||||
plan.Status = model.InnerCodeFailed
|
plan.Status = model.InnerCodeFailed
|
||||||
plan.ResultMessage = "本地未找到对应的顺运宝货运单"
|
plan.ResultMessage = "顺运宝未找到货运单"
|
||||||
plans = append(plans, plan)
|
plans = append(plans, plan)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -384,18 +386,3 @@ func innerCodeRawText(value any) string {
|
|||||||
return strings.TrimSpace(fmt.Sprint(typed))
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"cmautobuy/admin/model"
|
"cmautobuy/admin/model"
|
||||||
|
"cmautobuy/admin/repository"
|
||||||
"cmautobuy/admin/syb"
|
"cmautobuy/admin/syb"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -41,17 +44,123 @@ func TestReadInnerCodeDetails_总量不限且每批最多100(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestInnerCodeStockIDFromJSON(t *testing.T) {
|
type orderQueryInnerCodeReader struct {
|
||||||
for _, raw := range []string{
|
rowsByOrder map[string][]syb.StockRow
|
||||||
`{"stock":{"id":75104587},"detail":{"id":1}}`,
|
detailsByID map[int64]syb.StockDetail
|
||||||
`{"stock":{"id":"75104587"},"detail":{"id":1}}`,
|
queries []string
|
||||||
} {
|
detailCalls [][]int64
|
||||||
if got, ok := innerCodeStockIDFromJSON(raw); !ok || got != 75104587 {
|
err error
|
||||||
t.Fatalf("解析 stock id 失败 got=%d ok=%v", got, ok)
|
}
|
||||||
|
|
||||||
|
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 {
|
return result, nil
|
||||||
t.Fatal("缺少 stock.id 时不应成功")
|
}
|
||||||
|
|
||||||
|
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)
|
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) {
|
func TestPlanInnerCodeRows_同一远端明细不会占用两次(t *testing.T) {
|
||||||
|
|||||||
+110
-9
@@ -464,10 +464,8 @@ func (c *Client) CheckSession(ctx context.Context, userID int64, username string
|
|||||||
|
|
||||||
// ---------- 货运单列表 ----------
|
// ---------- 货运单列表 ----------
|
||||||
|
|
||||||
// listPayload 组装 /am/stock/listTotal、/am/stock/list 共用的请求体,
|
// stockListPayload 组装 /am/stock/listTotal、/am/stock/list 共用的请求体。
|
||||||
// 见 08 §4.1/§4.2:按日期范围查询(同步用这个),dvalue 是
|
func stockListPayload(query map[string]any, start, pageIndex, pageSize int) map[string]any {
|
||||||
// "起始日期,结束日期",YYYY-MM-DD,逗号分隔。
|
|
||||||
func listPayload(dateFrom, dateTo string, start, pageIndex, pageSize int) map[string]any {
|
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"history": 0,
|
"history": 0,
|
||||||
"length": pageSize,
|
"length": pageSize,
|
||||||
@@ -476,8 +474,14 @@ func listPayload(dateFrom, dateTo string, start, pageIndex, pageSize int) map[st
|
|||||||
"pageIndex": pageIndex,
|
"pageIndex": pageIndex,
|
||||||
"store": false,
|
"store": false,
|
||||||
"columns": columnsPayload(),
|
"columns": columnsPayload(),
|
||||||
"queries": []map[string]any{
|
"queries": []map[string]any{query},
|
||||||
{
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// listPayload 组装同步使用的日期范围条件,dvalue 是
|
||||||
|
// "起始日期,结束日期",YYYY-MM-DD,逗号分隔。
|
||||||
|
func listPayload(dateFrom, dateTo string, start, pageIndex, pageSize int) map[string]any {
|
||||||
|
return stockListPayload(map[string]any{
|
||||||
"dvalue": dateFrom + "," + dateTo,
|
"dvalue": dateFrom + "," + dateTo,
|
||||||
"tableName": "t_stock",
|
"tableName": "t_stock",
|
||||||
"colName": "created",
|
"colName": "created",
|
||||||
@@ -485,9 +489,21 @@ func listPayload(dateFrom, dateTo string, start, pageIndex, pageSize int) map[st
|
|||||||
"type": 3,
|
"type": 3,
|
||||||
"tableAlias": "t",
|
"tableAlias": "t",
|
||||||
"optType": 0,
|
"optType": 0,
|
||||||
},
|
}, start, pageIndex, pageSize)
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// orderNumberListPayload 使用顺运宝页面“全部单号”的原始 HAR 条件。
|
||||||
|
// allcode 会搜索多类单号,因此调用方还必须核对返回行的 code 与输入完全一致。
|
||||||
|
func orderNumberListPayload(orderNumber string, start, pageIndex, pageSize int) map[string]any {
|
||||||
|
return stockListPayload(map[string]any{
|
||||||
|
"dvalue": orderNumber,
|
||||||
|
"tableName": "t_stock",
|
||||||
|
"colName": "allcode",
|
||||||
|
"op": 6,
|
||||||
|
"type": 0,
|
||||||
|
"tableAlias": "t",
|
||||||
|
"optType": 1,
|
||||||
|
}, start, pageIndex, pageSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListTotal 查某个日期范围内的货运单总数:POST /am/stock/listTotal。
|
// ListTotal 查某个日期范围内的货运单总数:POST /am/stock/listTotal。
|
||||||
@@ -498,10 +514,17 @@ func (c *Client) ListTotal(ctx context.Context, dateFrom, dateTo string, pageSiz
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
return parseListTotal(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseListTotal(data json.RawMessage) (int, error) {
|
||||||
var total int
|
var total int
|
||||||
if err := json.Unmarshal(data, &total); err != nil {
|
if err := json.Unmarshal(data, &total); err != nil {
|
||||||
return 0, fmt.Errorf("listTotal 返回的总数格式错误: %s", string(data))
|
return 0, fmt.Errorf("listTotal 返回的总数格式错误: %s", string(data))
|
||||||
}
|
}
|
||||||
|
if total < 0 {
|
||||||
|
return 0, fmt.Errorf("listTotal 返回了负数: %d", total)
|
||||||
|
}
|
||||||
return total, nil
|
return total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,8 +539,12 @@ type StockRow struct {
|
|||||||
// ListPage 按日期范围翻一页货运单列表:POST /am/stock/list。
|
// ListPage 按日期范围翻一页货运单列表:POST /am/stock/list。
|
||||||
// 返回响应内的 total;HAR 证明它是当前页条数,不是筛选范围总数。
|
// 返回响应内的 total;HAR 证明它是当前页条数,不是筛选范围总数。
|
||||||
func (c *Client) ListPage(ctx context.Context, dateFrom, dateTo string, start, pageIndex, pageSize int) ([]StockRow, int, error) {
|
func (c *Client) ListPage(ctx context.Context, dateFrom, dateTo string, start, pageIndex, pageSize int) ([]StockRow, int, error) {
|
||||||
|
return c.listPage(ctx, listPayload(dateFrom, dateTo, start, pageIndex, pageSize))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) listPage(ctx context.Context, payload map[string]any) ([]StockRow, int, error) {
|
||||||
data, err := c.do(ctx, http.MethodPost, "/am/stock/list", nil,
|
data, err := c.do(ctx, http.MethodPost, "/am/stock/list", nil,
|
||||||
listPayload(dateFrom, dateTo, start, pageIndex, pageSize))
|
payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
@@ -548,6 +575,80 @@ func (c *Client) ListPage(ctx context.Context, dateFrom, dateTo string, start, p
|
|||||||
return rows, *wrap.Total, nil
|
return rows, *wrap.Total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
orderNumberPageSize = 20
|
||||||
|
orderNumberMaxMatches = 100
|
||||||
|
orderNumberMaxLength = 128
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListByOrderNumber 使用顺运宝页面“全部单号”条件直接查询货运单。
|
||||||
|
// 返回值只包含 code 与输入完全相同的货运单;服务端返回不相关 code、重复 ID
|
||||||
|
// 或不完整分页时按协议错误处理,避免后续把档口入库码写到错误货运单。
|
||||||
|
func (c *Client) ListByOrderNumber(ctx context.Context, orderNumber string) ([]StockRow, error) {
|
||||||
|
orderNumber = strings.TrimSpace(orderNumber)
|
||||||
|
if orderNumber == "" {
|
||||||
|
return nil, fmt.Errorf("货运单号不能为空")
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(orderNumber) > orderNumberMaxLength {
|
||||||
|
return nil, fmt.Errorf("货运单号不能超过 %d 个字符", orderNumberMaxLength)
|
||||||
|
}
|
||||||
|
for _, character := range orderNumber {
|
||||||
|
if character < 32 || character == 127 {
|
||||||
|
return nil, fmt.Errorf("货运单号不能包含控制字符")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := c.do(ctx, http.MethodPost, "/am/stock/listTotal", nil,
|
||||||
|
orderNumberListPayload(orderNumber, 0, 1, orderNumberPageSize))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
total, err := parseListTotal(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if total == 0 {
|
||||||
|
return []StockRow{}, nil
|
||||||
|
}
|
||||||
|
if total > orderNumberMaxMatches {
|
||||||
|
return nil, fmt.Errorf("货运单号 %q 查询返回 %d 条,超过安全上限 %d",
|
||||||
|
orderNumber, total, orderNumberMaxMatches)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := make([]StockRow, 0, total)
|
||||||
|
seenIDs := make(map[int64]bool, total)
|
||||||
|
for start := 0; start < total; start += orderNumberPageSize {
|
||||||
|
pageIndex := start/orderNumberPageSize + 1
|
||||||
|
pageRows, pageCount, pageErr := c.listPage(ctx,
|
||||||
|
orderNumberListPayload(orderNumber, start, pageIndex, orderNumberPageSize))
|
||||||
|
if pageErr != nil {
|
||||||
|
return nil, pageErr
|
||||||
|
}
|
||||||
|
expected := orderNumberPageSize
|
||||||
|
if remaining := total - start; remaining < expected {
|
||||||
|
expected = remaining
|
||||||
|
}
|
||||||
|
if pageCount != expected {
|
||||||
|
return nil, fmt.Errorf("货运单号 %q 第 %d 页不完整:预期 %d 行,实际 %d 行",
|
||||||
|
orderNumber, pageIndex, expected, pageCount)
|
||||||
|
}
|
||||||
|
for _, row := range pageRows {
|
||||||
|
if row.Code != orderNumber {
|
||||||
|
return nil, fmt.Errorf("货运单号 %q 查询返回了不一致的 code %q", orderNumber, row.Code)
|
||||||
|
}
|
||||||
|
if seenIDs[row.ID] {
|
||||||
|
return nil, fmt.Errorf("货运单号 %q 查询重复返回货运单 id=%d", orderNumber, row.ID)
|
||||||
|
}
|
||||||
|
seenIDs[row.ID] = true
|
||||||
|
rows = append(rows, row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(rows) != total {
|
||||||
|
return nil, fmt.Errorf("货运单号 %q 查询总数为 %d,实际读取 %d 行", orderNumber, total, len(rows))
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- 货运单明细 ----------
|
// ---------- 货运单明细 ----------
|
||||||
|
|
||||||
// DetailItem 是货运单明细里的一个商品(t_stock.details[] 的一项),
|
// DetailItem 是货运单明细里的一个商品(t_stock.details[] 的一项),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -334,6 +335,143 @@ func TestClient_ListTotal和ListPage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestClient_ListByOrderNumber_使用Allcode精确查询(t *testing.T) {
|
||||||
|
const orderNumber = "260812711E49CU"
|
||||||
|
var paths []string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
paths = append(paths, r.URL.Path)
|
||||||
|
var body map[string]any
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
t.Fatalf("解析请求体失败: %v", err)
|
||||||
|
}
|
||||||
|
queries, _ := body["queries"].([]any)
|
||||||
|
if len(queries) != 1 {
|
||||||
|
t.Fatalf("queries=%v", body["queries"])
|
||||||
|
}
|
||||||
|
query, _ := queries[0].(map[string]any)
|
||||||
|
if query["dvalue"] != orderNumber || query["tableName"] != "t_stock" ||
|
||||||
|
query["colName"] != "allcode" || query["op"] != float64(6) ||
|
||||||
|
query["type"] != float64(0) || query["tableAlias"] != "t" || query["optType"] != float64(1) {
|
||||||
|
t.Fatalf("allcode 查询条件不正确: %v", query)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/am/stock/listTotal":
|
||||||
|
w.Write(envelopeBody(t, true, "ok", 1, nil))
|
||||||
|
case "/am/stock/list":
|
||||||
|
w.Write(envelopeBody(t, true, "ok", map[string]any{
|
||||||
|
"total": 1,
|
||||||
|
"list": []map[string]any{{"id": 76000001, "code": orderNumber}},
|
||||||
|
}, nil))
|
||||||
|
default:
|
||||||
|
t.Fatalf("意外路径: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client, _ := New(srv.URL)
|
||||||
|
rows, err := client.ListByOrderNumber(context.Background(), " "+orderNumber+" ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(rows) != 1 || rows[0].ID != 76000001 || rows[0].Code != orderNumber {
|
||||||
|
t.Fatalf("货运单查询结果不正确: %+v", rows)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(paths, []string{"/am/stock/listTotal", "/am/stock/list"}) {
|
||||||
|
t.Fatalf("请求路径不正确: %v", paths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_ListByOrderNumber_按20条分页读取(t *testing.T) {
|
||||||
|
const orderNumber = "ORDER-21"
|
||||||
|
listRequests := 0
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body map[string]any
|
||||||
|
json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
if r.URL.Path == "/am/stock/listTotal" {
|
||||||
|
w.Write(envelopeBody(t, true, "ok", 21, nil))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
listRequests++
|
||||||
|
start := int(body["start"].(float64))
|
||||||
|
count := 20
|
||||||
|
if start == 20 {
|
||||||
|
count = 1
|
||||||
|
}
|
||||||
|
rows := make([]map[string]any, 0, count)
|
||||||
|
for index := 0; index < count; index++ {
|
||||||
|
rows = append(rows, map[string]any{"id": start + index + 1, "code": orderNumber})
|
||||||
|
}
|
||||||
|
w.Write(envelopeBody(t, true, "ok", map[string]any{"total": count, "list": rows}, nil))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client, _ := New(srv.URL)
|
||||||
|
rows, err := client.ListByOrderNumber(context.Background(), orderNumber)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(rows) != 21 || listRequests != 2 || rows[20].ID != 21 {
|
||||||
|
t.Fatalf("分页结果不正确 rows=%d requests=%d last=%+v", len(rows), listRequests, rows[len(rows)-1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_ListByOrderNumber_拒绝不一致Code和不完整页(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
total int
|
||||||
|
pageTotal int
|
||||||
|
rows []map[string]any
|
||||||
|
}{
|
||||||
|
{"code不一致", 1, 1, []map[string]any{{"id": 1, "code": "OTHER"}}},
|
||||||
|
{"分页不完整", 2, 1, []map[string]any{{"id": 1, "code": "ORDER"}}},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/am/stock/listTotal" {
|
||||||
|
w.Write(envelopeBody(t, true, "ok", test.total, nil))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Write(envelopeBody(t, true, "ok", map[string]any{
|
||||||
|
"total": test.pageTotal, "list": test.rows,
|
||||||
|
}, nil))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
client, _ := New(srv.URL)
|
||||||
|
if _, err := client.ListByOrderNumber(context.Background(), "ORDER"); err == nil {
|
||||||
|
t.Fatal("非法查询结果必须报错")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_ListByOrderNumber_零命中不查列表且限制安全上限(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
total int
|
||||||
|
wantError bool
|
||||||
|
}{
|
||||||
|
{"零命中", 0, false},
|
||||||
|
{"超过安全上限", orderNumberMaxMatches + 1, true},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
listRequests := 0
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/am/stock/list" {
|
||||||
|
listRequests++
|
||||||
|
}
|
||||||
|
w.Write(envelopeBody(t, true, "ok", test.total, nil))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
client, _ := New(srv.URL)
|
||||||
|
rows, err := client.ListByOrderNumber(context.Background(), "ORDER")
|
||||||
|
if (err != nil) != test.wantError || (!test.wantError && len(rows) != 0) || listRequests != 0 {
|
||||||
|
t.Fatalf("rows=%v err=%v listRequests=%d", rows, err, listRequests)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestClient_ListPage要求合法Total(t *testing.T) {
|
func TestClient_ListPage要求合法Total(t *testing.T) {
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
Reference in New Issue
Block a user