Files
bj_power/bj_power_mes/docs/superpowers/plans/2026-05-12-washer-batch-load-unload-plan.md
T

18 KiB
Raw Blame History

高压清洗机批量上料/下料/换料 实施计划

For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 高压清洗机支持灵活批量上料(有2个上2个,有1个上1个)、逐槽位判断换料/卸载、RobotAction 数组串行执行。

Architecture:trySchedule dispatch 阶段查询 WASHER_HP 槽位状态,编排 []RobotAction 数组一次性 dispatch。Worker 串行执行数组中每个 action。StartupWasher 作为独立 RobotAction,由编排决定何时追加。

Tech Stack: Go 1.23, ent ORM, PostgreSQL


Chunk 1: HardwareWorker 接口 + RobotWorker 适配

Task 1: HardwareWorker 接口改为接收 []RobotAction

Files:

  • Modify: internal/action/action.go:33-35

  • Test: internal/eventloop/loop_test.go

  • Step 1: 修改 HardwareWorker 接口

action.goHardwareWorker 接口从单个 action 改为 action 数组:

// HardwareWorker 硬件执行器接口
type HardwareWorker interface {
	Execute(ctx context.Context, actions []RobotAction) error
}
  • Step 2: 适配 RobotWorker.Execute

修改 internal/processor/robot_worker.goExecute 方法,串行执行 action 数组:

func (w *RobotWorker) Execute(ctx context.Context, actions []action.RobotAction) error {
	for _, act := range actions {
		slog.Info("robot worker: executing", "kind", act.Kind, "jobId", act.JobID)
		if err := w.executeSingle(ctx, act); err != nil {
			return fmt.Errorf("action %s failed: %w", act.Kind, err)
		}
	}
	return nil
}

// executeSingle 执行单个 RobotAction
func (w *RobotWorker) executeSingle(ctx context.Context, act action.RobotAction) error {
	switch act.Kind {
	case "scan_full":
		return w.executeScanFull(ctx, act)
	case "mark_full":
		return w.executeMarkFull(ctx, act)
	case "load_to_machine":
		return w.executeLoadToMachine(ctx, act)
	case "unload_to_buffer":
		return w.executeUnloadToBuffer(ctx, act)
	case "exchange_full":
		return w.executeExchangeFull(ctx, act)
	case "unload_to_dock":
		return w.executeUnloadToDock(ctx, act)
	case "batch_unload":
		return w.executeBatchUnload(ctx, act)
	case "startup_washer":
		return w.executeStartupWasher(ctx, act)
	default:
		return fmt.Errorf("unknown action kind: %s", act.Kind)
	}
}
  • Step 3: 新增 executeStartupWasher 方法

internal/processor/robot_worker.go 中新增:

// executeStartupWasher 启动高压清洗机
func (w *RobotWorker) executeStartupWasher(ctx context.Context, act action.RobotAction) error {
	if w.robotCtrl == nil {
		return fmt.Errorf("startup_washer: robot controller not available")
	}
	if err := w.robotCtrl.StartupWasher(ctx); err != nil {
		return fmt.Errorf("startup_washer: %w", err)
	}
	return nil
}
  • Step 4: 移除 executeLoadToMachine 中 machineID==5 的 StartupWasher 调用

删除 internal/processor/robot_worker.go:184-189 的代码块:

// 删除这段:
if machineID == 5 {
    if err := w.robotCtrl.StartupWasher(ctx); err != nil {
        return fmt.Errorf("load: startup washer: %w", err)
    }
}
  • Step 5: 适配 eventloop/worker.go 类型别名

internal/eventloop/worker.go 中的类型别名无需修改(RobotActionHardwareWorker 仍是 action 包的类型别名,接口变更自动传播)。

  • Step 6: 适配 dispatchWorker 调用

修改 internal/eventloop/loop.godispatchWorker 方法,将 l.worker.Execute(ctx, act) 改为 l.worker.Execute(ctx, []action.RobotAction{act})。这是临时适配,后续 Task 会改为传递 action 数组。

  • Step 7: 适配 replenishWorker 和其他调用点

