Files
bj_power/bj_power_mes/internal/wmsclient/client.go
T
SunYF 05b0b8a909 feat: 完成客户第12条需求+多系统改造
1. 新增预警表添加工单号字段,支持按工单号检索预警
2. 添加工单过滤在制品/绩效报表/预警中心查询
3. 补全WMS台账字段口径与权限配置
4. 修复备料单与数量不符处理流程
5. 新增配送进度/库位联动/自动出库功能
6. 修复AGV配送状态更新逻辑
7. 优化退料与库存管理细节
2026-09-19 17:04:58 +08:00

603 lines
20 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 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=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
}
// 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
}
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))
}