Files
bj_power/bj_power_mes/internal/logic/dailyplan.go
T

396 lines
13 KiB
Go
Raw Normal View History

2026-08-28 15:06:01 +08:00
package logic
import (
"context"
"errors"
"sort"
"strconv"
"strings"
2026-08-28 15:06:01 +08:00
"time"
"bj_power_mes/ent"
"bj_power_mes/ent/dailyplan"
2026-08-29 15:30:25 +08:00
"bj_power_mes/ent/workorder"
"bj_power_mes/ent/workpiece"
2026-08-28 15:06:01 +08:00
)
// 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"`
2026-08-28 15:06:01 +08:00
}
// SaveDailyPlan 创建/更新日排产(同一 工单+日期 唯一)
// 排产状态枚举统一为:PENDING(待执行)/PROCESSING(执行中)/DONE(已完成)/CANCELLED(已取消)
2026-08-28 15:06:01 +08:00
func (s *Service) SaveDailyPlan(ctx context.Context, req DailyPlanReq, operator string) error {
if req.OrderNo == "" || req.PlanDate == "" {
return errors.New("工单号和排产日期必填")
}
2026-08-29 15:30:25 +08:00
// 校验:该工单所有日排产计划数量累计不得超过工单总数量(保存时合计,改小可、改大不超总量)
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("该工单已暂停,恢复执行前不能新增排产")
}
2026-08-29 15:30:25 +08:00
cur, err := s.ctx.EntClient.DailyPlan.Query().
Where(dailyplan.OrderNo(req.OrderNo)).All(ctx)
if err != nil {
return err
}
// 计算合计:本次 req.PlanQty 恒计入;若「工单+日期」已有记录,则原值作废只算新值(避免旧值+新值重复累计)
total := req.PlanQty
2026-08-29 15:30:25 +08:00
for _, p := range cur {
if p.PlanDate == req.PlanDate {
if p.CompletedQty > 0 {
return errors.New("该排产日已有完工数量,不能修改计划数量")
}
2026-08-29 15:30:25 +08:00
continue
}
total += p.PlanQty
}
// 跨天在制品:只排「未开工数量 = 工单总数量 − 已完工 − 已进线未完工」,在产工件跨天继续、不被重排冲掉
remain, err := s.unscheduledQty(ctx, wo)
if err != nil {
return err
}
if total > remain {
return errors.New("排产合计数量超过未开工数量(工单总数量−已完工−在产未完工),无法保存")
2026-08-29 15:30:25 +08:00
}
2026-08-28 15:06:01 +08:00
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("非法的排产状态")
2026-08-28 15:06:01 +08:00
}
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)
2026-08-28 15:06:01 +08:00
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)
2026-08-28 15:06:01 +08:00
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()
2026-08-28 15:06:01 +08:00
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})
}
}
2026-08-28 15:06:01 +08:00
// 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)
2026-08-29 15:30:25 +08:00
}
// 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
}