搜索所有 l.worker.Execute 调用点,统一改为传递 []RobotAction 切片。当前只有 dispatchWorker 一处调用。

  • Step 8: 编译验证

Run: rtk go build ./internal/action/ ./internal/processor/ ./internal/eventloop/

  • Step 9: 运行现有测试

Run: rtk go test ./internal/eventloop/... ./internal/processor/... -count=1

  • Step 10: Commit
rtk git add internal/action/action.go internal/processor/robot_worker.go internal/eventloop/loop.go
rtk git commit -m "feat(worker): HardwareWorker 接收 []RobotAction,新增 startup_washer action"

Chunk 2: WASHER_HP 上料编排

Task 2: trySchedule 中 WASHER_HP Load 批量编排

Files:

  • Modify: internal/eventloop/loop.go:347-381 (trySchedule dispatch 阶段)

  • Test: internal/eventloop/loop_test.go

  • Step 1: 新增 findSecondWasherLoadCandidate 辅助方法

internal/eventloop/loop.go 中新增方法,查找是否有第二个待清洗工件可以同时上料:

// findSecondWasherLoadCandidate 查找第二个等待 WASHER_HP 上料的候选任务
func (l *ProductionEventLoop) findSecondWasherLoadCandidate(candidates []scheduler.CandidateTask, skipJobID int) *scheduler.CandidateTask {
	for i := range candidates {
		ct := &candidates[i]
		if ct.JobID == skipJobID {
			continue
		}
		if ct.Action == scheduler.ActionLoadWasher {
			snap := l.jobRuntimes[ct.JobID]
			if snap == nil {
				continue
			}
			// 分配目标设备
			if ct.TargetID == 0 {
				ct.TargetID = l.findIdleMachine(snap.ResourceType, snap.ProductTypeID)
			}
			if ct.TargetID > 0 {
				return ct
			}
		}
	}
	return nil
}
  • Step 2: 新增 buildWasherLoadActions 方法

构建 WASHER_HP 上料的 []RobotAction 数组:

// buildWasherLoadActions 构建 WASHER_HP 上料 action 数组
// 1个候选 → Load + StartupWasher
// 2个候选 → Load1 + Load2 + StartupWasher
func (l *ProductionEventLoop) buildWasherLoadActions(ctx context.Context, ct scheduler.CandidateTask, snap *RuntimeSnapshot, secondCT *scheduler.CandidateTask) []action.RobotAction {
	var actions []action.RobotAction

	// 第一个 Load
	actions = append(actions, l.candidateToRobotAction(ctx, ct, snap))

	// 第二个 Load(如果有)
	if secondCT != nil {
		secondSnap := l.jobRuntimes[secondCT.JobID]
		if secondSnap != nil {
			actions = append(actions, l.candidateToRobotAction(ctx, *secondCT, secondSnap))
		}
	}

	// StartupWasher(有 Load 就启动)
	actions = append(actions, action.RobotAction{
		Kind:          "startup_washer",
		JobID:         ct.JobID,
		WorkpieceType: snap.ProductTypeID,
	})

	return actions
}
  • Step 3: 修改 trySchedule dispatch 阶段

trySchedule 的候选任务循环中,当遇到 ActionLoadWasher 时,走批量编排路径:

// 在 ct.Action.IsLoad() 分支内,WASHER_HP 特殊处理
if ct.Action == scheduler.ActionLoadWasher {
    // 查找是否有第二个候选
    secondCT := l.findSecondWasherLoadCandidate(candidates, ct.JobID)
    actions := l.buildWasherLoadActions(ctx, ct, snap, secondCT)
    l.workerBusy.Store(true)
    go l.dispatchWorkerActions(ctx, ct.JobID, actions)
    // 标记第二个候选已消费
    if secondCT != nil {
        consumed[secondCT.JobID] = true
    }
    continue
}
  • Step 4: 新增 dispatchWorkerActions 方法

替代原 dispatchWorker,接收 []RobotAction 数组:

