Files
bj_power/bj_power_mes/internal/wmsclient/client.go
T

603 lines
20 KiB
Go
Raw Normal View History

2026-08-28 15:06:01 +08:00
package wmsclient
import (
"fmt"
2026-08-28 15:06:01 +08:00
"bytes"
"context"
"encoding/json"
"errors"
2026-08-28 15:06:01 +08:00
"net/http"
"net/url"
2026-08-28 15:06:01 +08:00
"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=3id 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
}
2026-09-15 16:37:39 +08:00
// 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/checkqty 传 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
}
2026-08-28 15:06:01 +08:00
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))
}