feat: 新增多类功能并优化现有流程
1. 工序工位支持0号上线位,更新解析逻辑与注释 2. 物料清单模块重命名为产品物料清单并优化文案 3. WMS与工位端新增用户/角色详情抽屉组件 4. 工单模块新增自动生成工单号、工位分组选择器 5. 日排产支持批量生成与详情查看 6. 工单列表重构操作菜单与表单优化
This commit is contained in:
@@ -229,7 +229,7 @@ func hasTorqueStep(ctx context.Context, client *ent.Client, sn string, wp *ent.W
|
||||
return n > 0
|
||||
}
|
||||
|
||||
// parseCardSeq 解析工单/工件工序组合 "1,3,5" → 工位号列表
|
||||
// parseCardSeq 解析工单/工件工序组合 "1,3,5" → 工位号列表(含 0 号上线位)
|
||||
func parseCardSeq(s string) []int {
|
||||
out := []int{}
|
||||
for _, p := range strings.Split(s, ",") {
|
||||
@@ -238,7 +238,7 @@ func parseCardSeq(s string) []int {
|
||||
continue
|
||||
}
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(p, "%d", &n); err == nil && n >= 1 && n <= logic.ProcessSeqMaxStation {
|
||||
if _, err := fmt.Sscanf(p, "%d", &n); err == nil && n >= 0 && n <= logic.ProcessSeqMaxStation {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,13 +30,16 @@ type DailyPlanReq struct {
|
||||
CompletedQty int `json:"completedQty"`
|
||||
Status string `json:"status"`
|
||||
DockCodes []string `json:"dockCodes"`
|
||||
// 批量排产(新建时与 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 == "" || req.PlanDate == "" {
|
||||
return errors.New("工单号和排产日期必填")
|
||||
if req.OrderNo == "" {
|
||||
return errors.New("工单号必填")
|
||||
}
|
||||
// 校验:该工单所有日排产计划数量累计不得超过工单总数量(保存时合计,改小可、改大不超总量)
|
||||
wo, err := s.ctx.EntClient.WorkOrder.Query().Where(workorder.WorkOrderNo(req.OrderNo)).First(ctx)
|
||||
@@ -49,6 +52,14 @@ func (s *Service) SaveDailyPlan(ctx context.Context, req DailyPlanReq, operator
|
||||
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("排产日期必填")
|
||||
}
|
||||
cur, err := s.ctx.EntClient.DailyPlan.Query().
|
||||
Where(dailyplan.OrderNo(req.OrderNo)).All(ctx)
|
||||
if err != nil {
|
||||
@@ -85,6 +96,10 @@ func (s *Service) SaveDailyPlan(ctx context.Context, req DailyPlanReq, operator
|
||||
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).
|
||||
SetDockCodes(dockCodes(req.DockCodes)).Save(ctx)
|
||||
@@ -108,6 +123,90 @@ func (s *Service) SaveDailyPlan(ctx context.Context, req DailyPlanReq, operator
|
||||
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").SetOperator(operator).Save(ctx)
|
||||
} else {
|
||||
_, err = s.ctx.EntClient.DailyPlan.Create().
|
||||
SetOrderNo(req.OrderNo).SetPlanDate(ds).SetPlanQty(q).
|
||||
SetStatus("PENDING").SetOperator(operator).SetDockCodes(dockCodes(req.DockCodes)).Save(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(dockCodes(req.DockCodes)) > 0 {
|
||||
s.rememberOrderDocks(ctx, req.OrderNo, dockCodes(req.DockCodes))
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// dockCodes 规范化接驳台列表:仅保留 DOCK01..DOCK20 / DOCK21,去重、保持顺序
|
||||
func dockCodes(codes []string) []string {
|
||||
seen := map[string]bool{}
|
||||
@@ -281,6 +380,38 @@ func expandDates(segments []DateSegment) ([]string, error) {
|
||||
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 {
|
||||
@@ -325,34 +456,16 @@ func (s *Service) AutoSchedule(ctx context.Context, req AutoScheduleReq, operato
|
||||
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("固定日产×天数超过未开工数量,无法排产")
|
||||
}
|
||||
planQty = distributeQty(quota, days, "FIXED", req.DailyQty)
|
||||
case "AVG", "":
|
||||
planQty = distributeQty(quota, days, "AVG", 0)
|
||||
}
|
||||
sum := 0
|
||||
for _, q := range planQty {
|
||||
|
||||
@@ -15,7 +15,7 @@ const ProcessSeqMaxStation = 999
|
||||
|
||||
// ParseProcessSeq 解析工位组合字符串为升序去重的工位码列表。
|
||||
// 唯一格式:逗号分隔 "1,3,5" / "10,11,12"。传送带不回走,故组合天然按工位号升序执行。
|
||||
// 工位号取值 1..ProcessSeqMaxStation:工位主数据可扩展到 14、15…(虚拟工位仅记录,不连 PLC)。
|
||||
// 工位号取值 0..ProcessSeqMaxStation:0/13 为虚拟上线/下线位(不连 PLC、仅记录),工位主数据可扩展到 14、15…。
|
||||
func ParseProcessSeq(s string) []int {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
@@ -29,7 +29,7 @@ func ParseProcessSeq(s string) []int {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.Atoi(p)
|
||||
if err != nil || n < 1 || n > ProcessSeqMaxStation || seen[n] {
|
||||
if err != nil || n < 0 || n > ProcessSeqMaxStation || seen[n] {
|
||||
continue
|
||||
}
|
||||
seen[n] = true
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bj_power_mes/ent"
|
||||
@@ -34,15 +36,22 @@ type WorkOrderReq struct {
|
||||
|
||||
// CreateWorkOrder 创建工单(状态恒 CREATED,后续用状态流转按钮推进)
|
||||
func (s *Service) CreateWorkOrder(ctx context.Context, req WorkOrderReq, operator string) error {
|
||||
if req.WorkOrderNo == "" {
|
||||
return errors.New("工单号不能为空")
|
||||
}
|
||||
if req.ProductTypeId == 0 {
|
||||
return errors.New("请选择产品型号")
|
||||
}
|
||||
if req.Quantity <= 0 {
|
||||
req.Quantity = 1
|
||||
}
|
||||
// 工单号:未传入则按规则生成 WO-日期-001;若与已有工单号重复,自动递增序号(001→002…)。
|
||||
if req.WorkOrderNo == "" {
|
||||
req.WorkOrderNo = defaultWorkOrderNo()
|
||||
}
|
||||
finalNo, err := s.resolveUniqueWorkOrderNo(ctx, req.WorkOrderNo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.WorkOrderNo = finalNo
|
||||
|
||||
status := req.Status
|
||||
if status == "" {
|
||||
status = "CREATED"
|
||||
@@ -50,11 +59,8 @@ func (s *Service) CreateWorkOrder(ctx context.Context, req WorkOrderReq, operato
|
||||
if status != "CREATED" {
|
||||
status = "CREATED" // 创建恒为已创建;不允许直接创建到后续状态
|
||||
}
|
||||
// 工位组合=本工单要经过的工位,按工位号升序(传送带不回走)。
|
||||
// 完整的产线定义 = 关联工位(station.flow_id)+ 工位号 1→12 顺序,不再有独立的"工艺路线"对象。
|
||||
if req.ProcessSeq == "" {
|
||||
req.ProcessSeq = FullProcessSeq
|
||||
} else if err := ValidateProcessSeq(req.ProcessSeq); err != nil {
|
||||
// 工位组合为本工单要经过的工位,按工位号升序(传送带不回走),必填项,禁止留空/自动默认全选。
|
||||
if err := ValidateProcessSeq(req.ProcessSeq); err != nil {
|
||||
return err
|
||||
}
|
||||
req.ProcessSeq = NormalizeProcessSeq(req.ProcessSeq)
|
||||
@@ -95,6 +101,40 @@ func (s *Service) CreateWorkOrder(ctx context.Context, req WorkOrderReq, operato
|
||||
return nil
|
||||
}
|
||||
|
||||
// defaultWorkOrderNo 工单号默认规则:WO-YYYYMMDD-001(如 WO-20260918-001)。
|
||||
func defaultWorkOrderNo() string {
|
||||
return fmt.Sprintf("WO-%s-001", time.Now().Format("20060102"))
|
||||
}
|
||||
|
||||
// resolveUniqueWorkOrderNo 工单号唯一性保障:若 want 已存在,则按尾部序号自动递增
|
||||
// (WO-20260918-001 → 002 → 003…),最多尝试 999 次,仍冲突则报错由前端手动指定。
|
||||
// 尾部序号解析失败(无数字后缀)时,退化为在末尾追加 -001/-002…。
|
||||
func (s *Service) resolveUniqueWorkOrderNo(ctx context.Context, want string) (string, error) {
|
||||
parts := strings.Split(want, "-")
|
||||
prefix := want
|
||||
seq := 0
|
||||
if len(parts) >= 2 {
|
||||
if n, err := strconv.Atoi(parts[len(parts)-1]); err == nil {
|
||||
seq = n
|
||||
prefix = strings.Join(parts[:len(parts)-1], "-")
|
||||
}
|
||||
}
|
||||
for i := 0; i < 999; i++ {
|
||||
cand := want
|
||||
if i > 0 {
|
||||
cand = fmt.Sprintf("%s-%03d", prefix, seq+i)
|
||||
}
|
||||
cnt, err := s.ctx.EntClient.WorkOrder.Query().Where(workorder.WorkOrderNo(cand)).Count(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if cnt == 0 {
|
||||
return cand, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("工单号自动编号失败(序号已用尽),请手动指定工单号")
|
||||
}
|
||||
|
||||
// UpdateWorkOrder 编辑工单。仅「已创建」状态允许编辑(已下发/执行中/已暂停只能流转,已完成/已取消只读)。
|
||||
func (s *Service) UpdateWorkOrder(ctx context.Context, req WorkOrderReq, operator string) error {
|
||||
if req.Id == 0 {
|
||||
|
||||
Reference in New Issue
Block a user