1. 移除工单、工件、日排产中的冗余工位组合/接驳台字段 2. 新增工位接驳台编码字段与唯一约束,关联WMS接驳台主数据 3. 重构日排产为工位产量分配模式,替代原接驳台列表 4. 新增备料单工位级字段与分批出库支持 5. 移除定时同步可生产数量,改为WMS实时拉取接口 6. 过滤操作日志,排除登录/退出相关日志 7. 调整仪表盘工序展示逻辑为今日派工路线 8. 新增接驳台维护菜单与基础数据
467 lines
16 KiB
Go
467 lines
16 KiB
Go
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_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 查今日工位派工(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_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("该工单已暂停,恢复执行前不能排产")
|
||
}
|
||
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
|
||
}
|