package wmsclient import ( "fmt" "bytes" "context" "encoding/json" "errors" "net/http" "net/url" "time" ) // Client WMS 内部接口客户端(半成品流转等) type Client struct { baseURL string token string hc *http.Client } func New(baseURL, token string) *Client { if baseURL == "" { baseURL = "http://127.0.0.1:9091" } return &Client{ baseURL: baseURL, token: token, hc: &http.Client{Timeout: 5 * time.Second}, } } // SemiInbound 调 WMS /api/internal/semi/inbound 半成品入库 func (c *Client) SemiInbound(ctx context.Context, sn, orderNo string, doneProcessCodes []int, operator string) error { return c.post(ctx, "/api/internal/semi/inbound", map[string]any{ "sn": sn, "orderNo": orderNo, "doneProcessCodes": doneProcessCodes, "operator": operator, }) } // SemiOutbound 调 WMS /api/internal/semi/outbound 半成品出库(重上线) func (c *Client) SemiOutbound(ctx context.Context, sn, orderNo string, doneProcessCodes []int, operator string) error { return c.post(ctx, "/api/internal/semi/outbound", map[string]any{ "sn": sn, "orderNo": orderNo, "doneProcessCodes": doneProcessCodes, "operator": operator, }) } // FinishedInbound 成品完工回流入库:调 WMS /api/internal/finished/inbound。 // 成品 SN 由 MES 系统生成(工件SN),统一库存主表 category=3;失败返回 err 由调用方降级。 func (c *Client) FinishedInbound(ctx context.Context, productCode, productName, sn, orderNo, operator string) error { return c.post(ctx, "/api/internal/finished/inbound", map[string]any{ "productCode": productCode, "productName": productName, "sn": sn, "orderNo": orderNo, "snSource": "AUTO", "operator": operator, }) } // MaterialExists 批量校验物料编码是否存在于 WMS 物料档案。 // 返回 WMS 中不存在的编码列表(全部存在则为空)。WMS 响应 {code,message,data:{missing:[]}}。 // 失败(网络/超时/非0响应)返回 err,由调用方决定降级策略。 func (c *Client) MaterialExists(ctx context.Context, codes []string) ([]string, error) { b, err := json.Marshal(map[string]any{"codes": codes}) if err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/internal/material/exists", bytes.NewReader(b)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") req.Header.Set("X-API-TOKEN", c.token) resp, err := c.hc.Do(req) if err != nil { return nil, err } defer resp.Body.Close() var body struct { Code int `json:"code"` Data struct { Missing []string `json:"missing"` } `json:"data"` } if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { return nil, err } if resp.StatusCode >= 300 || body.Code != 0 { return nil, &RespError{Status: resp.StatusCode} } return body.Data.Missing, nil } // ProductTypeRow 产品型号档案行(WMS materials item_type=3) // 注:原「备注」随 WMS 物料档案「简称」一并删除(2026-09-19), // 改为与 WMS 必填口径一致的「规格 / 单位」。 type ProductTypeRow struct { ID int `json:"id"` Code string `json:"code"` Name string `json:"name"` Spec string `json:"spec"` Unit string `json:"unit"` Category string `json:"category"` IsActive bool `json:"isActive"` CreatedAt int64 `json:"createdAt"` UpdatedAt int64 `json:"updatedAt"` } // get 调 WMS GET 接口并解出 {code,data} func (c *Client) get(ctx context.Context, path string, out any) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) if err != nil { return err } req.Header.Set("X-API-TOKEN", c.token) resp, err := c.hc.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 300 { return &RespError{Status: resp.StatusCode} } var body struct { Code int `json:"code"` Data any `json:"data"` } if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { return err } if body.Code != 0 { return &RespError{Status: resp.StatusCode} } if out != nil && body.Data != nil { b, _ := json.Marshal(body.Data) return json.Unmarshal(b, out) } return nil } // putJSON 调 WMS PUT 接口 func (c *Client) putJSON(ctx context.Context, path string, body any) error { b, err := json.Marshal(body) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodPut, c.baseURL+path, bytes.NewReader(b)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") req.Header.Set("X-API-TOKEN", c.token) resp, err := c.hc.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 300 { return &RespError{Status: resp.StatusCode} } return nil } // ProductTypes 全量产品型号(item_type=3,id asc,新建在底部)。isActive 可空。 func (c *Client) ProductTypes(ctx context.Context, isActive string) ([]ProductTypeRow, error) { q := "/api/internal/product-types?" if isActive != "" { q += "isActive=" + isActive } var list []ProductTypeRow if err := c.get(ctx, q, &list); err != nil { return nil, err } return list, nil } // ProductTypePage 分页+搜索(产品型号管理页真分页)。 // 2026-09-08 禁止关键字混搜:编码/名称/类别各自独立模糊。 func (c *Client) ProductTypePage(ctx context.Context, code, name, category, isActive string, page, pageSize int) (int64, []ProductTypeRow, error) { q := fmt.Sprintf("/api/internal/product-types?page=%d&pageSize=%d", page, pageSize) if code != "" { q += "&code=" + url.QueryEscape(code) } if name != "" { q += "&name=" + url.QueryEscape(name) } if category != "" { q += "&category=" + url.QueryEscape(category) } if isActive != "" { q += "&isActive=" + isActive } var data struct { Total int64 `json:"total"` List []ProductTypeRow `json:"list"` } if err := c.get(ctx, q, &data); err != nil { return 0, nil, err } return data.Total, data.List, nil } // CreateProductType 新建产品型号(code 可空由 WMS 自动生成 PROD-日期-流水) // spec/unit 必填(与 WMS 物料档案必填口径一致) func (c *Client) CreateProductType(ctx context.Context, code, name, spec, unit, category string, isActive bool) error { return c.post(ctx, "/api/internal/product-types", map[string]any{ "code": code, "name": name, "spec": spec, "unit": unit, "category": category, "isActive": isActive, }) } // UpdateProductType 修改产品型号(编码不可改) func (c *Client) UpdateProductType(ctx context.Context, id int, name, spec, unit, category string, isActive bool) error { return c.putJSON(ctx, "/api/internal/product-types", map[string]any{ "id": id, "name": name, "spec": spec, "unit": unit, "category": category, "isActive": isActive, }) } // DeleteProductType 删除产品型号(引用保护由调用方与 WMS 双侧校验) func (c *Client) DeleteProductType(ctx context.Context, id int) error { return c.post(ctx, "/api/internal/product-types/delete", map[string]any{"id": id}) } // ExportProductTypes 拉取产品型号导出 xlsx 字节流(WMS 端执行统一导出时间规则)。 // 编码/名称/类别各自独立模糊筛选(禁止关键字混搜)。 func (c *Client) ExportProductTypes(ctx context.Context, code, name, category, isActive, startDate, endDate string) ([]byte, error) { q := "/api/internal/product-types/export?" if code != "" { q += "code=" + url.QueryEscape(code) + "&" } if name != "" { q += "name=" + url.QueryEscape(name) + "&" } if category != "" { q += "category=" + url.QueryEscape(category) + "&" } if isActive != "" { q += "isActive=" + isActive + "&" } q += "startDate=" + startDate + "&endDate=" + endDate req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+q, nil) if err != nil { return nil, err } req.Header.Set("X-API-TOKEN", c.token) resp, err := c.hc.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode >= 300 { return nil, &RespError{Status: resp.StatusCode} } buf := new(bytes.Buffer) if _, err := buf.ReadFrom(resp.Body); err != nil { return nil, err } return buf.Bytes(), nil } // CreateReturnOrder 调 WMS /api/internal/return-order 预建退库单(工位退料回库房,待 WMS 确认收货)。 func (c *Client) CreateReturnOrder(ctx context.Context, orderNo, sn, materialCode, materialName, spec, reason, operator string, stationNo, qty int, unit string) error { return c.post(ctx, "/api/internal/return-order", map[string]any{ "orderNo": orderNo, "sn": sn, "materialCode": materialCode, "materialName": materialName, "spec": spec, "qty": qty, "unit": unit, "reason": reason, "operator": operator, "stationNo": stationNo, }) } // DisplayOverview 拉取 WMS 免登录看板总览 /api/display/overview。 // 用于看板「仓储动态」屏。WMS 不可达时返回 err,由调用方降级为空对象(看板其余屏不受影响)。 func (c *Client) DisplayOverview(ctx context.Context) (map[string]any, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api/display/overview", nil) if err != nil { return nil, err } req.Header.Set("X-API-TOKEN", c.token) resp, err := c.hc.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode >= 300 { return nil, &RespError{Status: resp.StatusCode} } var body struct { Code int `json:"code"` Data map[string]any `json:"data"` } if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { return nil, err } if body.Data == nil { return map[string]any{}, nil } return body.Data, nil } // StockAvailable 批量查询物料可用量(调 WMS /api/internal/stock/check,qty 传 0 只取可用量)。 // 返回 materialCode → availQty。用于 MES 侧计算可生产数量(BOM 单台用量在 MES、库存可用量在 WMS)。 func (c *Client) StockAvailable(ctx context.Context, codes []string) (map[string]int, error) { items := make([]map[string]any, 0, len(codes)) for _, code := range codes { items = append(items, map[string]any{"materialCode": code, "qty": 0}) } b, err := json.Marshal(map[string]any{"items": items}) if err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/internal/stock/check", bytes.NewReader(b)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") req.Header.Set("X-API-TOKEN", c.token) resp, err := c.hc.Do(req) if err != nil { return nil, err } defer resp.Body.Close() var body struct { Code int `json:"code"` Data struct { Items []struct { MaterialCode string `json:"materialCode"` AvailQty int `json:"availQty"` } `json:"items"` } `json:"data"` } if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { return nil, err } if resp.StatusCode >= 300 || body.Code != 0 { return nil, &RespError{Status: resp.StatusCode} } out := make(map[string]int, len(body.Data.Items)) for _, it := range body.Data.Items { out[it.MaterialCode] = it.AvailQty } return out, nil } // ProducibleItem 可生产数量快照行(MES 计算后推送 WMS 只读展示) type ProducibleItem struct { ProductCode string `json:"productCode"` ProductName string `json:"productName"` ProducibleQty int `json:"producibleQty"` ShortText string `json:"shortText"` ComputedAt int64 `json:"computedAt"` } // ProducibleDemand 未来5天物料需求行(推送 WMS,供备料四态「预警」判定) type ProducibleDemand struct { MaterialCode string `json:"materialCode"` Future5Qty int `json:"future5Qty"` } // MaterialTypes 批量查询物料在 WMS 的品类(1原材料/2半成品/3成品/4其他)。 // 供产品物料清单递归展开使用:成品子项需继续向下展开到原材料/外购件。 // WMS 不可达返回 err,由调用方按"全部视为叶子物料"降级(不阻塞产线)。 func (c *Client) MaterialTypes(ctx context.Context, codes []string) (map[string]int, error) { b, err := json.Marshal(map[string]any{"codes": codes}) if err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/internal/material/types", bytes.NewReader(b)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") req.Header.Set("X-API-TOKEN", c.token) resp, err := c.hc.Do(req) if err != nil { return nil, err } defer resp.Body.Close() var body struct { Code int `json:"code"` Data struct { Types map[string]int `json:"types"` } `json:"data"` Msg string `json:"message"` } if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { return nil, err } if body.Code != 0 { if body.Msg != "" { return nil, errors.New(body.Msg) } return nil, errors.New("WMS 查询物料品类失败") } if body.Data.Types == nil { return map[string]int{}, nil } return body.Data.Types, nil } // DockRow 接驳台主数据行(WMS dock 真源) type DockRow struct { DockCode string `json:"dockCode"` Name string `json:"name"` DockType string `json:"dockType"` StationNo int `json:"stationNo"` HasPallet bool `json:"hasPallet"` Status string `json:"status"` } // ListDocks 拉取 WMS 接驳台主数据列表(MES 工位维护页「送达接驳台」下拉真源)。 // WMS 不可达返回 err,由调用方降级为写死兜底。 func (c *Client) ListDocks(ctx context.Context) ([]DockRow, error) { var data struct { List []DockRow `json:"list"` } if err := c.get(ctx, "/api/internal/docks", &data); err != nil { return nil, err } return data.List, nil } // BackflushItem 反冲单条物料(已含损耗的单件消耗 * 完成件数) type BackflushItem struct { MaterialCode string `json:"materialCode"` Qty int `json:"qty"` } // Backflush 调 WMS /api/internal/material/backflush 反冲扣账。 // 物料在该道工序被消耗:报工完成时 MES 算好「工单产品BOM + 当前工序」消耗明细, // WMS 负责 FIFO 扣库存 + 生成 backflush 出库单。失败返回 err,由调用方降级(仅记日志不阻塞产线)。 func (c *Client) Backflush(ctx context.Context, orderNo string, stationNo, processCode int, operator string, items []BackflushItem) error { return c.post(ctx, "/api/internal/material/backflush", map[string]any{ "orderNo": orderNo, "stationNo": stationNo, "processCode": processCode, "operator": operator, "items": items, }) } // StockBatch WMS 可用批次(结构件) type StockBatch struct { BatchNo string `json:"batchNo"` Quantity int `json:"quantity"` LockedQty int `json:"lockedQty"` Avail int `json:"avail"` ZoneCode string `json:"zoneCode"` QualityStatus string `json:"qualityStatus"` } // StockBatches 调 WMS /api/internal/stock/batches 取该物料可出库的批次(FIFO 升序)与可用 SN 列表。 // 供「工单备料自动出库」分配用;WMS 只返回「合格」且在库的库存。 func (c *Client) StockBatches(ctx context.Context, materialCode string) ([]StockBatch, []string, error) { u := c.baseURL + "/api/internal/stock/batches?materialCode=" + url.QueryEscape(materialCode) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { return nil, nil, err } req.Header.Set("X-API-TOKEN", c.token) resp, err := c.hc.Do(req) if err != nil { return nil, nil, err } defer resp.Body.Close() var body struct { Code int `json:"code"` Message string `json:"message"` Data struct { Batches []StockBatch `json:"batches"` SnList []string `json:"snList"` } `json:"data"` } if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { return nil, nil, err } if resp.StatusCode >= 300 || body.Code != 0 { return nil, nil, errors.New("查询 WMS 可用库存失败: " + body.Message) } return body.Data.Batches, body.Data.SnList, nil } // StockDeductOpts 备料出库可选参数(一个字段一个语义,避免位置参数越加越长)。 type StockDeductOpts struct { BatchNo string // 批次出库(结构件),与 SnList 二选一 SnList []string // SN 出库(电气件),与 BatchNo 二选一 Qty int // 出库数量 Operator string TargetDock string ZoneCode string OutboundType string // workorder(备料,默认) / refill(补料) NeedAgv bool // 是否叫 AGV 配送到接驳台 OverReason string // 超出 BOM 需求时的超领原因(损耗/报废/返修/其他);空=超领将被 WMS 拦截 } // StockDeduct 调 WMS /api/internal/stock/deduct 执行一笔备料出库(批次或 SN 二选一)。 // 返回该笔出库单号与工单台账状态(等于「领料完结」表示该物料已领完)。 // 失败返回 err(含 WMS 业务错误文案,由调用方降级为保持待备料并预警)。 func (c *Client) StockDeduct(ctx context.Context, orderNo, materialCode string, o StockDeductOpts) (string, string, error) { payload := map[string]any{ "orderNo": orderNo, "materialCode": materialCode, "qty": o.Qty, "operator": o.Operator, "targetDock": o.TargetDock, "zoneCode": o.ZoneCode, } if o.BatchNo != "" { payload["batchNo"] = o.BatchNo } if len(o.SnList) > 0 { payload["snList"] = o.SnList } if o.OutboundType != "" { payload["outboundType"] = o.OutboundType } if o.NeedAgv { payload["needAgv"] = true } if o.OverReason != "" { payload["overReason"] = o.OverReason } b, err := json.Marshal(payload) if err != nil { return "", "", err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/internal/stock/deduct", bytes.NewReader(b)) if err != nil { return "", "", err } req.Header.Set("Content-Type", "application/json") req.Header.Set("X-API-TOKEN", c.token) resp, err := c.hc.Do(req) if err != nil { return "", "", err } defer resp.Body.Close() var body struct { Code int `json:"code"` Message string `json:"message"` Data struct { OutboundNo string `json:"outboundNo"` Status string `json:"status"` } `json:"data"` } if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { return "", "", err } if resp.StatusCode >= 300 || body.Code != 0 { return "", "", errors.New(body.Message) } return body.Data.OutboundNo, body.Data.Status, nil } // LedgerProgressRow 工单台账配送进度行(WMS 侧四段量)。 // 在途 / 线边由 MES 用本地已接料量派生(在途 = outQty − receivedQty;线边 = receivedQty − consumedQty), // WMS 不猜 MES 的接料量,避免跨系统重复记账。 type LedgerProgressRow struct { OrderNo string `json:"orderNo"` MaterialCode string `json:"materialCode"` MaterialName string `json:"materialName"` TargetStation string `json:"targetStation"` TotalQty int `json:"totalQty"` OutQty int `json:"outQty"` ReturnedQty int `json:"returnedQty"` ConsumedQty int `json:"consumedQty"` NetOut int `json:"netOut"` Gap int `json:"gap"` Status string `json:"status"` CompletedAt int64 `json:"completedAt"` } // LedgerProgress 拉取 WMS 工单物料台账配送进度(只读聚合,WMS 是台账唯一真源)。 func (c *Client) LedgerProgress(ctx context.Context, orderNo, materialCode string, page, pageSize int) ([]LedgerProgressRow, int, error) { u := fmt.Sprintf("/api/internal/ledger/progress?orderNo=%s&materialCode=%s&page=%d&pageSize=%d", url.QueryEscape(orderNo), url.QueryEscape(materialCode), page, pageSize) var out struct { Total int `json:"total"` List []LedgerProgressRow `json:"list"` } if err := c.get(ctx, u, &out); err != nil { return nil, 0, err } return out.List, out.Total, nil } func (c *Client) post(ctx context.Context, path string, body any) error { b, err := json.Marshal(body) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(b)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") req.Header.Set("X-API-TOKEN", c.token) resp, err := c.hc.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 300 { return &RespError{Status: resp.StatusCode} } return nil } type RespError struct { Status int } func (e *RespError) Error() string { return "wms call failed with status " + string(rune('0'+e.Status)) }