Files
bj_power/bj_power_wms/internal/handler/misc.go
T
SunYF 8e6e8e1d20 docs(business): 生成业务契约文档并重构MES客户端代码
- 创建案例.md文档,包含业务场景清单、业务流程、案例和回归测试三份完整文档
- WMS库房管理系统涵盖入库、出库、库存、检验、盘点等24个业务场景
- MES产线控制系统包含工单、排产、BOM、备料、质检、追溯等23个业务场景
- 工位终端系统支持上线、报工、下线、领料、巡检等15个现场作业场景
- 主链路从合同到成品入库,异常链路处理不合格、数量不符、退料等情况
- 删除MES客户端中的ScanRecord相关代码,精简客户端结构
- 更新客户端初始化逻辑,移除扫描记录相关的依赖注入配置
2026-09-20 18:25:44 +08:00

464 lines
16 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 handler
import (
"net/http"
"strconv"
"strings"
"time"
"bj_power_wms/ent"
"bj_power_wms/ent/inventory"
"bj_power_wms/ent/material"
"bj_power_wms/ent/ordermaterialledger"
"bj_power_wms/internal/svc"
)
// 库存品类(与 schema/inventory.go 保持一致):1=原材料 2=半成品 3=成品
const (
invCatRaw = 1
invCatSemi = 2
invCatFinished = 3
)
// 半成品 / 台账 相关(盘点见 stocktake.go
//
// 【统一库存主表收编 2026-09-07】半成品不再写独立 semi_finisheds 表,
// 全部落 inventoriesmanage_mode=2 SN单件, category=2 半成品)。
// 三个 semi 接口签名与响应结构保持不变,MES / 前端零改造平滑切换。
// semiInboundHandler 半成品/成品入库(携带已完成工序)
// 与 MES 闭环:MES 工位报工暂存退库 / 完工回流时调用本接口,把 SN 入 WMS 库
// inventoriescategory=2 半成品 / 3 成品,客户诉求 问题记录 L222 半成品与成品要区分)。
// 成品入库 9 字段(L218):SN/图号/归属/产品状态/相关标准/试验记录单号/记录员/记录日期/工单号。
// 为保持两系统解耦,materialCode 不强制要求——优先用请求传入,都没有则留空(SN 本身即追溯锚点)。
func semiInboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
Sn string `json:"sn"`
MaterialCode string `json:"materialCode"`
DoneProcessCodes []int `json:"doneProcessCodes"`
CompletedProcess string `json:"completedProcess"`
ZoneCode string `json:"zoneCode"`
OrderNo string `json:"orderNo"`
Operator string `json:"operator"`
Category int `json:"category"` // 2=半成品 3=成品(默认 2
OwnershipType string `json:"ownershipType"`
OwnershipNo string `json:"ownershipNo"`
ProductStatus string `json:"productStatus"` // 已通过/部分通过/返修(成品用)
RelatedStandard string `json:"relatedStandard"` // 相关标准(试验方案号)
TestRecordNo string `json:"testRecordNo"` // 试验记录单号
RecordedBy string `json:"recordedBy"` // 记录员
RecordedAt int64 `json:"recordedAt"` // 记录日期(unix 秒)
}
if err := parseJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, "参数错误")
return
}
if req.Sn == "" {
fail(w, http.StatusBadRequest, "sn 必填")
return
}
category := req.Category
if category != invCatFinished {
category = invCatSemi
}
catLabel := "半成品"
if category == invCatFinished {
catLabel = "成品"
}
// SN 防重:同一品类下同一 SN 只允许一条库存行(含已出库历史,避免重复回流)
exists, err := ctx.EntClient.Inventory.Query().
Where(inventory.CategoryEQ(category), inventory.SnCodeEQ(req.Sn)).
Exist(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
if exists {
fail(w, http.StatusConflict, catLabel+"已存在: "+req.Sn)
return
}
// 已完成工序:MES 传 doneProcessCodes([]int) 优先,否则用 completedProcess 字符串
completed := req.CompletedProcess
if len(req.DoneProcessCodes) > 0 {
parts := make([]string, 0, len(req.DoneProcessCodes))
for _, c := range req.DoneProcessCodes {
parts = append(parts, strconv.Itoa(c))
}
completed = strings.Join(parts, ",")
}
// 物料编码:以请求传入为准;名称从物料档案补齐(档案缺失则用编码兜底)
materialCode := req.MaterialCode
materialName := materialCode
if materialCode != "" {
if m, err := ctx.EntClient.Material.Query().Where(material.CodeEQ(materialCode)).First(ctx0()); err == nil && m != nil {
materialName = m.Name
}
}
rec, err := ctx.EntClient.Inventory.Create().
SetManageMode(2).
SetCategory(category).
SetSnCode(req.Sn).
SetMaterialCode(materialCode).
SetMaterialName(materialName).
SetQuantity(1).
SetQualityStatus("未检").
SetNillableZoneCode(strPtr(req.ZoneCode)).
SetStatus("在库").
SetNillableInboundNo(strPtr(req.OrderNo)).
SetOrderNo(req.OrderNo).
SetCompletedProcess(completed).
SetNillableOwnershipType(strPtr(ownershipTypeOrNone(req.OwnershipType))).
SetNillableOwnershipNo(strPtr(req.OwnershipNo)).
SetNillableProductStatus(strPtr(req.ProductStatus)).
SetNillableRelatedStandard(strPtr(req.RelatedStandard)).
SetNillableTestRecordNo(strPtr(req.TestRecordNo)).
SetNillableRecordedBy(strPtr(req.RecordedBy)).
SetNillableRecordedAt(int64Ptr(req.RecordedAt)).
SetRemark(catLabel + "完工暂存回流").
Save(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
ctx.EventLog.Write(ctx0(), "semi.inbound", r.Header.Get("X-Username"), "semi", req.Sn,
catLabel+"入库 "+req.Sn, map[string]any{"materialCode": materialCode, "category": category})
ok(w, semiView(rec))
}
}
// semiOutboundHandler 半成品/成品出库(重上线/发货等):更新 inventories 状态
// 客户诉求 L224:出库原因必选(包装/继续生产/检验试验/其他),写入 outbound_reason 留痕。
func semiOutboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
Sn string `json:"sn"`
ZoneCode string `json:"zoneCode"`
Operator string `json:"operator"`
Reason string `json:"reason"` // 出库原因:包装/继续生产/检验试验/其他
Category int `json:"category"` // 2=半成品 3=成品(默认 2
}
if err := parseJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, "参数错误")
return
}
if req.Sn == "" {
fail(w, http.StatusBadRequest, "sn 必填")
return
}
if strings.TrimSpace(req.Reason) == "" {
fail(w, http.StatusBadRequest, "出库原因必选(包装/继续生产/检验试验/其他)")
return
}
category := req.Category
if category != invCatFinished {
category = invCatSemi
}
catLabel := "半成品"
if category == invCatFinished {
catLabel = "成品"
}
rec, err := ctx.EntClient.Inventory.Query().
Where(inventory.CategoryEQ(category), inventory.SnCodeEQ(req.Sn)).
Only(ctx0())
if err != nil {
fail(w, http.StatusNotFound, catLabel+"不存在: "+req.Sn)
return
}
if rec.Status != "在库" {
fail(w, http.StatusConflict, catLabel+"不在库: "+rec.Status)
return
}
// 盘点冻结守卫(问题2):盘点期间库存量冻结,禁止半成品/成品出库。
if rec.StocktakeNo != "" {
fail(w, http.StatusConflict, catLabel+" "+req.Sn+" 正在盘点单 "+rec.StocktakeNo+" 锁定中,盘点期间禁止出库")
return
}
ctx.EntClient.Inventory.UpdateOneID(rec.ID).
SetStatus("出库").
SetNillableOutboundReason(strPtr(req.Reason)).
SetUpdatedAt(time.Now().Unix()).
ExecX(ctx0())
ctx.EventLog.Write(ctx0(), "semi.outbound", r.Header.Get("X-Username"), "semi", req.Sn,
catLabel+"出库 "+req.Sn, map[string]any{"reason": req.Reason})
ok(w, map[string]any{"sn": req.Sn, "status": "出库"})
}
}
// semiView 把统一库存行映射为半成品/成品视图(保持原 /api/semi/query 响应结构,前端零改造)
type semiViewRow struct {
ID int `json:"id"`
Sn string `json:"sn"`
MaterialCode string `json:"materialCode"`
MaterialName string `json:"materialName"`
Category int `json:"category"` // 2=半成品 3=成品
CompletedProcess string `json:"completedProcess"`
Quantity int `json:"quantity"`
ZoneCode string `json:"zoneCode"`
Status string `json:"status"`
OrderNo string `json:"orderNo"`
OwnershipNo string `json:"ownershipNo"`
ProductStatus string `json:"productStatus"`
RelatedStandard string `json:"relatedStandard"`
TestRecordNo string `json:"testRecordNo"`
RecordedBy string `json:"recordedBy"`
RecordedAt int64 `json:"recordedAt"`
OutboundReason string `json:"outboundReason"`
CreatedAt int64 `json:"createdAt"`
UpdatedAt int64 `json:"updatedAt"`
}
func semiView(inv *ent.Inventory) *semiViewRow {
return &semiViewRow{
ID: inv.ID,
Sn: inv.SnCode,
MaterialCode: inv.MaterialCode,
MaterialName: inv.MaterialName,
Category: inv.Category,
CompletedProcess: inv.CompletedProcess,
Quantity: inv.Quantity,
ZoneCode: inv.ZoneCode,
Status: inv.Status,
OrderNo: inv.OrderNo,
OwnershipNo: inv.OwnershipNo,
ProductStatus: inv.ProductStatus,
RelatedStandard: inv.RelatedStandard,
TestRecordNo: inv.TestRecordNo,
RecordedBy: inv.RecordedBy,
RecordedAt: inv.RecordedAt,
OutboundReason: inv.OutboundReason,
CreatedAt: inv.CreatedAt,
UpdatedAt: inv.UpdatedAt,
}
}
// querySemiHandler 半成品查询(读统一库存主表 category=2 半成品)。
// 半成品管理页只处理半成品;成品由 MES 完工回流入库、经 WMS 完工检验,在「库存查询」页按品类查看,不在本页手工出入库。
func querySemiHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
page := atoi(r.URL.Query().Get("page"), 1)
pageSize := atoi(r.URL.Query().Get("pageSize"), 20)
materialCode := r.URL.Query().Get("materialCode")
status := r.URL.Query().Get("status")
sn := r.URL.Query().Get("sn")
ownershipNo := r.URL.Query().Get("ownershipNo")
q := ctx.EntClient.Inventory.Query().Where(inventory.CategoryEQ(invCatSemi))
if materialCode != "" {
q = q.Where(inventory.MaterialCodeEqualFold(materialCode))
}
if status != "" {
q = q.Where(inventory.StatusEQ(status))
}
if sn != "" {
q = q.Where(inventory.SnCodeContainsFold(sn))
}
if ownershipNo != "" {
q = q.Where(inventory.OwnershipNoContainsFold(ownershipNo))
}
total, err := q.Count(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
list, err := q.Order(ent.Desc("created_at"), ent.Desc("id")).
Offset((page - 1) * pageSize).Limit(pageSize).All(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
rows := make([]*semiViewRow, 0, len(list))
for _, inv := range list {
rows = append(rows, semiView(inv))
}
ok(w, map[string]any{
"total": total,
"list": rows,
"page": page,
"pageSize": pageSize,
})
}
}
// ledgerRow 台账行(服务端派生 可用库存 / 缺口 / 四态)
type ledgerRow struct {
*ent.OrderMaterialLedger
AvailableQty int `json:"availableQty"` // 实时可用库存
ShortQty int `json:"shortQty"` // 缺口 = 需求 - 可用(下限 0)
State string `json:"state"` // 备料四态:齐套/未齐套/预警/未到货
}
// ledgerState 备料四态判定(客户诉求 问题记录 L242-258 孙彦飞口径),优先级:未到货 > 预警 > 未齐套 > 齐套。
//
// 状态定义(L253-256):
//
// 齐套 :可用库存 ≥ 需求总量
// 未齐套:0 < 可用库存 < 需求总量
// 未到货:可用库存 = 0 且 已出库 = 0
// 预警 :可用库存 < 未来5天需求量(future5Qty 由 MES 按近5天日排产×单台用量推送到 material_demands
// 无推送记录时 future5Qty=0,该档不生效,退化为三态判定)
func ledgerState(avail, need, out, future5Qty int) string {
if avail <= 0 && out <= 0 {
return "未到货"
}
if future5Qty > 0 && avail < future5Qty {
return "预警"
}
if avail >= need {
return "齐套"
}
return "未齐套"
}
// materialFuture5QtyMap 读取 MES 推送的未来5天需求量(material_demands),返回 material_code → qty
func materialFuture5QtyMap(ctx *svc.ServiceContext) map[string]int {
list, err := ctx.EntClient.MaterialDemand.Query().All(ctx0())
if err != nil {
return map[string]int{}
}
m := make(map[string]int, len(list))
for _, d := range list {
m[d.MaterialCode] = d.Future5Qty
}
return m
}
// materialAvailableQty 物料可用库存汇总(在库/锁定 且 质量状态=合格)。
// 客户口径(问题记录 L135):「仅合格品计入库存数量」——未检(待检)/不合格/待退 不计可用。
func materialAvailableQty(ctx *svc.ServiceContext, code string) int {
rows, err := ctx.EntClient.Inventory.Query().
Where(
inventory.MaterialCodeEQ(code),
inventory.StatusIn("在库", "锁定"),
inventory.QualityStatusEQ(QStatusPass),
).All(ctx0())
if err != nil {
return 0
}
avail := 0
for _, inv := range rows {
if inv.ManageMode == 1 {
avail += inv.Quantity - inv.LockedQty
} else if inv.Status == "在库" {
avail++
}
}
return avail
}
func max0(n int) int {
if n < 0 {
return 0
}
return n
}
// queryLedgerHandler 工单物料台账查询(含备料四态)
// 完成时间筛选(客户诉求 问题记录 L238-240):completeStart/completeEnd 按台账 completed_at
// (最后一笔领料完结出库时间)过滤,仅匹配已完结的台账行。
func queryLedgerHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orderNo := r.URL.Query().Get("orderNo")
materialCode := r.URL.Query().Get("materialCode")
materialName := r.URL.Query().Get("materialName")
status := r.URL.Query().Get("status")
state := r.URL.Query().Get("state") // 四态筛选:齐套/未齐套/预警/未到货
completeStart := r.URL.Query().Get("completeStart") // 完成时间起(YYYY-MM-DD
completeEnd := r.URL.Query().Get("completeEnd") // 完成时间止
page := atoi(r.URL.Query().Get("page"), 1)
pageSize := atoi(r.URL.Query().Get("pageSize"), 20)
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
q := ctx.EntClient.OrderMaterialLedger.Query()
if orderNo != "" {
q = q.Where(ordermaterialledger.OrderNoEQ(orderNo))
}
if materialCode != "" {
q = q.Where(ordermaterialledger.MaterialCodeEqualFold(materialCode))
}
if materialName != "" {
q = q.Where(ordermaterialledger.MaterialNameContains(materialName))
}
if status != "" {
// 兼容旧口径(进行中/领料完结)
switch status {
case "进行中":
q = q.Where(ordermaterialledger.StatusEQ("进行中"))
case "领料完结":
q = q.Where(ordermaterialledger.StatusEQ("领料完结"))
}
}
if completeStart != "" {
if t, err := time.ParseInLocation("2006-01-02", completeStart, time.Local); err == nil {
q = q.Where(ordermaterialledger.CompletedAtGTE(t.Unix()))
}
}
if completeEnd != "" {
if t, err := time.ParseInLocation("2006-01-02", completeEnd, time.Local); err == nil {
q = q.Where(ordermaterialledger.CompletedAtLTE(t.Add(24 * time.Hour).Unix()))
}
}
all, err := q.Order(ent.Desc("created_at"), ent.Desc("id")).All(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
// 计算可用库存 + 四态(四态需实时库存 + MES 推送的未来5天需求量,故在 Go 侧派生)
availCache := map[string]int{}
demandMap := materialFuture5QtyMap(ctx)
rows := make([]*ledgerRow, 0, len(all))
for _, lg := range all {
avail, ok2 := availCache[lg.MaterialCode]
if !ok2 {
avail = materialAvailableQty(ctx, lg.MaterialCode)
availCache[lg.MaterialCode] = avail
}
st := ledgerState(avail, lg.TotalQty, lg.OutQty, demandMap[lg.MaterialCode])
if state != "" && st != state {
continue
}
rows = append(rows, &ledgerRow{
OrderMaterialLedger: lg,
AvailableQty: avail,
ShortQty: max0(lg.TotalQty - avail),
State: st,
})
}
total := len(rows)
start := (page - 1) * pageSize
if start > total {
start = total
}
end := start + pageSize
if end > total {
end = total
}
pageRows := rows[start:end]
ok(w, map[string]any{
"total": total,
"list": pageRows,
"page": page,
"pageSize": pageSize,
})
}
}