Files
bj_power/bj_power_mes/internal/logic/dailyplan.go
T
SunYF 37f10d3a13 refactor: 重构产线工位与备料流程,移除冗余字段
1. 移除工单、工件、日排产中的冗余工位组合/接驳台字段
2. 新增工位接驳台编码字段与唯一约束,关联WMS接驳台主数据
3. 重构日排产为工位产量分配模式,替代原接驳台列表
4. 新增备料单工位级字段与分批出库支持
5. 移除定时同步可生产数量,改为WMS实时拉取接口
6. 过滤操作日志,排除登录/退出相关日志
7. 调整仪表盘工序展示逻辑为今日派工路线
8. 新增接驳台维护菜单与基础数据
2026-09-19 07:50:21 +08:00

467 lines
16 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"
"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 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_planstatus=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 查今日工位派工(station_process)路线覆盖的接驳台编码(DOCKxx),供排产/备料默认推荐。
// 路线由工位派工定义,接驳台由各派工工位引用(station.dockCode),因此可直接由路线派生。
func (s *Service) SuggestedDockCodes(ctx context.Context, orderNo string) []string {
route, err := RouteStations(ctx, s.ctx.EntClient, TodayStr(), "")
if err != nil || len(route) == 0 {
return nil
}
sts, err := s.ctx.EntClient.Station.Query().Where(station.StationNoIn(route...)).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)
}
// 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
}
// 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_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 "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
}