# 高压清洗机批量上料/下料/换料 实施计划 > **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.go` 中 `HardwareWorker` 接口从单个 action 改为 action 数组: ```go // HardwareWorker 硬件执行器接口 type HardwareWorker interface { Execute(ctx context.Context, actions []RobotAction) error } ``` - [ ] **Step 2: 适配 RobotWorker.Execute** 修改 `internal/processor/robot_worker.go` 中 `Execute` 方法,串行执行 action 数组: ```go 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` 中新增: ```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` 的代码块: ```go // 删除这段: 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` 中的类型别名无需修改(`RobotAction` 和 `HardwareWorker` 仍是 `action` 包的类型别名,接口变更自动传播)。 - [ ] **Step 6: 适配 dispatchWorker 调用** 修改 `internal/eventloop/loop.go` 中 `dispatchWorker` 方法,将 `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** ```bash 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` 中新增方法,查找是否有第二个待清洗工件可以同时上料: ```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` 数组: ```go // 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` 时,走批量编排路径: ```go // 在 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` 数组: ```go // 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** ```bash 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: ```go // buildWasherUnloadActions 构建 WASHER_HP 卸载/换料 action 数组 // 检查 batch 设备另一个槽位状态,按需追加 action func (l *ProductionEventLoop) buildWasherUnloadActions(ctx context.Context, ct scheduler.CandidateTask, snap *RuntimeSnapshot) []action.RobotAction { var actions []action.RobotAction // 第一个 action(Unload 或 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 时,走批量编排路径: ```go // 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 逻辑** 删除 `candidateToRobotAction` 中 `snap.ResourceType == "WASHER_HP" && mid > 0` 的 `batch_unload` 分支(`loop.go:685-704`),改为直接返回 `unload_to_buffer`: ```go // 替换原来的 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** ```bash 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 中提取 `jobIds` 和 `targetIDs` 数组 - 对每个 action,根据 kind 执行对应的状态推进逻辑(复用现有 handleWorkerResult 中的单 action 逻辑) - `startup_washer` action 无状态变更 - 任一 action 失败 → 整个批次失败,已成功的 action 状态保留 - [ ] **Step 2: 新增 handleSingleActionResult 辅助方法** 将现有 handleWorkerResult 中单 action 的状态推进逻辑提取为独立方法: ```go // 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** ```bash 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 中 `triggerMachineDone` 的 `case 5` 使用 `washerDone` atomic 防重复触发。新逻辑下 `StartupWasher` 作为独立 action 调用 `robotCtrl.StartupWasher(ctx)`,该方法写入 `WasherStartup` PLC 地址。 检查 MockPLC 是否有 `WasherStartup` 地址的 auto-reply 规则。如果没有,需要添加:当 `WasherStartup` 信号被写入时,触发 4s 计时后设置 `WasherDone` 信号。 - [ ] **Step 2: 添加 WasherStartup auto-reply 规则(如需要)** 在 `initAutoReplyRules` 中添加: ```go // WasherStartup (M1xxx): 启动清洗机,4s 后触发 WasherDone // 需要确认 WasherStartup 对应的 PLC 地址 ``` 注意:如果 `StartupWasher` 已经通过 `PlaceWorkpieceToMachine` 的 `case 5` 路径触发,则不需要额外规则。需要确认 `StartupWasher` 写入的地址是否与 `PlaceWorkpieceToMachine` 的 `case 5` 路径一致。 - [ ] **Step 3: 编译验证** Run: `rtk go build ./internal/mock/` - [ ] **Step 4: Commit** ```bash 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个 Done,0个配对 → `Unload + Unload` - 2个 Done,1个配对 → `Exchange + Unload + StartupWasher` - 2个 Done,2个配对 → `Exchange + Exchange + StartupWasher` - 1个 Done,0个配对 → `Unload` - 1个 Done,1个配对 → `Exchange + Load + StartupWasher` - [ ] **Step 3: 运行全部测试** Run: `rtk go test ./internal/eventloop/... ./internal/processor/... -count=1` - [ ] **Step 4: Commit** ```bash 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_washer`(2个候选时) - `dispatch actions` 包含 `load_to_machine, startup_washer`(1个候选时) - 卸载/换料场景的 action 数组正确 - [ ] **Step 3: 最终 Commit** ```bash rtk git commit -m "feat(washer): 高压清洗机批量上料/下料/换料完成" ```