package logic import ( "context" "errors" "sort" "time" "bj_power_mes/ent" "bj_power_mes/ent/dailyplan" "bj_power_mes/ent/station" "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"` StationPlanQty map[string]int `json:"stationPlanQty"` // 工位号(字符串键)→计划产量分配;缺省按路线均分 // 批量排产(新建时与 PlanDate 二选一):日期范围 [start,end] + 分摊模式 PlanRange []string `json:"planRange"` // [start,end] yyyy-MM-dd,批量生成多条日排产 Mode string `json:"mode"` // AVG(平均分摊)/FIXED(固定日产);单条保存时忽略 } // SaveDailyPlan 创建/更新日排产(同一 工单+日期 唯一) // 排产状态枚举统一为:PENDING(待执行)/PROCESSING(执行中)/DONE(已完成)/CANCELLED(已取消) func (s *Service) SaveDailyPlan(ctx context.Context, req DailyPlanReq, operator string) error { if req.OrderNo == "" { 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("该工单已暂停,恢复执行前不能新增排产") } // 排产继承工单工艺组合(唯一路线源头):组合未配置或工位改绑工艺后禁止排产 if err := s.ValidateWOCombinationForSchedule(ctx, wo); err != nil { return err } // 批量排产(新建 + 日期范围):一键生成多天日排产,参考工单「自动排产」 if req.Id == 0 && len(req.PlanRange) == 2 && req.PlanRange[0] != "" && req.PlanRange[1] != "" { return s.saveDailyPlanBatch(ctx, req, operator, wo) } // 单条排产(编辑既有行 / 新建单日) if req.PlanDate == "" { return errors.New("排产日期必填") } spq := req.StationPlanQty if spq == nil { spq = map[string]int{} } 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 { // 编辑时若不传 status,保留该行原有状态(避免编辑把已启用(CONFIRMED)误回退为待启用) if req.Status == "" { status = exist.Status } _, err = s.ctx.EntClient.DailyPlan.UpdateOneID(exist.ID). SetOrderNo(req.OrderNo).SetPlanDate(req.PlanDate).SetPlanQty(req.PlanQty).SetStatus(status).SetStationPlanQty(spq).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).SetStationPlanQty(spq).SetOperator(operator).Save(ctx) if err != nil { return err } } s.ctx.EventLog.Write(ctx, "daily.plan.save", req.OrderNo, operator, "daily_plan", req.OrderNo, "保存日排产", map[string]any{"planDate": req.PlanDate, "planQty": req.PlanQty}) s.notifyDashboard() return nil } // saveDailyPlanBatch 批量排产(新建 + 日期范围):把 total 件按模式分摊到范围内每一天, // 生成多条 daily_plan(status=PENDING 待人工启用)。合计受「未开工数量」约束,执行中/已完工日计划受保护不被覆盖。 func (s *Service) saveDailyPlanBatch(ctx context.Context, req DailyPlanReq, operator string, wo *ent.WorkOrder) error { dates, err := expandDates([]DateSegment{{Start: req.PlanRange[0], End: req.PlanRange[1]}}) if err != nil { return err } if len(dates) == 0 { return errors.New("请选择排产日期范围") } remain, err := s.unscheduledQty(ctx, wo) if err != nil { return err } 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 "FIXED": if req.PlanQty <= 0 { return errors.New("固定日产模式需填写每日产量(>0)") } if req.PlanQty*days > quota { return errors.New("固定日产×天数超过未开工数量(工单总数量−已完工−在产未完工),无法排产") } planQty = distributeQty(req.PlanQty*days, days, "FIXED", req.PlanQty) case "AVG", "": if req.PlanQty <= 0 { return errors.New("排产总数量需大于 0") } if req.PlanQty > quota { return errors.New("排产总数量超过未开工数量(工单总数量−已完工−在产未完工),无法排产") } planQty = distributeQty(req.PlanQty, days, "AVG", 0) default: return errors.New("排产模式仅支持 AVG(平均分摊) / FIXED(固定日产)") } sum := 0 for _, q := range planQty { sum += q } if sum == 0 { return errors.New("未开工数量为 0,无需排产(已在产/已完工)") } 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 { // 保护执行中/已完工的排产不被覆盖(其数量已计入 existTotal,跳过不会超排) if existOne.Status == "PROCESSING" || existOne.Status == "DONE" { continue } _, err = s.ctx.EntClient.DailyPlan.UpdateOneID(existOne.ID). SetPlanQty(q).SetStatus("PENDING").SetStationPlanQty(map[string]int{}).SetOperator(operator).Save(ctx) } else { _, err = s.ctx.EntClient.DailyPlan.Create(). SetOrderNo(req.OrderNo).SetPlanDate(ds).SetPlanQty(q). SetStatus("PENDING").SetStationPlanQty(map[string]int{}).SetOperator(operator).Save(ctx) } if err != nil { return err } } s.ctx.EventLog.Write(ctx, "daily.plan.batch", req.OrderNo, operator, "daily_plan", req.OrderNo, "日排产批量生成", map[string]any{"days": days, "total": sum, "mode": req.Mode, "quota": quota}) s.notifyDashboard() return nil } // SuggestedDockCodes 查工单工艺组合覆盖工位的接驳台编码(DOCKxx),供排产/备料默认推荐。 // 路线唯一源头 = 工单 flow_combination(按工位号升序),接驳台取 station.dock_code。 func (s *Service) SuggestedDockCodes(ctx context.Context, orderNo string) []string { wo, err := s.ctx.EntClient.WorkOrder.Query().Where(workorder.WorkOrderNo(orderNo)).First(ctx) if err != nil { return nil } items, _ := RouteStationsFromWO(ctx, s.ctx.EntClient, wo) if len(items) == 0 { return nil } sts, err := s.ctx.EntClient.Station.Query().Where(station.StationNoIn(RouteStationsOfWO(items)...)).All(ctx) if err != nil { return nil } seen := map[string]bool{} out := []string{} for _, st := range sts { if st.DockCode != "" && !seen[st.DockCode] { seen[st.DockCode] = true out = append(out, st.DockCode) } } return out } // 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) } // DailyPlanRow 日排产行(附工单工艺组合,供前端派生路线/接驳台展示) type DailyPlanRow struct { *ent.DailyPlan FlowCombination []map[string]int `json:"flowCombination"` } // ListDailyPlansWithCombination 日排产列表 + 每行附带所属工单的工艺组合 func (s *Service) ListDailyPlansWithCombination(ctx context.Context, orderNo, planDate string) ([]*DailyPlanRow, error) { plans, err := s.ListDailyPlans(ctx, orderNo, planDate) if err != nil { return nil, err } orderNos := make([]string, 0, len(plans)) seen := map[string]bool{} for _, p := range plans { if !seen[p.OrderNo] { seen[p.OrderNo] = true orderNos = append(orderNos, p.OrderNo) } } combByOrder := map[string][]map[string]int{} if len(orderNos) > 0 { wos, err := s.ctx.EntClient.WorkOrder.Query().Where(workorder.WorkOrderNoIn(orderNos...)).All(ctx) if err == nil { for _, wo := range wos { combByOrder[wo.WorkOrderNo] = wo.FlowCombination } } } rows := make([]*DailyPlanRow, 0, len(plans)) for _, p := range plans { rows = append(rows, &DailyPlanRow{DailyPlan: p, FlowCombination: combByOrder[p.OrderNo]}) } return rows, nil } // DeleteDailyPlan 删除一条日排产记录 func (s *Service) DeleteDailyPlan(ctx context.Context, id int) error { return s.ctx.EntClient.DailyPlan.DeleteOneID(id).Exec(ctx) } // SetDailyPlanStatus 日排产启用/停用(人工专用入口,POST /daily-plans/:id/status): // 仅允许 PENDING(待执行)↔CONFIRMED(已启用) 之间切换;执行中/已完成/已取消由系统联动维护,无人工入口。 // 启用(→CONFIRMED)前校验所属工单(按排产 orderNo 查)状态必须为 RELEASED 或 IN_PROGRESS。 // 只 UPDATE status 字段,绝不触碰计划数量/分产量等其它列。 func (s *Service) SetDailyPlanStatus(ctx context.Context, id int, target, operator string) error { if id <= 0 { return errors.New("缺少排产 id") } if target != "PENDING" && target != "CONFIRMED" { return errors.New("仅支持 启用/停用 操作") } p, err := s.ctx.EntClient.DailyPlan.Get(ctx, id) if err != nil { return errors.New("日排产不存在") } if p.Status != "PENDING" && p.Status != "CONFIRMED" { return errors.New("仅支持 启用/停用 操作") } if p.Status == target { return nil // 幂等:状态相同直接成功 } if target == "CONFIRMED" { wo, err := s.ctx.EntClient.WorkOrder.Query().Where(workorder.WorkOrderNo(p.OrderNo)).First(ctx) if err != nil { return errors.New("工单不存在") } if wo.Status != "RELEASED" && wo.Status != "IN_PROGRESS" { return errors.New("工单未下发,不能启用排产") } } if err := s.ctx.EntClient.DailyPlan.UpdateOneID(id).SetStatus(target).Exec(ctx); err != nil { return err } s.ctx.EventLog.Write(ctx, "daily.plan.status", p.OrderNo, operator, "daily_plan", p.OrderNo, "排产启用/停用", map[string]any{"id": id, "from": p.Status, "to": target}) s.notifyDashboard() return nil } // ---------- 自动排产(引导式) ---------- // 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 } // distributeQty 把 total 件分摊到 days 天: // AVG 平均分摊(余数补到前面几天,合计=total);FIXED 每日 dailyQty(末日取余数,合计=min(total, dailyQty*days))。 // 返回每日数量切片(长度=days)。调用方需自行校验 total 不超过可排上限(quota)。 func distributeQty(total, days int, mode string, dailyQty int) []int { out := make([]int, days) if days <= 0 || total <= 0 { return out } switch mode { case "FIXED": left := total for i := 0; i < days; i++ { if left >= dailyQty { out[i] = dailyQty left -= dailyQty } else { out[i] = left left = 0 } } default: // AVG base := total / days for i := 0; i < days; i++ { out[i] = base } for i := 0; i < total-base*days; i++ { out[i]++ } } return out } // AutoSchedule 引导式自动排产:生成多条 daily_plan(status=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("该工单已暂停,恢复执行前不能排产") } // 自动排产继承工单工艺组合(唯一路线源头,不可改):先校验组合与工位绑定一致 if err := s.ValidateWOCombinationForSchedule(ctx, wo); err != nil { return err } 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 "FIXED": if req.DailyQty <= 0 { return errors.New("固定日产模式需填写每日产量(>0)") } if quota > req.DailyQty*days { return errors.New("固定日产×天数超过未开工数量,无法排产") } planQty = distributeQty(quota, days, "FIXED", req.DailyQty) case "AVG", "": planQty = distributeQty(quota, days, "AVG", 0) } 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 }