Files
bj_power/bj_power_wms/internal/handler/misc.go
T
SunYF 1cc93795ce feat(mes): 添加定时同步可生产数量到WMS及BOM项相关标准字段
- 在main.go中添加定时任务每10分钟同步可生产数量到WMS系统
- 为BomItem实体添加relatedStandard字段及相关CRUD方法
- 为InspectionRecord实体添加reportNo、materialCode、materialName等字段
- 更新ent schema确保新字段的验证和默认值设置
- 添加必要的数据库迁移和字段映射逻辑
2026-09-17 12:49:07 +08:00

461 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
}
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/3
// 客户诉求 L230:查询增加归属(工单号/科技项目号);L222:半成品与成品分开查(category 参数)。
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")
category := atoi(r.URL.Query().Get("category"), 0) // 2=半成品 3=成品 0=两者都查
q := ctx.EntClient.Inventory.Query().Where(inventory.CategoryIn(invCatSemi, invCatFinished))
if category == invCatSemi || category == invCatFinished {
q = q.Where(inventory.CategoryEQ(category))
}
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 物料可用库存汇总(在库/锁定 且 质量状态非 不合格/待退)。
func materialAvailableQty(ctx *svc.ServiceContext, code string) int {
rows, err := ctx.EntClient.Inventory.Query().
Where(
inventory.MaterialCodeEQ(code),
inventory.StatusIn("在库", "锁定"),
inventory.QualityStatusNotIn("不合格", "待退"),
).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,
})
}
}