// dispatchWorkerActions 异步执行 Worker 动作数组,完成后回投 WorkerResult
func (l *ProductionEventLoop) dispatchWorkerActions(ctx context.Context, primaryJobID int, actions []action.RobotAction) {
	taskID := fmt.Sprintf("batch-%d-%d", primaryJobID, time.Now().UnixNano())

	kinds := make([]string, len(actions))
	for i, a := range actions {
		kinds[i] = a.Kind
	}
	slog.Info("event loop: dispatch actions", "taskID", taskID, "actions", kinds)

	err := l.worker.Execute(ctx, actions)

	msgType := ResRobotActionSucceeded
	if err != nil {
		slog.Error("event loop: worker actions failed", "jobId", primaryJobID, "error", err)
		msgType = ResRobotActionFailed
	}

	payload := map[string]any{
		"jobId":    primaryJobID,
		"actions":  kinds,
		"actionCount": len(actions),
	}

	// 收集每个 action 的 JobID 和目标设备信息
	var jobIDs []int
	var targetIDs []int
	for _, a := range actions {
		jobIDs = append(jobIDs, a.JobID)
		targetIDs = append(targetIDs, a.DstMachine)
	}
	payload["jobIds"] = jobIDs
	payload["targetIDs"] = targetIDs

	l.Send(EventLoopMessage{
		ID:            taskID,
		Type:          msgType,
		CorrelationID: taskID,
		Payload:       payload,
		CreatedAt:     time.Now(),
	})
}
  • Step 5: 编译验证

Run: rtk go build ./internal/eventloop/

  • Step 6: Commit
rtk git add internal/eventloop/loop.go
rtk git commit -m "feat(eventloop): WASHER_HP 上料批量编排,Load+StartupWasher 或 Load1+Load2+StartupWasher"

Chunk 3: WASHER_HP 卸载/换料编排

Task 3: trySchedule 中 WASHER_HP Unload/Exchange 批量编排

Files:

  • Modify: internal/eventloop/loop.go (trySchedule dispatch 阶段 + candidateToRobotAction)

  • Test: internal/eventloop/loop_test.go

  • Step 1: 新增 buildWasherUnloadActions 方法

构建 WASHER_HP 卸载/换料的 []RobotAction 数组。当 dispatch 一个 Unload/Exchange 时,检查 batchMachines 决定是否追加第二个 action

// buildWasherUnloadActions 构建 WASHER_HP 卸载/换料 action 数组
// 检查 batch 设备另一个槽位状态,按需追加 action
func (l *ProductionEventLoop) buildWasherUnloadActions(ctx context.Context, ct scheduler.CandidateTask, snap *RuntimeSnapshot) []action.RobotAction {
	var actions []action.RobotAction

	// 第一个 actionUnload 或 Exchange
	actions = append(actions, l.candidateToRobotAction(ctx, ct, snap))

	machineID, _ := parsePositionRef(snap.PositionRefID)
	if machineID <= 0 || !l.batchMachines[machineID] {
		return actions
	}

	// 查询同设备另一个槽位状态
	doneSlots := l.db.FindAllDoneJobsOnMachine(ctx, machineID)
	emptySlots := 0
	if l.registry != nil {
		if st, ok := l.registry.Get(strconv.Itoa(machineID)); ok {
			if counter, ok := st.(interface{ EmptySlotCount() int }); ok {
				emptySlots = counter.EmptySlotCount()
			}
		}
	}

	// 另一个槽位有 Done 工件
	if len(doneSlots) > 1 {
		// 找到第二个 Done 槽位(不是当前 Job 的)
		for _, ds := range doneSlots {
			if ds.JobID == ct.JobID {
				continue
			}
			dsnap := l.jobRuntimes[ds.JobID]
			if dsnap == nil {
				continue
			}
			// 检查是否有配对
			loadJobID := l.findExchangePair(dsnap)
			if loadJobID > 0 {
				// 追加 Exchange
				loadSnap := l.jobRuntimes[loadJobID]
				loadTempSlot := 0
				if loadSnap != nil {
					loadTempSlot = loadSnap.TempSlotNo
				}
				actions = append(actions, action.RobotAction{
					Kind:          "exchange_full",
					JobID:         ds.JobID,
					WorkpieceType: dsnap.ProductTypeID,
					SrcSlot:       loadTempSlot,
					DstSlot:       dsnap.TempSlotNo,
					DstMachine:    machineID,
					Extra:         map[string]any{"loadJobID": loadJobID},
				})
			} else {
				// 追加 Unload
				actions = append(actions, action.RobotAction{
					Kind:          "unload_to_buffer",
					JobID:         ds.JobID,
					WorkpieceType: dsnap.ProductTypeID,
					SrcMachine:    machineID,
					DstSlot:       dsnap.TempSlotNo,
				})
			}
			break // 只处理一个额外槽位
		}
	} else if emptySlots > 0 {
		// 另一个槽位空,检查是否有待上料工件
		loadJobID := l.findExchangePair(snap)
		if loadJobID > 0 {
			loadSnap := l.jobRuntimes[loadJobID]
			if loadSnap != nil {
				actions = append(actions, action.RobotAction{
					Kind:          "load_to_machine",
					JobID:         loadJobID,
					WorkpieceType: loadSnap.ProductTypeID,
					SrcSlot:       loadSnap.TempSlotNo,
					DstMachine:    machineID,
				})
			}
		}
	}

	// 启动判断:actions 中包含 Exchange 或 Load → 追加 StartupWasher
	hasLoadOrExchange := false
	for _, a := range actions {
		if a.Kind == "exchange_full" || a.Kind == "load_to_machine" {
			hasLoadOrExchange = true
			break
		}
	}
	if hasLoadOrExchange {
		actions = append(actions, action.RobotAction{
			Kind:          "startup_washer",
			JobID:         ct.JobID,
			WorkpieceType: snap.ProductTypeID,
		})
	}

	return actions
}
  • Step 2: 修改 trySchedule dispatch 阶段

