Files
bj_power/bj_power_mes/internal/logic/dailyplan.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

396 lines
13 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"
"sort"
"strconv"
"strings"
"time"
"bj_power_mes/ent"
"bj_power_mes/ent/dailyplan"
"bj_power_mes/ent/workorder"
"bj_power_mes/ent/workpiece"
)
// parseDate 解析 yyyy-MM-dd 或标准时间
func parseDate(v string) (time.Time, error) {
if t, err := time.ParseInLocation("2006-01-02", v, time.Local); err == nil {
return t, nil
}
return time.ParseInLocation(time.RFC3339, v, time.Local)
}
type DailyPlanReq struct {
Id int `json:"id"`
OrderNo string `json:"orderNo"`
PlanDate string `json:"planDate"`
PlanQty int `json:"planQty"`
CompletedQty int `json:"completedQty"`
Status string `json:"status"`
DockCodes []string `json:"dockCodes"`
}
// SaveDailyPlan 创建/更新日排产(同一 工单+日期 唯一)
// 排产状态枚举统一为:PENDING(待执行)/PROCESSING(执行中)/DONE(已完成)/CANCELLED(已取消)
func (s *Service) SaveDailyPlan(ctx context.Context, req DailyPlanReq, operator string) error {
if req.OrderNo == "" || req.PlanDate == "" {
return errors.New("工单号和排产日期必填")
}
// 校验:该工单所有日排产计划数量累计不得超过工单总数量(保存时合计,改小可、改大不超总量)
wo, err := s.ctx.EntClient.WorkOrder.Query().Where(workorder.WorkOrderNo(req.OrderNo)).First(ctx)
if err != nil {
return errors.New("工单不存在")
}
if wo.Status == "DONE" || wo.Status == "CANCELLED" {
return errors.New("该工单已完成或已取消,不能排产")
}
if wo.Status == "PAUSED" {
return errors.New("该工单已暂停,恢复执行前不能新增排产")
}
cur, err := s.ctx.EntClient.DailyPlan.Query().
Where(dailyplan.OrderNo(req.OrderNo)).All(ctx)
if err != nil {
return err
}
// 计算合计:本次 req.PlanQty 恒计入;若「工单+日期」已有记录,则原值作废只算新值(避免旧值+新值重复累计)
total := req.PlanQty
for _, p := range cur {
if p.PlanDate == req.PlanDate {
if p.CompletedQty > 0 {
return errors.New("该排产日已有完工数量,不能修改计划数量")
}
continue
}
total += p.PlanQty
}
// 跨天在制品:只排「未开工数量 = 工单总数量 − 已完工 − 已进线未完工」,在产工件跨天继续、不被重排冲掉
remain, err := s.unscheduledQty(ctx, wo)
if err != nil {
return err
}
if total > remain {
return errors.New("排产合计数量超过未开工数量(工单总数量−已完工−在产未完工),无法保存")
}
exist, err := s.ctx.EntClient.DailyPlan.Query().
Where(dailyplan.OrderNo(req.OrderNo), dailyplan.PlanDate(req.PlanDate)).First(ctx)
status := req.Status
switch status {
case "", "PENDING", "PROCESSING", "DONE", "CANCELLED", "CONFIRMED":
if status == "" {
status = "PENDING"
}
default:
return errors.New("非法的排产状态")
}
if err == nil && exist != nil {
_, err = s.ctx.EntClient.DailyPlan.UpdateOneID(exist.ID).
SetOrderNo(req.OrderNo).SetPlanDate(req.PlanDate).SetPlanQty(req.PlanQty).SetStatus(status).
SetDockCodes(dockCodes(req.DockCodes)).Save(ctx)
if err != nil {
return err
}
} else {
_, err = s.ctx.EntClient.DailyPlan.Create().
SetOrderNo(req.OrderNo).SetPlanDate(req.PlanDate).SetPlanQty(req.PlanQty).
SetStatus(status).SetOperator(operator).SetDockCodes(dockCodes(req.DockCodes)).Save(ctx)
if err != nil {
return err
}
}
// 记录该工单本次使用的接驳台到工单 context,供下次排产默认推荐
if len(dockCodes(req.DockCodes)) > 0 {
s.rememberOrderDocks(ctx, req.OrderNo, dockCodes(req.DockCodes))
}
s.ctx.EventLog.Write(ctx, "daily.plan.save", req.OrderNo, operator, "daily_plan", req.OrderNo, "保存日排产", map[string]any{"planDate": req.PlanDate, "planQty": req.PlanQty, "dockCodes": req.DockCodes})
s.notifyDashboard()
return nil
}
// dockCodes 规范化接驳台列表:仅保留 DOCK01..DOCK20 / DOCK21,去重、保持顺序
func dockCodes(codes []string) []string {
seen := map[string]bool{}
out := []string{}
for _, c := range codes {
c = strings.TrimSpace(c)
if !validDockCode(c) || seen[c] {
continue
}
seen[c] = true
out = append(out, c)
}
return out
}
// validDockCode DOCK01..DOCK21
func validDockCode(c string) bool {
if len(c) != 6 || !strings.HasPrefix(c, "DOCK") {
return false
}
n, err := strconv.Atoi(c[4:])
return err == nil && n >= 1 && n <= 21
}
// rememberOrderDocks 把工单「上次使用的接驳台」写入 work_order.context,供下次排产默认推荐
func (s *Service) rememberOrderDocks(ctx context.Context, orderNo string, docks []string) {
if len(docks) == 0 {
return
}
wo, err := s.ctx.EntClient.WorkOrder.Query().Where(workorder.WorkOrderNo(orderNo)).Only(ctx)
if err != nil {
return
}
c := wo.Context
if c == nil {
c = map[string]any{}
}
c["lastDockCodes"] = docks
_ = s.ctx.EntClient.WorkOrder.UpdateOneID(wo.ID).SetContext(c).Exec(ctx)
}
// SuggestedDockCodes 查某工单「上次使用的接驳台」,供排产默认推荐
func (s *Service) SuggestedDockCodes(ctx context.Context, orderNo string) []string {
wo, err := s.ctx.EntClient.WorkOrder.Query().Where(workorder.WorkOrderNo(orderNo)).Only(ctx)
if err != nil {
return nil
}
if v, ok := wo.Context["lastDockCodes"].([]any); ok {
out := []string{}
for _, x := range v {
if s2, ok := x.(string); ok {
out = append(out, s2)
}
}
return out
}
return nil
}
// syncPlansByOrderStatus 工单状态联动日排产:
// - 工单 → IN_PROGRESS:该工单 PENDING 排产置 PROCESSING(开始执行)
// - 工单 → DONE:已足额完工(completedQty>=planQty)的排产置 DONE;其余未完成排产置 CANCELLED
// - 工单 → CANCELLED:该工单未完成(PENDING/PROCESSING)排产全部置 CANCELLED
func (s *Service) syncPlansByOrderStatus(ctx context.Context, orderNo, woStatus, operator string) {
if orderNo == "" {
return
}
plans, err := s.ctx.EntClient.DailyPlan.Query().
Where(dailyplan.OrderNo(orderNo), dailyplan.StatusIn("PENDING", "PROCESSING")).All(ctx)
if err != nil {
return
}
changed := 0
to := ""
for _, p := range plans {
switch woStatus {
case "IN_PROGRESS":
to = "PROCESSING"
case "DONE":
if p.CompletedQty >= p.PlanQty {
to = "DONE"
} else {
to = "CANCELLED"
}
case "CANCELLED":
to = "CANCELLED"
default:
return
}
if p.Status == to {
continue
}
if _, err := s.ctx.EntClient.DailyPlan.UpdateOneID(p.ID).SetStatus(to).Save(ctx); err == nil {
changed++
}
}
if changed > 0 {
s.ctx.EventLog.Write(ctx, "daily.plan.status_sync", orderNo, operator, "daily_plan", orderNo,
"工单状态联动排产", map[string]any{"woStatus": woStatus, "changed": changed})
}
}
// ListDailyPlans 按工单/日期查询日排产
func (s *Service) ListDailyPlans(ctx context.Context, orderNo, planDate string) ([]*ent.DailyPlan, error) {
q := s.ctx.EntClient.DailyPlan.Query()
if orderNo != "" {
q = q.Where(dailyplan.OrderNo(orderNo))
}
if planDate != "" {
q = q.Where(dailyplan.PlanDate(planDate))
}
return q.Order(ent.Desc(dailyplan.FieldPlanDate)).All(ctx)
}
// DeleteDailyPlan 删除一条日排产记录
func (s *Service) DeleteDailyPlan(ctx context.Context, id int) error {
return s.ctx.EntClient.DailyPlan.DeleteOneID(id).Exec(ctx)
}
// ---------- 自动排产(引导式) ----------
// DateSegment 日期区间 [start, end](含两端),yyyy-MM-dd
type DateSegment struct {
Start string `json:"start"`
End string `json:"end"`
}
// AutoScheduleReq 引导式自动排产请求
// Mode: AVG(平均分摊到区间各天) / FIXED(固定日产 dailyQty)
// Segments: 一个或多个日期区间(如 6/23-26、6/28-29),区间并集去重排序
type AutoScheduleReq struct {
OrderNo string `json:"orderNo"`
Mode string `json:"mode"`
DailyQty int `json:"dailyQty"` // FIXED 模式每日产量
Segments []DateSegment `json:"segments"`
}
// unscheduledQty 未开工数量 = 工单总数量 − 已完工 − 已进线未完工(跨天在制品不重排)
func (s *Service) unscheduledQty(ctx context.Context, wo *ent.WorkOrder) (int, error) {
inLine, err := s.ctx.EntClient.Workpiece.Query().
Where(workpiece.OrderNo(wo.WorkOrderNo), workpiece.StatusIn("ONLINE", "PROCESSING")).Count(ctx)
if err != nil {
return 0, err
}
remain := wo.Quantity - wo.FinishedNum - inLine
if remain < 0 {
remain = 0
}
return remain, nil
}
// expandDates 把多个日期区间展开为去重、升序的日期列表(yyyy-MM-dd)
func expandDates(segments []DateSegment) ([]string, error) {
seen := map[string]bool{}
out := []string{}
for _, seg := range segments {
start, e1 := time.ParseInLocation("2006-01-02", seg.Start, time.Local)
end, e2 := time.ParseInLocation("2006-01-02", seg.End, time.Local)
if e1 != nil || e2 != nil || !end.After(start) && !end.Equal(start) {
return nil, errors.New("日期区间格式错误(应为 yyyy-MM-dd,且结束≥开始):" + seg.Start + "~" + seg.End)
}
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
ds := d.Format("2006-01-02")
if !seen[ds] {
seen[ds] = true
out = append(out, ds)
}
}
}
sort.Strings(out)
return out, nil
}
// AutoSchedule 引导式自动排产:生成多条 daily_planstatus=PENDING,未启用),供人工确认后启用。
// 排产合计(含已有排产)≤ 未开工数量;已进线未完工工件跨天继续、不被重排冲掉。
func (s *Service) AutoSchedule(ctx context.Context, req AutoScheduleReq, operator string) error {
if req.OrderNo == "" {
return errors.New("工单号必填")
}
if req.Mode != "AVG" && req.Mode != "FIXED" {
return errors.New("排产模式仅支持 AVG(平均分摊) / FIXED(固定日产)")
}
dates, err := expandDates(req.Segments)
if err != nil {
return err
}
if len(dates) == 0 {
return errors.New("请至少选择一个日期区间")
}
wo, err := s.ctx.EntClient.WorkOrder.Query().Where(workorder.WorkOrderNo(req.OrderNo)).First(ctx)
if err != nil {
return errors.New("工单不存在")
}
if wo.Status == "DONE" || wo.Status == "CANCELLED" {
return errors.New("该工单已完成或已取消,不能排产")
}
if wo.Status == "PAUSED" {
return errors.New("该工单已暂停,恢复执行前不能排产")
}
remain, err := s.unscheduledQty(ctx, wo)
if err != nil {
return err
}
// 已有排产(非本次区间内的)已占用部分未开工数量,本次可排上限 = remain − 既有排产合计
exist, _ := s.ctx.EntClient.DailyPlan.Query().Where(dailyplan.OrderNo(req.OrderNo)).All(ctx)
existTotal := 0
for _, p := range exist {
existTotal += p.PlanQty
}
quota := remain - existTotal
if quota < 0 {
quota = 0
}
days := len(dates)
var planQty []int
switch req.Mode {
case "AVG":
base := quota / days
planQty = make([]int, days)
for i := range planQty {
planQty[i] = base
}
// 余数补到前面几天,保证合计=quota
for i := 0; i < quota-base*days; i++ {
planQty[i]++
}
case "FIXED":
if req.DailyQty <= 0 {
return errors.New("固定日产模式需填写每日产量(>0)")
}
planQty = make([]int, days)
left := quota
for i := range planQty {
if left >= req.DailyQty {
planQty[i] = req.DailyQty
left -= req.DailyQty
} else {
planQty[i] = left
left = 0
}
}
if quota > req.DailyQty*days {
return errors.New("固定日产×天数超过未开工数量,无法排产")
}
}
sum := 0
for _, q := range planQty {
sum += q
}
if sum == 0 {
return errors.New("未开工数量为 0,无需排产(已在产/已完工)")
}
// 生成/更新排产(本次区间内的日期覆盖式写入,status=PENDING 待人工启用;执行中/已完工的日计划受保护不被覆盖)
for i, ds := range dates {
q := planQty[i]
if q <= 0 {
continue
}
existOne, _ := s.ctx.EntClient.DailyPlan.Query().
Where(dailyplan.OrderNo(req.OrderNo), dailyplan.PlanDate(ds)).First(ctx)
if existOne != nil {
// 保护执行中/已完成的排产(方案 K / P2-3):不打回 PENDING、不覆盖数量,
// 避免自动排产把正在产线执行(PROCESSING)或已完工(DONE)的日计划冲掉;
// 这些计划的既有数量已在 existTotal 中计入、quota 已相应扣减,跳过不会超排。
if existOne.Status == "PROCESSING" || existOne.Status == "DONE" {
continue
}
_, err = s.ctx.EntClient.DailyPlan.UpdateOneID(existOne.ID).
SetPlanQty(q).SetStatus("PENDING").SetOperator(operator).Save(ctx)
} else {
_, err = s.ctx.EntClient.DailyPlan.Create().
SetOrderNo(req.OrderNo).SetPlanDate(ds).SetPlanQty(q).
SetStatus("PENDING").SetOperator(operator).Save(ctx)
}
if err != nil {
return err
}
}
s.ctx.EventLog.Write(ctx, "daily.plan.auto", req.OrderNo, operator, "daily_plan", req.OrderNo,
"自动排产(模式="+req.Mode+")", map[string]any{"days": days, "total": sum, "quota": quota})
s.notifyDashboard()
return nil
}