Files
bj_power/bj_power_mes/internal/wmsclient/client.go
T
SunYF 62e45b7379 feat: 完成附件存储重构与磁盘监控功能,同步物料档案与入库单规则更新
1.  新增跨平台磁盘空间监控能力,每日7点自动检测附件目录剩余空间,触发阈值告警
2.  重构附件存储方案为年/月/日/文件类型分层结构,统一MES与WMS的附件管理逻辑
3.  对齐物料档案与入库单的质量状态校验规则,仅合格品计入库存与出库
4.  实现入库单作废功能与区域库位的多级父子结构管理
5.  删除物料简称字段,补充规格型号与单位必填项,统一系统数据口径
6.  新增附件中心与磁盘状态查询接口,完善权限控制与操作日志
2026-09-19 10:54:15 +08:00

461 lines
15 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,
})
}
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))
}