trySchedule 的候选任务循环中,当遇到 WASHER_HP Unload/Exchange 时,走批量编排路径:

// WASHER_HP Unload/Exchange 特殊处理
if (ct.Action == scheduler.ActionUnloadWasher || ct.Action == scheduler.ActionExchange) && snap.ResourceType == "WASHER_HP" {
    actions := l.buildWasherUnloadActions(ctx, ct, snap)
    l.workerBusy.Store(true)
    go l.dispatchWorkerActions(ctx, ct.JobID, actions)
    continue
}
  • Step 3: 移除 candidateToRobotAction 中 WASHER_HP batch_unload 逻辑

删除 candidateToRobotActionsnap.ResourceType == "WASHER_HP" && mid > 0batch_unload 分支(loop.go:685-704),改为直接返回 unload_to_buffer

// 替换原来的 WASHER_HP batch_unload 逻辑
if ct.Action.IsUnload() {
    mid, _ := parsePositionRef(snap.PositionRefID)
    return action.RobotAction{
        Kind:          "unload_to_buffer",
        JobID:         ct.JobID,
        WorkpieceType: wt,
        SrcMachine:    mid,
        DstSlot:       ts,
    }
}
  • Step 4: 编译验证

Run: rtk go build ./internal/eventloop/

  • Step 5: Commit
rtk git add internal/eventloop/loop.go
rtk git commit -m "feat(eventloop): WASHER_HP 卸载/换料批量编排,逐槽位判断 Exchange/Unload"

Chunk 4: handleWorkerResult 适配

Task 4: handleWorkerResult 处理多 action 结果

Files:

  • Modify: internal/eventloop/loop.go:1062-1253 (handleWorkerResult)

  • Test: internal/eventloop/loop_test.go

  • Step 1: 修改 handleWorkerResult 识别多 action 结果

dispatchWorkerActions 返回的 payload 包含 actionCount > 1 时,按顺序处理每个 action 的状态变更。核心逻辑:

  • 从 payload 中提取 jobIdstargetIDs 数组

  • 对每个 action,根据 kind 执行对应的状态推进逻辑(复用现有 handleWorkerResult 中的单 action 逻辑)

  • startup_washer action 无状态变更

  • 任一 action 失败 → 整个批次失败,已成功的 action 状态保留

  • Step 2: 新增 handleSingleActionResult 辅助方法

将现有 handleWorkerResult 中单 action 的状态推进逻辑提取为独立方法:

