Files
bj_power/bj_power_mes/internal/logic/material.go
T
SunYF d609ff8a9e refactor: 重构工艺工序相关命名与数据模型
1.  全局替换"工序"为"工艺"统一术语,包括页面文案、枚举、注释
2.  重构工单与工件工艺数据模型:
    - 新增工艺组合表flow_material作为备料/齐套唯一源头
    - 新增工位状态表station_state支持自主停单/恢复接单
    - 移除station_process派工表,改用工单工艺组合作为路线唯一源头
    - 替换processCode为flowId作为工艺关联标识
    - 重构工件当前进度字段为currentStationNo
3.  删除冗余的静态备份资源文件
4.  调整物料选择组件默认启用仅显示激活物料
5.  优化工单创建/编辑逻辑,新增工艺组合校验与保存
2026-09-22 11:32:29 +08:00

537 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 logic
import (
"context"
"errors"
"fmt"
"math"
"sort"
"strconv"
"strings"
"time"
"bj_power_mes/ent"
"bj_power_mes/ent/flowmaterial"
"bj_power_mes/ent/materialrequest"
"bj_power_mes/ent/station"
"bj_power_mes/ent/workorder"
"bj_power_mes/internal/wmsclient"
)
// GenerateMaterialRequest 按日排产 + 工单工艺组合 + 工艺物料清单(flow_material) 自动算料,生成工位级备料单。
//
// 算法(2026-09-22 生产模型定稿):
// - 路线来源:工单 flow_combination[{flowId,stationNo}],工位号即顺序号)。无组合的工单跳过并记日志。
// - 物料来源:组合条目涉及工艺的 flow_material(工艺物料清单 ⊆ 产品 BOM)。
// - 数量:备料 = 组合条目 × flow_material × 分产量——各工位量取日排产 stationPlanQty
// (全有值且和>0 时采用,否则同工艺工位平均拆、余数补前位);reqQty = 分产量 × 单台用量 ×(1+损耗率)。
// - 目标接驳台:取 station.dock_code(无接驳台则留空待仓管指定)。
// - BOM 中未被任何涉及工艺引用的物料:记 eventlog(bom.unassigned) 告警,不阻断生成。
//
// 生成前校验:涉及物料编码须在 WMS 物料档案存在,有缺失返回 missing(不生成);
// WMS 不可达/异常时降级为"全部存在"并记 eventlog,避免 WMS 抖动阻塞产线。
// 返回值:count=本次生成条数;missing=WMS 中不存在的物料编码(非空时 count=0)。
func (s *Service) GenerateMaterialRequest(ctx context.Context, planDate string, operator string) (int, []string, error) {
plans, err := s.ListDailyPlans(ctx, "", planDate)
if err != nil {
return 0, nil, err
}
if len(plans) == 0 {
return 0, nil, errors.New("该日期没有排产计划")
}
type planGroup struct {
plan *ent.DailyPlan
wo *ent.WorkOrder
items []FlowItem // 工单工艺组合(工位号升序)
fmByFlow map[int][]*ent.FlowMaterial // 工艺ID → 工艺物料清单
}
groups := make([]planGroup, 0, len(plans))
codeSet := map[string]bool{}
for _, plan := range plans {
if plan.Status == "CANCELLED" || plan.Status == "DONE" {
continue // 已取消/已完成的排产不再算料
}
wo, err := s.ctx.EntClient.WorkOrder.Query().
Where(workorder.WorkOrderNo(plan.OrderNo)).Only(ctx)
if err != nil {
continue
}
items, _ := RouteStationsFromWO(ctx, s.ctx.EntClient, wo)
if len(items) == 0 {
s.ctx.EventLog.Write(ctx, "material.request.skip", plan.OrderNo, operator, "material_request", plan.OrderNo,
"工单未配置工艺组合,本日跳过备料", map[string]any{"planDate": plan.PlanDate})
continue
}
g := planGroup{plan: plan, wo: wo, items: items, fmByFlow: map[int][]*ent.FlowMaterial{}}
for _, it := range items {
if _, done := g.fmByFlow[it.FlowId]; done {
continue
}
fms, err := s.ctx.EntClient.FlowMaterial.Query().
Where(flowmaterial.FlowId(it.FlowId)).All(ctx)
if err != nil {
continue
}
g.fmByFlow[it.FlowId] = fms
for _, fm := range fms {
if fm.MaterialCode != "" {
codeSet[fm.MaterialCode] = true
}
}
}
// BOM 物料未被任何涉及工艺引用 → 告警不阻断(工艺物料清单 ⊆ 产品 BOM 的兜底提醒)
if bom, e := s.ListBom(ctx, wo.ProductCode, bomNameOrDefault("")); e == nil {
covered := map[string]bool{}
for _, fms := range g.fmByFlow {
for _, fm := range fms {
covered[fm.MaterialCode] = true
}
}
un := []string{}
for _, b := range bom {
if b.MaterialCode != "" && !covered[b.MaterialCode] {
un = append(un, b.MaterialCode)
}
}
if len(un) > 0 {
s.ctx.EventLog.Write(ctx, "bom.unassigned", plan.OrderNo, operator, "product_bom", wo.ProductCode,
"BOM 物料未配置在任何涉及工艺的物料清单,本次备料未覆盖",
map[string]any{"materials": un})
}
}
groups = append(groups, g)
}
if len(groups) == 0 {
return 0, nil, errors.New("该日期排产均无有效工单工艺组合,无法生成备料单")
}
// 阶段1:WMS 物料存在性校验(全部存在才生成)
codes := make([]string, 0, len(codeSet))
for c := range codeSet {
codes = append(codes, c)
}
missing, wmsErr := s.ctx.Wms.MaterialExists(ctx, codes)
if wmsErr != nil {
// 降级:WMS 不可达按"全部存在"放行,记日志供事后核查
s.ctx.EventLog.Write(ctx, "material.request.wms_skip", "", operator, "material_request", planDate,
"WMS 物料校验不可达,已降级放行", map[string]any{"planDate": planDate, "error": wmsErr.Error()})
} else if len(missing) > 0 {
return 0, missing, nil
}
// 阶段2:全部存在 → 按 组合条目 × flow_material × 分产量 拆料生成
created := 0
tsBase := time.Now().Format("20060102150405")
seq := 1
for _, g := range groups {
// 工位产量分配(日排产层):stationNo(字符串键) → 计划产量
stationQty := map[int]int{}
for k, v := range g.plan.StationPlanQty {
if n, e := strconv.Atoi(k); e == nil {
stationQty[n] = v
}
}
// 工艺ID → 该工艺涉及的全部工位(组合内升序)
stationsByFlow := map[int][]int{}
for _, it := range g.items {
stationsByFlow[it.FlowId] = append(stationsByFlow[it.FlowId], it.StationNo)
}
// 工位号 → 接驳台编码
dockByStation := map[int]string{}
if sts, e := s.ctx.EntClient.Station.Query().
Where(station.StationNoIn(RouteStationsOfWO(g.items)...)).All(ctx); e == nil {
for _, st := range sts {
dockByStation[st.StationNo] = st.DockCode
}
}
for flowId, stations := range stationsByFlow {
fms := g.fmByFlow[flowId]
if len(fms) == 0 {
continue // 该工艺未配置物料清单 → 无料可备
}
// 分产量:stationPlanQty 全有值且和>0 直接采用,否则平均拆、余数补前位
qtys := splitQtyToStations(stations, g.plan.PlanQty, stationQty)
for _, fm := range fms {
if fm.MaterialCode == "" {
continue
}
for i, stNo := range stations {
q := qtys[i]
if q <= 0 {
continue
}
reqQty := float64(q) * fm.UnitQty * (1 + fm.LossRate/100)
if reqQty <= 0 {
continue
}
reqNo := fmt.Sprintf("MR%s%03d", tsBase, seq)
seq++
_ = s.ctx.EntClient.MaterialRequest.Create().
SetRequestNo(reqNo).SetOrderNo(g.plan.OrderNo).SetPlanDate(g.plan.PlanDate).
SetMaterialCode(fm.MaterialCode).SetMaterialName(fm.MaterialName).
SetUnit(fm.Unit).SetManageMode(fm.ManageMode).SetReqQty(reqQty).
SetStatus("PENDING").SetOperator(operator).
SetStationNo(stNo).SetFlowId(flowId).SetTargetDock(dockByStation[stNo]).Exec(ctx)
created++
}
}
}
}
s.ctx.EventLog.Write(ctx, "material.request.generate", "", operator, "material_request", planDate, "按日排产+工单工艺组合+工艺物料清单生成工位级备料单", map[string]any{"planDate": planDate, "count": created})
// 需求10:把每个工单的「工单级需求总量」同步给 WMS 台账(幂等,失败降级不阻塞)。
// 必须先同步 total_qty,后续自动出库才能在台账上累加 out_qty 并判定缺口/领料完结。
synced := map[string]bool{}
for _, g := range groups {
if synced[g.plan.OrderNo] {
continue
}
synced[g.plan.OrderNo] = true
if e := s.SyncOrderLedger(ctx, g.plan.OrderNo, operator); e != nil {
s.ctx.EventLog.Write(ctx, "material.ledger.sync_fail", "", operator, "material_request", g.plan.OrderNo,
"工单需求量同步 WMS 台账失败", map[string]any{"orderNo": g.plan.OrderNo, "error": e.Error()})
}
}
// 生成后立即自动出库(客户第1条:平时无需操作)。缺料/失败不阻塞生成,仅记预警等待补库存。
if _, short := s.AutoOutboundRequests(ctx, planDate, "", operator); len(short) > 0 {
s.ctx.EventLog.Write(ctx, "material.outbound.short", "", operator, "material_request", planDate,
"备料自动出库未齐套", map[string]any{"planDate": planDate, "short": strings.Join(short, "; ")})
}
s.notifyDashboard()
return created, nil, nil
}
// SyncOrderLedger 计算并向 WMS 下发某工单的「工单级物料需求总量」(需求10)。
//
// 需求量口径(临时.md §四 工单域):需求量 = 工单数量 × 单台用量 × (1+损耗率),向上取整。
// 工单级而非日级——用日级会误卡跨天生产(临时.md L283-285)。
// 多级 BOM 展开到叶子后按物料编码聚合(同编码合并),一个物料一行下发。
// WMS 侧按 orderNo+materialCode 幂等 upsert total_qty,可重复调用。
func (s *Service) SyncOrderLedger(ctx context.Context, orderNo, operator string) error {
wo, err := s.ctx.EntClient.WorkOrder.Query().
Where(workorder.WorkOrderNo(orderNo)).Only(ctx)
if err != nil {
return err
}
leaves := s.expandBomToLeaf(ctx, wo.ProductCode, bomNameOrDefault(""), 1, 0, map[string]bool{})
if len(leaves) == 0 {
return nil
}
type agg struct {
name string
qty float64
}
m := map[string]*agg{}
order := make([]string, 0, len(leaves))
for _, it := range leaves {
if it.materialCode == "" {
continue
}
need := float64(wo.Quantity) * it.unitQty * (1 + it.lossRate/100)
if need <= 0 {
continue
}
if a, ok := m[it.materialCode]; ok {
a.qty += need
} else {
m[it.materialCode] = &agg{name: it.materialName, qty: need}
order = append(order, it.materialCode)
}
}
items := make([]wmsclient.LedgerSyncItem, 0, len(order))
for _, code := range order {
a := m[code]
items = append(items, wmsclient.LedgerSyncItem{
MaterialCode: code,
MaterialName: a.name,
TotalQty: int(math.Ceil(a.qty - 1e-9)),
})
}
if len(items) == 0 {
return nil
}
return s.ctx.Wms.LedgerSync(ctx, orderNo, wo.ProductCode, operator, items)
}
// AutoOutboundRequests 工单备料自动出库(客户第1条:备料出库根据 MES 排产自动生成,平时无需操作)。
//
// 对指定排产日 / 工单下的备料单,按 FIFO 从 WMS「合格」在库库存自动领料:
// - 结构件(manageMode=1):按批次号升序逐批出库,直到补足未出数量;
// - 电气件(manageMode=2):按可用 SN 逐件出库。
//
// 幂等:只出「未出数量 = 需求量 − 已发数量」,重复调用不会超领。
// 库存不足的行已出部分照常计入 sentQty,差额维持待备料并触发「备料未齐套」预警,
// 库存补齐后再次调用即可补出(补出由 WMS 台账强约束兜底,不会超出工单 BOM 需求)。
// 返回:本次补足(出库总量达标)的备料单条数、未出齐的明细文案。
func (s *Service) AutoOutboundRequests(ctx context.Context, planDate, orderNo, operator string) (int, []string) {
list, err := s.ListMaterialRequests(ctx, MaterialRequestQuery{OrderNo: orderNo, PlanDate: planDate})
if err != nil {
return 0, []string{"查询备料单失败: " + err.Error()}
}
// 补料单(refill)优先于排产备料(plan):先把补料发出,避免产线因缺料停线。
sort.SliceStable(list, func(i, j int) bool {
return list[i].Source == "REFILL" && list[j].Source != "REFILL"
})
done := 0
short := make([]string, 0)
for _, mr := range list {
need := int(mr.ReqQty - mr.SentQty + 0.5)
if need <= 0 {
continue
}
batches, sns, err := s.ctx.Wms.StockBatches(ctx, mr.MaterialCode)
if err != nil {
short = append(short, fmt.Sprintf("%s %s 查询 WMS 库存失败", mr.OrderNo, mr.MaterialCode))
continue
}
dock := mr.TargetDock
if dock == "" {
dock = "DOCK01"
}
remain := need
outboundNos := make([]string, 0, 4)
// 出库参数:补料(source=REFILL) 记为 refill 类型并默认叫车,且携带超领原因
// (补料常因损耗/报废超出 BOM 需求,WMS 侧有原因才放行)。
obType := "workorder"
if mr.Source == "REFILL" {
obType = "refill"
}
opts := wmsclient.StockDeductOpts{
Qty: need, Operator: operator, TargetDock: dock,
OutboundType: obType, NeedAgv: mr.NeedAgv, OverReason: mr.OverReason,
}
if mr.ManageMode == "2" {
for _, sn := range sns {
if remain <= 0 {
break
}
opts.SnList = []string{sn}
opts.BatchNo = ""
opts.Qty = 1
no, _, e := s.ctx.Wms.StockDeduct(ctx, mr.OrderNo, mr.MaterialCode, opts)
if e != nil {
break
}
outboundNos = append(outboundNos, no)
remain--
}
} else {
for _, b := range batches {
if remain <= 0 {
break
}
take := b.Avail
if take > remain {
take = remain
}
if take <= 0 {
continue
}
opts.SnList = nil
opts.BatchNo = b.BatchNo
opts.Qty = take
opts.ZoneCode = b.ZoneCode
no, _, e := s.ctx.Wms.StockDeduct(ctx, mr.OrderNo, mr.MaterialCode, opts)
if e != nil {
break
}
outboundNos = append(outboundNos, no)
remain -= take
}
}
sent := need - remain
if sent > 0 {
upd := s.ctx.EntClient.MaterialRequest.UpdateOneID(mr.ID).AddSentQty(float64(sent))
if remain == 0 {
// 出库量已达标:备料完成,等 AGV 配送(WMS 下发后回写 DELIVERING
upd = upd.SetStatus("LOCKED")
done++
}
if _, e := upd.Save(ctx); e == nil {
s.ctx.EventLog.Write(ctx, "material.outbound.auto", mr.OrderNo, operator, "material_request", mr.RequestNo,
"备料自动出库", map[string]any{"materialCode": mr.MaterialCode, "qty": sent, "outboundNos": strings.Join(outboundNos, ",")})
}
}
if remain > 0 {
short = append(short, fmt.Sprintf("%s %s 缺 %d", mr.OrderNo, mr.MaterialCode, remain))
s.Evaluate(ctx, "material_shortage", 1, "备料未齐套",
fmt.Sprintf("工单 %s 物料 %s 自动出库后仍缺 %d(已出 %d/%d", mr.OrderNo, mr.MaterialCode, remain, sent, need),
"material_request", mr.RequestNo, mr.OrderNo)
}
}
if done > 0 {
s.notifyDashboard()
}
return done, short
}
// bomLeaf 递归展开后的叶子物料行(原材料/外购件)
type bomLeaf struct {
materialCode string
materialName string
unit string
manageMode string
unitQty float64 // 已折算父级倍率的单台用量
lossRate float64
}
// expandBomToLeaf 递归展开产品物料清单到叶子(原材料/外购件),供备料算料。
//
// 业务口径:成品可以作为零部件被另一个成品再次加工(多级嵌套)。备料算料时必须把
// 成品子项按其自身的物料清单继续展开,直到原材料/外购件,否则产线会缺料。
// 用量按倍率相乘(父级单台用量 × 子级单台用量)。
//
// R12026-09-19):递归骨架统一到 bom_expand.go 的 expandBomTree,与追溯侧
// expandBindBom)共用同一份实现;下钻判定由 wmsFinishedResolver 注入,
// WMS 不可达时按叶子处理,不阻塞备料生成。
func (s *Service) expandBomToLeaf(ctx context.Context, productCode, bomName string, mult float64, depth int, path map[string]bool) []bomLeaf {
return bomTreeToLeaf(s.expandBomTree(ctx, productCode, bomName, mult, depth, path, s.wmsFinishedResolver))
}
// splitQtyToStations 把日总量 dayQty 拆分到 stations 各工位:
// 优先使用 stationQty 显式分配(其和>0 时直接采用);否则平均分配(余数补到前面工位)。
func splitQtyToStations(stations []int, dayQty int, stationQty map[int]int) []int {
n := len(stations)
out := make([]int, n)
usePlan := true
sum := 0
for _, st := range stations {
v, ok := stationQty[st]
if !ok {
usePlan = false
break
}
sum += v
}
if usePlan && sum > 0 {
for i, st := range stations {
out[i] = stationQty[st]
}
return out
}
if n == 0 {
return out
}
base := dayQty / n
for i := 0; i < n; i++ {
out[i] = base
}
for i := 0; i < dayQty-base*n; i++ {
out[i]++
}
return out
}
// MaterialRequestQuery 备料单查询条件(零值=不过滤)。
// 统一入口:原 ListMaterialRequests / ListMaterialRequestsFiltered 两套位置参数合并到本结构,
// 避免参数越加越长、调用点易错。
type MaterialRequestQuery struct {
OrderNo string
PlanDate string
Status string
MaterialCode string
MaterialName string
TargetDock string
StationNo string
Source string // PLAN / REFILL
RequestNo string // 备料单号(独立模糊筛选;列表页禁用关键字混搜)
}
// ListMaterialRequests 备料单查询(大小写不敏感)。
func (s *Service) ListMaterialRequests(ctx context.Context, o MaterialRequestQuery) ([]*ent.MaterialRequest, error) {
q := s.ctx.EntClient.MaterialRequest.Query()
if o.OrderNo != "" {
q = q.Where(materialrequest.OrderNoEqualFold(o.OrderNo))
}
if o.PlanDate != "" {
q = q.Where(materialrequest.PlanDate(o.PlanDate))
}
if o.Status != "" {
q = q.Where(materialrequest.Status(o.Status))
}
if o.MaterialCode != "" {
q = q.Where(materialrequest.MaterialCodeContainsFold(o.MaterialCode))
}
if o.MaterialName != "" {
q = q.Where(materialrequest.MaterialNameContainsFold(o.MaterialName))
}
if o.TargetDock != "" {
q = q.Where(materialrequest.TargetDockEqualFold(o.TargetDock))
}
if o.Source != "" {
q = q.Where(materialrequest.Source(o.Source))
}
if o.RequestNo != "" {
q = q.Where(materialrequest.RequestNoContainsFold(o.RequestNo))
}
if o.StationNo != "" {
if n, e := strconv.Atoi(o.StationNo); e == nil {
q = q.Where(materialrequest.StationNo(n))
}
}
return q.Order(ent.Desc(materialrequest.FieldCreatedAt), ent.Desc(materialrequest.FieldID)).All(ctx)
}
// ReceiveMaterialRequest 接料确认(两个入口共用:工位终端扫码接料 / MES 备料页人工点「已接料」)。
//
// 字段口径(2026-09-19 定稿):写 receivedQty(已接料),**不再累加 sentQty**。
//
// sentQty 已发出量 [写者:自动出库] —— 幂等基准「还需发多少 = reqQty sentQty」
// receivedQty 已接料量 [写者:本方法] —— 在途 = sentQty receivedQty
//
// 上限 = 已发出量(工位只能接到仓库已发出来的料),不超接。
// qty<=0 表示「确认本批全部剩余」,适合扫码/一键接料;>0 为本次实收批次量。
// 累计接到 sentQty 且已发齐 reqQty → 状态置 DONE(送达并与接料双完结)。
func (s *Service) ReceiveMaterialRequest(ctx context.Context, requestNo string, qty int, operator string) error {
mr, err := s.ctx.EntClient.MaterialRequest.Query().
Where(materialrequest.RequestNo(requestNo)).Only(ctx)
if err != nil {
return errors.New("备料单不存在")
}
sent := int(mr.SentQty)
already := int(mr.ReceivedQty)
pending := sent - already // 已发出但尚未接料(在途)
if pending < 0 {
pending = 0
}
add := qty
if add <= 0 {
add = pending // 确认本批全部剩余
}
if add > pending {
add = pending // 不超接:上限是仓库已发出的量
}
if add < 0 {
add = 0
}
newReceived := already + add
upd := s.ctx.EntClient.MaterialRequest.UpdateOneID(mr.ID).
SetReceivedQty(float64(newReceived)).
SetReceivedAt(time.Now().Unix()).
SetReceiveBy(operator)
if mr.Status != "DONE" && sent >= int(mr.ReqQty) && newReceived >= sent {
upd.SetStatus("DONE")
}
if _, err = upd.Save(ctx); err != nil {
return err
}
s.ctx.EventLog.Write(ctx, "material.request.receive", requestNo, operator, "material_request", requestNo,
"接料确认", map[string]any{"add": add, "receivedQty": newReceived, "sentQty": sent,
"reqQty": int(mr.ReqQty), "awaiting": sent - newReceived})
s.notifyDashboard()
return nil
}
// SetMaterialRequestStatus 推进备料单状态(WMS 下发 AGV DELIVERING / 到位 DONE
func (s *Service) SetMaterialRequestStatus(ctx context.Context, requestNo, status string) error {
n, err := s.ctx.EntClient.MaterialRequest.Update().
Where(materialrequest.RequestNo(requestNo)).
SetStatus(status).
Save(ctx)
if err != nil {
return err
}
if n == 0 {
return errors.New("备料单不存在")
}
s.notifyDashboard()
return nil
}