Files
SunYF f35e9b5b75 docs(deployment): 更新部署手册和业务架构说明
- 更新MES和WMS服务端口配置说明
- 详细说明产线工位设备配置(扫码枪+拧紧枪)
- 明确AGV配送和接驳台的模拟模式与真实对接方案
- 更新WMS中AGV配送默认模拟模式说明
- 完善厂内物流(AGV/接驳台)的业务架构约束
- 修订临时文档为详细的项目问题和解决方案规划
2026-09-20 14:52:36 +08:00

528 lines
19 KiB
Go
Raw Permalink 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/materialrequest"
"bj_power_mes/ent/station"
"bj_power_mes/ent/workorder"
"bj_power_mes/internal/wmsclient"
)
// GenerateMaterialRequest 按日排产 + 今日工位派工(station_process) 自动算料,生成工位级备料单。
//
// 算法:
// - 路线来源:今日 station_process 派工(工位号→工序编号)。某工序(1~12)今日派工到哪些工位,
// 该工序的 BOM 物料就拆到这些工位各生成一行备料单(物料随工序上料)。
// - 数量:单工位量 = 该工位分配产量(日排产 stationPlanQty,未配置则日总量平均到各派工工位)
// × BOM 单台用量 ×(1+损耗率);同工序多工位时日总量按分配/平均拆分,不重复放大。
// - 目标接驳台:取该派工工位的 station.dockCode(无接驳台则留空待仓管指定)。
// - processCode=0 的 BOM 物料(不参与工位绑定):按日总量生成一行,工位/接驳台留空。
//
// 生成前校验:BOM 内所有物料编码须在 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("该日期没有排产计划")
}
// 今日工位派工:工位号 → 工序编号
procMap, err := RouteStationProcessCodeMap(ctx, s.ctx.EntClient, TodayStr(), "")
if err != nil {
return 0, nil, err
}
if len(procMap) == 0 {
return 0, nil, errors.New("今日未配置工位派工(station_process),无法生成备料单")
}
// 工序编号 → 工位号列表(反查,供 BOM 物料按工序拆到工位)
stationsByProcess := map[int][]int{}
stNos := make([]int, 0, len(procMap))
for stNo, pc := range procMap {
stationsByProcess[pc] = append(stationsByProcess[pc], stNo)
stNos = append(stNos, stNo)
}
// 工位号 → 接驳台编码
dockByStation := map[int]string{}
if sts, e := s.ctx.EntClient.Station.Query().Where(station.StationNoIn(stNos...)).All(ctx); e == nil {
for _, st := range sts {
dockByStation[st.StationNo] = st.DockCode
}
}
type planBom struct {
plan *ent.DailyPlan
wo *ent.WorkOrder
bom []*ent.BomItem
}
groups := make([]planBom, 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
}
bom, err := s.ListBom(ctx, wo.ProductCode, bomNameOrDefault(""))
if err != nil {
continue
}
if len(bom) == 0 {
continue
}
for _, item := range bom {
if item.MaterialCode != "" {
codeSet[item.MaterialCode] = true
}
}
groups = append(groups, planBom{plan: plan, wo: wo, bom: bom})
}
if len(groups) == 0 {
return 0, nil, errors.New("该日期排产均无有效 BOM 物料,无法生成备料单")
}
// 阶段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:全部存在 → 按工位拆料生成
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
}
}
// 成品可作零部件:BOM 子项若为成品,递归展开到叶子(原材料/外购件)再算料
leaves := s.expandBomToLeaf(ctx, g.wo.ProductCode, bomNameOrDefault(""), 1, 0, map[string]bool{})
for _, item := range leaves {
pc := item.processCode // 该物料所属装配工序(1~12),0=不参与工位绑定
if pc == 0 {
reqQty := float64(g.plan.PlanQty) * item.unitQty * (1 + item.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(item.materialCode).SetMaterialName(item.materialName).
SetUnit(item.unit).SetManageMode(item.manageMode).SetReqQty(reqQty).
SetStatus("PENDING").SetOperator(operator).Exec(ctx)
created++
continue
}
stations := stationsByProcess[pc]
if len(stations) == 0 {
// 该工序今日未派工到任何工位,本日无需上料(非缺料)
continue
}
qtys := splitQtyToStations(stations, g.plan.PlanQty, stationQty)
for i, stNo := range stations {
q := qtys[i]
if q <= 0 {
continue
}
reqQty := float64(q) * item.unitQty * (1 + item.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(item.materialCode).SetMaterialName(item.materialName).
SetUnit(item.unit).SetManageMode(item.manageMode).SetReqQty(reqQty).
SetStatus("PENDING").SetOperator(operator).
SetStationNo(stNo).SetProcessCode(pc).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
processCode int
}
// 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
}