// handleSingleActionResult 处理单个 action 成功后的状态推进
func (l *ProductionEventLoop) handleSingleActionResult(ctx context.Context, jobID int, actKind string, targetID int, assignedSlot int) {
    // 根据 actKind 执行对应的状态推进:
    // load_to_machine → AdvanceStep + SetStatus(Processing)
    // unload_to_buffer → AdvanceStep + SetStatus(OnBuffer)
    // exchange_full → AdvanceStep(doneJob) + AdvanceStep(loadJob) + SetStatus(Processing)
    // startup_washer → 无操作
    // unload_to_dock → AdvanceStep + ClearTempSlotNo
    // scan_full / mark_full → 更新 context
}
  • Step 3: 编译验证

Run: rtk go build ./internal/eventloop/

  • Step 4: 运行测试

Run: rtk go test ./internal/eventloop/... -count=1

  • Step 5: Commit
rtk git add internal/eventloop/loop.go
rtk git commit -m "feat(eventloop): handleWorkerResult 适配多 action 结果处理"

Chunk 5: MockPLC 适配 + 集成测试

Task 5: MockPLC StartupWasher 信号适配

Files:

  • Modify: internal/mock/plc.go

  • Test: internal/eventloop/loop_test.go

  • Step 1: 确认 MockPLC StartupWasher 信号路径

当前 MockPLC 中 triggerMachineDonecase 5 使用 washerDone atomic 防重复触发。新逻辑下 StartupWasher 作为独立 action 调用 robotCtrl.StartupWasher(ctx),该方法写入 WasherStartup PLC 地址。

检查 MockPLC 是否有 WasherStartup 地址的 auto-reply 规则。如果没有,需要添加:当 WasherStartup 信号被写入时,触发 4s 计时后设置 WasherDone 信号。

  • Step 2: 添加 WasherStartup auto-reply 规则(如需要)

initAutoReplyRules 中添加:

// WasherStartup (M1xxx): 启动清洗机,4s 后触发 WasherDone
// 需要确认 WasherStartup 对应的 PLC 地址

注意:如果 StartupWasher 已经通过 PlaceWorkpieceToMachinecase 5 路径触发,则不需要额外规则。需要确认 StartupWasher 写入的地址是否与 PlaceWorkpieceToMachinecase 5 路径一致。

  • Step 3: 编译验证

Run: rtk go build ./internal/mock/

  • Step 4: Commit
rtk git add internal/mock/plc.go
rtk git commit -m "feat(mock): StartupWasher 信号适配"

Task 6: 端到端集成测试

Files:

  • Test: internal/eventloop/loop_test.go

  • Step 1: 编写 WASHER_HP 批量上料测试

测试场景:

  • 1个待清洗工件 → 生成 []RobotAction{Load, StartupWasher}

  • 2个待清洗工件 → 生成 []RobotAction{Load1, Load2, StartupWasher}

  • Step 2: 编写 WASHER_HP 卸载/换料测试

测试场景:

  • 2个 Done0个配对 → Unload + Unload

  • 2个 Done1个配对 → Exchange + Unload + StartupWasher

  • 2个 Done2个配对 → Exchange + Exchange + StartupWasher

  • 1个 Done0个配对 → Unload

  • 1个 Done1个配对 → Exchange + Load + StartupWasher

  • Step 3: 运行全部测试

Run: rtk go test ./internal/eventloop/... ./internal/processor/... -count=1

  • Step 4: Commit
rtk git add internal/eventloop/loop_test.go
rtk git commit -m "test(eventloop): WASHER_HP 批量上料/卸载/换料集成测试"

Chunk 6: Mock 模式端到端验证

Task 7: Mock 模式完整流程验证

Files:

  • No new files (验证现有 mockrun)

  • Step 1: 运行 mockrun 验证完整流程

Run: rtk go run cmd/mockrun/main.go (观察日志确认 WASHER_HP 批量上料/卸料行为)

  • Step 2: 检查日志关键事件

确认日志中出现:

  • dispatch actions 包含 load_to_machine, load_to_machine, startup_washer2个候选时)

  • dispatch actions 包含 load_to_machine, startup_washer1个候选时)

  • 卸载/换料场景的 action 数组正确

  • Step 3: 最终 Commit

rtk git commit -m "feat(washer): 高压清洗机批量上料/下料/换料完成"