Files
cmautobuy/admin/syb/client.go
T

827 lines
28 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package syb 是顺运宝 ERP 的 HTTP 客户端:验证码、登录、货运单列表、
// 货运单明细。
//
// 接口契约来自 docs/admin/08-顺运宝接口.md(从抓包还原),登录/查询流程
// 照 raw_data/shunyunbaoerp_single.py 抄,但**存储换 SQLite、不用 Redis、
// 不用 OCR**——那两样是那个一次性脚本的需要,Admin 是常驻进程,见 08 §8。
//
// `[必须]` 本包只负责"怎么跟顺运宝的 HTTP 接口打交道",不碰数据库、
// 不认识 *gin.Context。编排(算日期范围、字段映射、落库)在
// admin/service/syb.go。
package syb
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"strconv"
"strings"
"time"
"unicode/utf8"
)
// ErrSessionInvalid 表示服务端**明确**判定当前会话未登录或已过期
// (docs/admin/08-顺运宝接口.md §3.5)。调用方看到这个错误应该清掉
// 本地缓存的会话、提示操作员重新登录。
//
// `[必须]` 只有能明确判定"未登录"的情况才包成这个:HTTP 401/403,
// 或响应体 status=false 且 msg 含"未登录"/"登录过期",或 code 是 -2。
// **超时、5xx、JSON 格式错误一律是别的错误类型**,不能包成这个——
// 网络抖一下就判定登出会触发不必要的重新登录、验证码弹个不停,
// 还可能把本来有效的会话丢掉,见 §3.5 的理由和工单 #46。
var ErrSessionInvalid = errors.New("顺运宝会话未登录或已过期")
// ErrWriteResultUnknown 表示写请求可能已经到达顺运宝,但客户端无法确认结果。
// 调用方只能重新读取核对,绝不能自动重发同一个写请求。
var ErrWriteResultUnknown = errors.New("顺运宝写入结果未知")
type requestOutcomeUnknownError struct{ err error }
func (e requestOutcomeUnknownError) Error() string { return e.err.Error() }
func (e requestOutcomeUnknownError) Unwrap() error { return e.err }
// Client 是一个顺运宝 ERP 会话:验证码、登录、货运单查询共用同一个
// http.Client(同一个 Cookie Jar)。
//
// `[必须]` 08 §3.2:验证码和登录必须用**同一个** Cookie Jar,
// 换客户端拿到的验证码就对不上——所以本包不提供"每次请求新建一个
// Client"的用法,调用方要在整个"取验证码 → 登录"流程里复用同一个实例。
type Client struct {
baseURL string
http *http.Client
jar *cookiejar.Jar
}
// New 创建一个新的顺运宝客户端,带一个空的 Cookie Jar。
func New(baseURL string) (*Client, error) {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
if baseURL == "" {
return nil, fmt.Errorf("顺运宝 base_url 不能为空")
}
jar, err := cookiejar.New(nil)
if err != nil {
return nil, fmt.Errorf("创建顺运宝客户端的 Cookie Jar 失败: %w", err)
}
return &Client{
baseURL: baseURL,
jar: jar,
http: &http.Client{
Jar: jar,
// 5 秒连接 + 30 秒读取是示例脚本用的值(见 raw_data/shunyunbaoerp_single.py),
// 这里简化成一个总超时,量级一致。
Timeout: 30 * time.Second,
},
}, nil
}
// ---------- 会话持久化:Cookie 导入/导出 ----------
// cookieDTO 是缓存进 syb_session.cookies 的单个 Cookie 的 JSON 形状。
type cookieDTO struct {
Name string `json:"name"`
Value string `json:"value"`
Path string `json:"path,omitempty"`
}
// ExportCookiesJSON 把当前 Cookie Jar 里属于 base_url 的 Cookie
// 导出成 JSON 数组文本,供 repository.SaveSybSession 存进去。
func (c *Client) ExportCookiesJSON() (string, error) {
u, err := url.Parse(c.baseURL)
if err != nil {
return "", fmt.Errorf("解析 base_url 失败: %w", err)
}
cookies := c.jar.Cookies(u)
dtos := make([]cookieDTO, 0, len(cookies))
for _, ck := range cookies {
dtos = append(dtos, cookieDTO{Name: ck.Name, Value: ck.Value, Path: ck.Path})
}
b, err := json.Marshal(dtos)
if err != nil {
return "", fmt.Errorf("序列化 Cookie 失败: %w", err)
}
return string(b), nil
}
// ImportCookiesJSON 把缓存的 Cookie JSON 恢复进当前 Cookie Jar,
// 恢复登录会话时用(重启 Admin 后免登录)。
func (c *Client) ImportCookiesJSON(cookiesJSON string) error {
var dtos []cookieDTO
if err := json.Unmarshal([]byte(cookiesJSON), &dtos); err != nil {
return fmt.Errorf("解析缓存的顺运宝 Cookie 失败: %w", err)
}
u, err := url.Parse(c.baseURL)
if err != nil {
return fmt.Errorf("解析 base_url 失败: %w", err)
}
cookies := make([]*http.Cookie, 0, len(dtos))
for _, d := range dtos {
if d.Name == "" {
continue
}
path := d.Path
if path == "" {
path = "/"
}
cookies = append(cookies, &http.Cookie{Name: d.Name, Value: d.Value, Path: path})
}
c.jar.SetCookies(u, cookies)
return nil
}
// ---------- 统一响应信封 ----------
// envelope 是 /am/** 接口统一的响应形状,见 08 §2:
//
// { "status": true, "msg": "获取成功", "data": <任意>, "code": null }
//
// `[必须]` 判断成功只看 status === true,不看 HTTP 状态码——服务端
// 业务失败时也可能返回 200。data 可能是对象/数组/裸整数,所以用
// json.RawMessage 延后解析,各接口自己按预期形状再反序列化一次。
type envelope struct {
Status bool `json:"status"`
Msg string `json:"msg"`
Data json.RawMessage `json:"data"`
Code json.RawMessage `json:"code"`
}
// do 发一个请求并取出统一响应信封里的 data,同时按 §3.5 的规则
// 把"明确未登录"和"别的错误"分开。
//
// `[必须]` 这一个函数是本包**唯一**发起 HTTP 请求、判断错误类型的地方;
// 登录、会话校验、货运单列表/明细全部走它,好处是"网络故障不能判定
// 未登录"这条规则只需要写一遍、测一遍,不会在多个接口各写一份、
// 早晚有一处漏判。
func (c *Client) do(ctx context.Context, method, path string, query url.Values, body any) (json.RawMessage, error) {
fullURL := c.baseURL + path
if len(query) > 0 {
fullURL += "?" + query.Encode()
}
var bodyReader io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("构造请求体失败: %w", err)
}
bodyReader = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, fullURL, bodyReader)
if err != nil {
return nil, fmt.Errorf("构造顺运宝请求 %s 失败: %w", path, err)
}
if bodyReader != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("X-Requested-With", "XMLHttpRequest")
resp, err := c.http.Do(req)
if err != nil {
// 网络故障(超时、连不上、DNS 失败……)——`[必须]` 不能当成"未登录",
// 见 ErrSessionInvalid 的注释和 08 §3.5。
return nil, requestOutcomeUnknownError{fmt.Errorf("请求顺运宝接口 %s 失败(网络问题,不代表未登录): %w", path, err)}
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, requestOutcomeUnknownError{fmt.Errorf("读取顺运宝接口 %s 响应失败: %w", path, err)}
}
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return nil, fmt.Errorf("顺运宝接口 %s 返回 %d: %w", path, resp.StatusCode, ErrSessionInvalid)
}
if resp.StatusCode >= http.StatusInternalServerError {
return nil, requestOutcomeUnknownError{fmt.Errorf("顺运宝接口 %s 返回 %d(服务端故障,不代表未登录)", path, resp.StatusCode)}
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("顺运宝接口 %s 返回意外状态码 %d", path, resp.StatusCode)
}
var env envelope
if err := json.Unmarshal(raw, &env); err != nil {
return nil, requestOutcomeUnknownError{fmt.Errorf("顺运宝接口 %s 响应不是合法 JSON(格式错误,不代表未登录): %w", path, err)}
}
if !env.Status {
if isSessionInvalidMessage(env.Msg, env.Code) {
return nil, fmt.Errorf("顺运宝接口 %s: %s: %w", path, env.Msg, ErrSessionInvalid)
}
return nil, fmt.Errorf("顺运宝接口 %s 业务失败: msg=%s code=%s",
path, orDefault(env.Msg, "(无)"), string(env.Code))
}
return env.Data, nil
}
// isSessionInvalidMessage 判断业务失败信息是不是"明确未登录",
// 规则见 08 §3.5:msg 含"未登录"/"登录过期",或 code 是 -2
// (数字或字符串两种写法都算,服务端返回哪种没有实测确认过)。
func isSessionInvalidMessage(msg string, code json.RawMessage) bool {
if strings.Contains(msg, "未登录") || strings.Contains(msg, "登录过期") {
return true
}
c := strings.TrimSpace(string(code))
return c == "-2" || c == `"-2"`
}
func orDefault(s, fallback string) string {
if s == "" {
return fallback
}
return s
}
// ---------- 验证码 ----------
// Captcha 是一张验证码图片。
type Captcha struct {
Image []byte
ContentType string
}
// FetchCaptcha 取一张新的验证码图片:GET /api/p/code1?<毫秒时间戳>。
//
// `[必须]` 时间戳参数每次都要换,是为了绕开浏览器/中间层缓存,见 08 §3.2。
func (c *Client) FetchCaptcha(ctx context.Context) (*Captcha, error) {
u := fmt.Sprintf("%s/api/p/code1?%d", c.baseURL, time.Now().UnixMilli())
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, fmt.Errorf("构造验证码请求失败: %w", err)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("获取顺运宝验证码失败(网络问题): %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("获取顺运宝验证码失败,HTTP 状态码 %d", resp.StatusCode)
}
ct := resp.Header.Get("Content-Type")
if !strings.HasPrefix(ct, "image/") {
return nil, fmt.Errorf("验证码接口没有返回图片,Content-Type=%q", ct)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取验证码图片失败: %w", err)
}
return &Captcha{Image: data, ContentType: ct}, nil
}
// ---------- 登录 ----------
// LoginUser 是登录响应里的用户信息。
type LoginUser struct {
ID int64
Username string
}
// LoginResult 是登录成功后要缓存的信息。
type LoginResult struct {
User LoginUser
// ExpiresAt 是 min(JWT exp, 从现在起 24 小时)——08 §3.3/§3.4:
// 会话有效期正好 24 小时,缓存不能活得比会话长。
ExpiresAt time.Time
}
// Login 提交用户名、密码、验证码登录。
//
// `[必须]` 密码只出现在请求体(走 HTTPS)。本函数、以及它调用的 do(),
// 产生的任何错误信息都不包含密码——错误信息只带 path/msg/code,
// 不回显请求体,防止密码进日志(admin/AGENTS.md「日志不得出现密码」)。
func (c *Client) Login(ctx context.Context, username, password, code string) (*LoginResult, error) {
if username == "" || password == "" || code == "" {
return nil, fmt.Errorf("用户名、密码、验证码均不能为空")
}
data, err := c.do(ctx, http.MethodPost, "/am/auth/login", nil, map[string]string{
"username": username,
"password": password,
"code": code,
})
if err != nil {
return nil, err
}
var payload struct {
User struct {
ID int64 `json:"id"`
Username string `json:"username"`
} `json:"user"`
Token string `json:"token"`
}
if err := json.Unmarshal(data, &payload); err != nil {
return nil, fmt.Errorf("登录响应格式错误: %w", err)
}
if payload.User.ID == 0 || payload.User.Username == "" {
return nil, fmt.Errorf("登录响应中缺少 user 信息")
}
// `[必须]` 缓存有效期取 min(JWT 剩余, 24h)——08 §3.3。
// 解析不出 JWT(或它不含 exp)时退化成"从现在起 24 小时",
// 不因为解析失败就直接报错——登录本身已经成功了。
expiresAt := time.Now().Add(24 * time.Hour)
if exp, ok := jwtExpiry(payload.Token); ok && exp.Before(expiresAt) {
expiresAt = exp
}
return &LoginResult{
User: LoginUser{ID: payload.User.ID, Username: payload.User.Username},
ExpiresAt: expiresAt,
}, nil
}
// jwtExpiry 从 JWT 的 payload 段解析 exp(Unix 秒)。
// 解析不了返回 (零值, false),不 panic、不报错——调用方负责兜底。
func jwtExpiry(token string) (time.Time, bool) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return time.Time{}, false
}
payload := parts[1]
if m := len(payload) % 4; m != 0 {
payload += strings.Repeat("=", 4-m)
}
raw, err := base64.URLEncoding.DecodeString(payload)
if err != nil {
return time.Time{}, false
}
var claims struct {
Exp int64 `json:"exp"`
}
if err := json.Unmarshal(raw, &claims); err != nil || claims.Exp == 0 {
return time.Time{}, false
}
return time.Unix(claims.Exp, 0), true
}
// ---------- 登录:验证码自动识别(工单 #47) ----------
// DefaultOcrMaxAttempts 是自动识别失败后的重试次数上限,maxAttempts<=0
// 时退化成这个值——示例脚本 raw_data/shunyunbaoerp_single.py 用的也是 5。
const DefaultOcrMaxAttempts = 5
// LoginWithOCR 尝试用 OCR 服务自动识别验证码并登录,最多重试 maxAttempts 次。
//
// `[必须]` 见 docs/admin/08-顺运宝接口.md「验证码自动识别」一节和工单 #47:
// - 每次重试都要重新取一张验证码图——同一张图再识别一次结果不会变,
// 纯属浪费,而且验证码可能已经被上一次失败的登录作废;
// - 识别结果必须**非空且恰好 4 位字母数字**才拿去登录,过滤掉
// OCR 可能带回的空格和标点;不满足就换图重试,不拿去试登录——
// 白费一次登录尝试,而且频繁错误登录可能触发对方风控;
// - OCR 请求本身失败(网络/超时/格式错误/业务失败)判定为"服务不可用",
// 立即降级,不占用重试次数——服务本身连不上,重试没有意义;
// - 达到 maxAttempts 仍没登录成功,降级并说明已尝试的次数。
//
// `[必须]` 本函数**不返回 error**:外部 OCR 服务失败、识别失败都是
// 预期路径,不是异常——调用方应该把非空的返回原因展示给操作员并弹
// 手工输入框,而不是当成系统错误处理。返回 (result, "") 表示登录成功;
// 返回 (nil, reason) 表示需要降级到手工,reason 是给操作员看的说明。
func (c *Client) LoginWithOCR(ctx context.Context, ocr *OcrClient, username, password string, maxAttempts int) (*LoginResult, string) {
if ocr == nil {
return nil, "验证码识别服务未配置,请手工输入"
}
if maxAttempts <= 0 {
maxAttempts = DefaultOcrMaxAttempts
}
for attempt := 1; attempt <= maxAttempts; attempt++ {
captcha, err := c.FetchCaptcha(ctx)
if err != nil {
return nil, fmt.Sprintf("获取验证码图片失败(%s),请手工输入", err.Error())
}
code, err := ocr.Recognize(ctx, captcha.Image)
if err != nil {
return nil, fmt.Sprintf("验证码识别服务暂时不可用(%s),请手工输入", err.Error())
}
code = filterAlnum(code)
if len(code) != 4 {
// 识别结果为空或长度不对,说明这张图没识别对,换图重试,
// 不拿去登录(08 §OCR:不是 4 位就不要拿去登录)。
continue
}
result, err := c.Login(ctx, username, password, code)
if err == nil {
return result, ""
}
// 登录失败(验证码错/密码错,08 §9 未区分),换一张图重试。
}
return nil, fmt.Sprintf("自动识别验证码失败(已尝试 %d 次),请手工输入", maxAttempts)
}
// filterAlnum 只保留字母和数字,滤掉 OCR 可能带回的空格和标点
// (照抄 raw_data/shunyunbaoerp_single.py 的 ocr_captcha() 的做法)。
func filterAlnum(s string) string {
var b strings.Builder
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
b.WriteRune(r)
}
}
return b.String()
}
// CheckSession 用 GET /am/user/get?id=<userID> 校验当前 Cookie 代表的
// 会话是否仍然有效,并核对返回的 id/username 与期望值一致(08 §3.5:
// 不一致说明串号了,同样按未登录处理)。
//
// 返回 nil 表示会话有效;返回 ErrSessionInvalid(可用 errors.Is 判断)
// 表示明确未登录;返回其它错误表示网络/格式问题,**不代表未登录**。
func (c *Client) CheckSession(ctx context.Context, userID int64, username string) error {
q := url.Values{"id": {strconv.FormatInt(userID, 10)}}
data, err := c.do(ctx, http.MethodGet, "/am/user/get", q, nil)
if err != nil {
return err
}
var got struct {
ID any `json:"id"`
Username string `json:"username"`
}
if err := json.Unmarshal(data, &got); err != nil {
return fmt.Errorf("会话校验响应格式错误: %w", err)
}
if got.Username == "" {
return fmt.Errorf("会话校验响应缺少 id/username")
}
gotID := fmt.Sprintf("%v", got.ID)
if gotID != strconv.FormatInt(userID, 10) || got.Username != username {
return ErrSessionInvalid
}
return nil
}
// ---------- 货运单列表 ----------
// stockListPayload 组装 /am/stock/listTotal、/am/stock/list 共用的请求体。
func stockListPayload(query map[string]any, start, pageIndex, pageSize int) map[string]any {
return map[string]any{
"history": 0,
"length": pageSize,
"start": start,
"pageTotal": 0,
"pageIndex": pageIndex,
"store": false,
"columns": columnsPayload(),
"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,
"tableName": "t_stock",
"colName": "created",
"op": 0,
"type": 3,
"tableAlias": "t",
"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。
// `[必须]` data 是裸整数,不是对象,见 08 §2。
func (c *Client) ListTotal(ctx context.Context, dateFrom, dateTo string, pageSize int) (int, error) {
data, err := c.do(ctx, http.MethodPost, "/am/stock/listTotal", nil,
listPayload(dateFrom, dateTo, 0, 1, pageSize))
if err != nil {
return 0, err
}
return parseListTotal(data)
}
func parseListTotal(data json.RawMessage) (int, error) {
var total int
if err := json.Unmarshal(data, &total); err != nil {
return 0, fmt.Errorf("listTotal 返回的总数格式错误: %s", string(data))
}
if total < 0 {
return 0, fmt.Errorf("listTotal 返回了负数: %d", total)
}
return total, nil
}
// StockRow 是货运单列表里的一行。Raw 保留完整原始字段,
// 供 service 层落库 syb_data 时和明细合并。
type StockRow struct {
ID int64
Code string
Raw map[string]any
}
// ListPage 按日期范围翻一页货运单列表:POST /am/stock/list。
// 返回响应内的 total;HAR 证明它是当前页条数,不是筛选范围总数。
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,
payload)
if err != nil {
return nil, 0, err
}
var wrap struct {
List []map[string]any `json:"list"`
Total *int `json:"total"`
}
if err := json.Unmarshal(data, &wrap); err != nil {
return nil, 0, fmt.Errorf("货运单列表响应格式错误: %w", err)
}
if wrap.Total == nil || *wrap.Total < 0 {
return nil, 0, fmt.Errorf("货运单列表响应缺少合法的 total")
}
if *wrap.Total != len(wrap.List) {
return nil, 0, fmt.Errorf("货运单列表响应的当前页条数为 %d,但实际返回 %d 行", *wrap.Total, len(wrap.List))
}
rows := make([]StockRow, 0, len(wrap.List))
for _, raw := range wrap.List {
id, ok := toInt64(raw["id"])
if !ok {
return nil, 0, fmt.Errorf("货运单列表里有一行缺少合法的 id")
}
code, _ := raw["code"].(string)
rows = append(rows, StockRow{ID: id, Code: code, Raw: raw})
}
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[] 的一项),
// 见 08 §6.1。
type DetailItem struct {
ID int64
ProductID int64
ProductTitle string
ProductSpec string
ProductQty int
ProductPrice float64 // 元,`[必须]` 不是分,见 08 §5.1
ProductThumb int64
Raw map[string]any
}
// StockDetail 是一张货运单的明细,一张货运单可以有多个商品(Details)。
type StockDetail struct {
ID int64
Code string
Details []DetailItem
// Raw 是外层字段(不含 details),落库 syb_data 时和 StockRow.Raw 合并。
Raw map[string]any
}
// DetailListByStock 按货运单 id 批量取明细:
// POST /am/stock/detail/listByStock?hist=0
//
// `[必须]` 一次最多传 100 个 id,超了由调用方分批,见 08 §6。
func (c *Client) DetailListByStock(ctx context.Context, ids []int64) ([]StockDetail, error) {
if len(ids) == 0 {
return nil, nil
}
if len(ids) > 100 {
return nil, fmt.Errorf("单批查询明细最多 100 个 id,实际传了 %d 个,请分批调用", len(ids))
}
data, err := c.do(ctx, http.MethodPost, "/am/stock/detail/listByStock",
url.Values{"hist": {"0"}}, map[string]any{"ids": ids})
if err != nil {
return nil, err
}
var wrap struct {
List []map[string]any `json:"list"`
}
if err := json.Unmarshal(data, &wrap); err != nil {
return nil, fmt.Errorf("货运单明细响应格式错误: %w", err)
}
out := make([]StockDetail, 0, len(wrap.List))
for _, raw := range wrap.List {
id, ok := toInt64(raw["id"])
if !ok {
return nil, fmt.Errorf("货运单明细里有一行缺少合法的 id")
}
code, _ := raw["code"].(string)
var rawDetails []any
if dv, ok := raw["details"].([]any); ok {
rawDetails = dv
}
items := make([]DetailItem, 0, len(rawDetails))
for _, d := range rawDetails {
m, ok := d.(map[string]any)
if !ok {
continue
}
itemID, _ := toInt64(m["id"])
productID, _ := toInt64(m["productId"])
qty, _ := toInt64(m["productQty"])
price, _ := toFloat64(m["productPrice"])
thumb, _ := toInt64(m["productThumb"])
title, _ := m["productTitle"].(string)
spec, _ := m["productSpec"].(string)
items = append(items, DetailItem{
ID: itemID, ProductID: productID, ProductTitle: title, ProductSpec: spec,
ProductQty: int(qty), ProductPrice: price, ProductThumb: thumb, Raw: m,
})
}
outerRaw := make(map[string]any, len(raw))
for k, v := range raw {
if k == "details" {
continue
}
outerRaw[k] = v
}
out = append(out, StockDetail{ID: id, Code: code, Details: items, Raw: outerRaw})
}
return out, nil
}
// DeleteInnerCode 清除一个货运明细的 innerExpCode。
// 请求只发送一次;结果未知时返回 ErrWriteResultUnknown,调用方不得重试。
func (c *Client) DeleteInnerCode(ctx context.Context, detailID int64) error {
if detailID <= 0 {
return fmt.Errorf("detailId 必须是正整数")
}
_, err := c.do(ctx, http.MethodGet, "/am/stock/detail/deleteInnerCode",
url.Values{"detailId": {strconv.FormatInt(detailID, 10)}}, nil)
return classifyInnerCodeWriteError(err)
}
// UpdateDetailCode 把档口入库码写入货运明细的 innerExpCode。
// 请求只发送一次;结果未知时返回 ErrWriteResultUnknown,调用方不得重试。
func (c *Client) UpdateDetailCode(ctx context.Context, stockID, detailID int64, code string) error {
code = strings.TrimSpace(code)
if stockID <= 0 || detailID <= 0 {
return fmt.Errorf("货运单 id 和 detailId 必须是正整数")
}
if code == "" {
return fmt.Errorf("code 不能为空")
}
if utf8.RuneCountInString(code) > 128 {
return fmt.Errorf("code 不能超过 128 个字符")
}
for _, character := range code {
if character < 32 || character == 127 {
return fmt.Errorf("code 不能包含控制字符")
}
}
_, err := c.do(ctx, http.MethodGet, "/am/stock/detail/updateDetailCode", url.Values{
"t": {"0"}, "id": {strconv.FormatInt(stockID, 10)},
"detailId": {strconv.FormatInt(detailID, 10)}, "code": {code},
}, nil)
return classifyInnerCodeWriteError(err)
}
func classifyInnerCodeWriteError(err error) error {
if err == nil {
return nil
}
var unknown requestOutcomeUnknownError
if errors.As(err, &unknown) {
return fmt.Errorf("%w:%v", ErrWriteResultUnknown, err)
}
return err
}
// ---------- 类型转换:JSON 数字/字符串统一转 ----------
// toInt64 兼容 JSON 数字被 encoding/json 解成 float64、以及顺运宝个别
// 字段用字符串传数字的情况。
func toInt64(v any) (int64, bool) {
switch n := v.(type) {
case float64:
return int64(n), true
case int64:
return n, true
case json.Number:
i, err := n.Int64()
return i, err == nil
case string:
i, err := strconv.ParseInt(strings.TrimSpace(n), 10, 64)
return i, err == nil
default:
return 0, false
}
}
func toFloat64(v any) (float64, bool) {
switch n := v.(type) {
case float64:
return n, true
case json.Number:
f, err := n.Float64()
return f, err == nil
case string:
f, err := strconv.ParseFloat(strings.TrimSpace(n), 64)
return f, err == nil
default:
return 0, false
}
}