diff --git a/Sln.Wcs.HikRoBotDispatcher/HikRoBotDispatchHub.cs b/Sln.Wcs.HikRoBotDispatcher/HikRoBotDispatchHub.cs
index dcb21df..dc845a5 100644
--- a/Sln.Wcs.HikRoBotDispatcher/HikRoBotDispatchHub.cs
+++ b/Sln.Wcs.HikRoBotDispatcher/HikRoBotDispatchHub.cs
@@ -23,12 +23,14 @@
#endregion << 版 本 注 释 >>
+using Sln.Wcs.HikRoBotAdapter.Domain.Dto.GbContinueTask;
using Sln.Wcs.HikRoBotAdapter.Domain.Dto.GbTaskSubmit;
using Sln.Wcs.HikRoBotAdapter.Domain.Dto.QueryTask;
using Sln.Wcs.HikRoBotAdapter.Enum;
using Sln.Wcs.HikRoBotAdapter.Service;
using Sln.Wcs.Model.Domain;
using Sln.Wcs.Serilog;
+using TargetRoute = Sln.Wcs.HikRoBotAdapter.Domain.Dto.GbTaskSubmit.TargetRoute;
namespace Sln.Wcs.HikRoBotDispatcher;
@@ -48,19 +50,67 @@ public class HikRoBotDispatchHub
/// 接收调度任务=> 下发至AGVS
///
///
- public void ReciveTask(LiveTaskDetail taskDetail)
+ public bool ReciveTask(LiveTaskDetail taskDetail)
{
+ string startType = taskDetail.startPoint.Length > 12 ? "SITE" : "STORAGE";
+ string endType = taskDetail.endPoint.Length > 12 ? "SITE" : "STORAGE";
GbTaskSubmitResultDto submitResultDto = _hikRobotAdapter.GbTaskSubmit(new GbTaskSubmitDto()
{
-
+ RobotTaskCode = taskDetail.taskCode,
+ TaskType = taskDetail.endPoint.Length > 12 ? "F07" : "PF-FMR-COMMON",
+ TargetRoute = new List()
+ {
+ new TargetRoute()
+ {
+ Type = startType,
+ Code = taskDetail.startPoint
+ },
+ new TargetRoute()
+ {
+ Type = endType,
+ Code = taskDetail.endPoint
+ }
+ }
+
});
- if (submitResultDto.code == HikRoBotStatusEnum.成功)
+ if (submitResultDto.code == HikRoBotStatusEnum.成功 && submitResultDto.data.code == "SUCCESS")
{
_logger.Info($"调度任务{taskDetail.taskCode}下发成功");
+
+ return true;
}
else
{
_logger.Info($"调度任务{taskDetail.taskCode}下发失败:{submitResultDto.msg}");
+ return false;
+ }
+ }
+
+ ///
+ /// 继续执行任务=> 通知AGVS继续执行
+ ///
+ ///
+ public bool ContinueTask(LiveTaskDetail taskDetail)
+ {
+ GbContinueTaskResultDto result = _hikRobotAdapter.GbContinueTask(new GbContinueTaskDto()
+ {
+ TriggerType = "TASK",
+ TriggerCode = taskDetail.taskCode,
+ TargetRoute = new HikRoBotAdapter.Domain.Dto.GbContinueTask.TargetRoute()
+ {
+ Type = "SITE",
+ Code = taskDetail.execDevice
+ }
+ });
+ if (result.code == HikRoBotStatusEnum.成功)
+ {
+ _logger.Info($"调度任务{taskDetail.taskCode}继续执行成功");
+ return true;
+ }
+ else
+ {
+ _logger.Info($"调度任务{taskDetail.taskCode}继续执行失败:{result.msg}");
+ return false;
}
}
diff --git a/Sln.Wcs.HikRoBotServer/Program.cs b/Sln.Wcs.HikRoBotServer/Program.cs
index 45a3b79..d62a569 100644
--- a/Sln.Wcs.HikRoBotServer/Program.cs
+++ b/Sln.Wcs.HikRoBotServer/Program.cs
@@ -1,5 +1,6 @@
using System.Reflection;
using Com.Ctrip.Framework.Apollo;
+using Flurl.Http;
using Sln.Wcs;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
@@ -19,6 +20,7 @@ using ZiggyCreatures.Caching.Fusion;
using ZiggyCreatures.Caching.Fusion.Serialization.NewtonsoftJson;
var builder = WebApplication.CreateBuilder(args);
+builder.WebHost.ConfigureKestrel(o => o.ListenLocalhost(5200));
var basePath = AppContext.BaseDirectory;
// ---- 配置 ----
@@ -30,8 +32,15 @@ var localConfig = new ConfigurationBuilder()
var apolloConfigSection = localConfig.GetSection("apollo");
builder.Services.AddSingleton(localConfig);
+FlurlHttp.ConfigureClientForUrl("https://172.16.12.11")
+ .ConfigureInnerHandler(handler =>
+ {
+ handler.ServerCertificateCustomValidationCallback = (_, _, _, _) => true;
+ });
+
+
var configProvider = new UpdateableConfigProvider();
-configProvider.Set("PLC参数", "");
+configProvider.Set("PLC参数", "");
var apolloConfig = new ConfigurationBuilder()
.SetBasePath(basePath)
@@ -92,8 +101,8 @@ api.MapPost("/task/receive", (ReceiveTaskRequest req) =>
taskCode = req.TaskCode, deviceType = 1, taskStatus = 1,
startPoint = req.StartPoint, endPoint = req.EndPoint
};
- hub.ReciveTask(detail);
- return Results.Ok(new { success = true });
+ bool res = hub.ReciveTask(detail);
+ return Results.Ok(new { success = true , isSuccess = res});
});
// Hub 方法: GetTaskStatus
@@ -103,6 +112,19 @@ api.MapGet("/task/status", (string taskCode) =>
return Results.Ok(new { time = DateTime.Now, status = "ok" , taskStatus = taskStatus });
});
+// Hub 方法: ContinueTask
+api.MapPost("/task/continue", (ContinueTaskRequest req) =>
+{
+ var detail = new LiveTaskDetail
+ {
+ taskCode = req.TaskCode, deviceType = 1, taskStatus = 1,
+ startPoint = req.StartPoint, endPoint = req.EndPoint,
+ execDevice = req.ExecDevice
+ };
+ bool res = hub.ContinueTask(detail);
+ return Results.Ok(new { success = true, isSuccess = res });
+});
+
//0524779AA0550094
// AGV 等待点
// api.MapPost("/robot/wait", (HikRoBotWaitRequest req) =>
@@ -116,10 +138,11 @@ log.Info("HikRoBotServer 就绪: http://localhost:5200/swagger");
app.Run();
record ReceiveTaskRequest(string TaskCode, string StartPoint, string EndPoint);
+record ContinueTaskRequest(string TaskCode, string StartPoint, string EndPoint, string ExecDevice);
///
/// 请求参数
///
/// WCS 下发的任务编号:同 submit 接口
/// 提升机编号:15 栋入库-1#Hoist ;15栋出库-2#Hoist;14 栋提升机-3#Host;13 栋提升机-4#Hoist;
-record HikRoBotWaitRequest(string TaskCode, string HoistCode);
+//record HikRoBotWaitRequest(string TaskCode, string HoistCode);
diff --git a/Sln.Wcs.HoistDispatcher/HoistDispatchHub.cs b/Sln.Wcs.HoistDispatcher/HoistDispatchHub.cs
index e28a526..f29eaad 100644
--- a/Sln.Wcs.HoistDispatcher/HoistDispatchHub.cs
+++ b/Sln.Wcs.HoistDispatcher/HoistDispatchHub.cs
@@ -23,6 +23,7 @@
#endregion << 版 本 注 释 >>
+using System.Collections.Concurrent;
using Newtonsoft.Json;
using Sln.Wcs.HoistAdapter.Domain.Dto.GetHoistStatus;
using Sln.Wcs.HoistAdapter.Domain.Dto.HoistTaskExecutor;
@@ -42,6 +43,8 @@ public class HoistDispatchHub
{
private readonly SerilogHelper _logger;
private readonly IHoistService _hoistAdapter;
+ private static readonly ConcurrentDictionary HoistLocks = new();
+
public HoistDispatchHub(IHoistService hoistAdapter, SerilogHelper logger)
{
_hoistAdapter = hoistAdapter;
@@ -136,8 +139,6 @@ public class HoistDispatchHub
int endPoint = Convert.ToInt32(deviceInfo.deviceSerialNo + startFloor + endFloor);
-
-
//调用适配层下发 提升机调度任务
SetHoistTaskResultDto res = _hoistAdapter.SetHoistTask(new SetHoistTaskDto()
{
@@ -162,30 +163,44 @@ public class HoistDispatchHub
}
}
+ ///
+ /// 等待提升机任务完成
+ ///
+ ///
+ ///
+ ///
+ public async Task WaitHoistComplete(string hostCode, int deviceSerialNo)
+ {
+ var lockObj = HoistLocks.GetOrAdd(hostCode, _ => new object());
+ lock (lockObj)
+ {
+ RefreshDeviceParams(hostCode, out List deviceInfos);
+ var device = deviceInfos?.FirstOrDefault(info => info.deviceSerialNo == deviceSerialNo);
+ var paramValue = device?.deviceParams
+ .FirstOrDefault(item => item.paramName.Contains("任务执行完成"))
+ ?.paramValue;
+
+ return paramValue != 1;
+ }
+ }
+
///
/// 获取空闲提升机
///
///
///
- public async Task GetFreeHoistAsync(string hostCode)
+ public async Task GetFreeHoistAsync(string hostCode)
{
- while (true)
+ var lockObj = HoistLocks.GetOrAdd(hostCode, _ => new object());
+ lock (lockObj)
{
- lock (string.Empty)
- {
- RefreshDeviceParams(hostCode, out List deviceInfos);
-
- var freeHoist = deviceInfos?.FirstOrDefault(info =>
- info.deviceParams.Any(item =>
- item.paramName.Contains("提升机反馈任务状态") && item.paramValue == 0
- )
- );
-
- if (freeHoist != null)
- return freeHoist;
-
- Thread.Sleep(1000);
- }
+ RefreshDeviceParams(hostCode, out List deviceInfos);
+
+ return deviceInfos?.FirstOrDefault(info =>
+ info.deviceParams.Any(item =>
+ item.paramName.Contains("提升机反馈任务状态") && item.paramValue == 0
+ )
+ );
}
}
diff --git a/Sln.Wcs.HoistServer/Program.cs b/Sln.Wcs.HoistServer/Program.cs
index b2b21b8..69a4a0e 100644
--- a/Sln.Wcs.HoistServer/Program.cs
+++ b/Sln.Wcs.HoistServer/Program.cs
@@ -18,6 +18,7 @@ using ZiggyCreatures.Caching.Fusion;
using ZiggyCreatures.Caching.Fusion.Serialization.NewtonsoftJson;
var builder = WebApplication.CreateBuilder(args);
+builder.WebHost.ConfigureKestrel(o => o.ListenLocalhost(5100));
var basePath = AppContext.BaseDirectory;
// ---- 配置 ----
@@ -147,6 +148,13 @@ api.MapGet("/hoist/free", async (string hostCode) =>
return Results.Ok(new { found = d != null, d?.deviceCode, d?.deviceName, d?.hostCode, d?.deviceSerialNo });
});
+// 5. WaitHoistComplete
+api.MapGet("/hoist/wait-complete", async (string hostCode, int deviceSerialNo) =>
+{
+ bool res = await hub.WaitHoistComplete(hostCode, deviceSerialNo);
+ return Results.Ok(new { success = true, isComplete = res });
+});
+
api.MapGet("/health", () => Results.Ok(new { time = DateTime.Now, status = "ok" }));
log.Info("HoistServer 就绪: http://localhost:5100/swagger");
diff --git a/Sln.Wcs.Strategy/MaterialInStoreExecutor.cs b/Sln.Wcs.Strategy/MaterialInStoreExecutor.cs
index 698209c..a0a1bf9 100644
--- a/Sln.Wcs.Strategy/MaterialInStoreExecutor.cs
+++ b/Sln.Wcs.Strategy/MaterialInStoreExecutor.cs
@@ -1,4 +1,6 @@
using System.Collections.Concurrent;
+using System.Net.Http;
+using System.Net.Http.Json;
using Sln.Wcs.Model.Domain;
using Sln.Wcs.Repository.service;
using Sln.Wcs.Serilog;
@@ -118,25 +120,29 @@ public class MaterialInStoreExecutor
lock (DbWriteLock) { task.taskStatus = 2; _taskQueueService.Update(task); }
- foreach (var detail in task.taskDetails.OrderBy(d => d.objId))
+ var sortedDetails = task.taskDetails.OrderBy(d => d.objId).ToList();
+ for (int i = 0; i < sortedDetails.Count; i++)
{
if (ct.IsCancellationRequested) return;
+ var detail = sortedDetails[i];
+
if (detail.taskStatus is 2 or 3)
{
- _logger.Info($" 明细 {detail.objId} 已处理,跳过");
+ _logger.Info($" 明细 {detail.objId}-{detail.taskCode} 已处理,跳过");
continue;
}
lock (DbWriteLock) { detail.taskStatus = 2; _taskDetailService.Update(detail); }
+ var nextDetail = i + 1 < sortedDetails.Count ? sortedDetails[i + 1] : null;
+
var devName = detail.deviceType switch { 1 => "AGV", 2 => "提升机", _ => "输送线" };
_logger.Info($" → {devName}下发 {detail.taskCode}-{detail.objId}: {detail.startPoint} → {detail.endPoint}");
- // 模拟执行在锁外,多任务并行不受影响
var ok = detail.deviceType switch
{
- 1 => await DispatchAgvAsync(detail),
+ 1 => await DispatchAgvAsync(detail, nextDetail),
2 => await DispatchHoistAsync(detail),
_ => await DispatchConveyorAsync(detail)
};
@@ -144,12 +150,12 @@ public class MaterialInStoreExecutor
if (ok)
{
lock (DbWriteLock) { detail.taskStatus = 3; _taskDetailService.Update(detail); }
- _logger.Info($" ✓ {detail.objId} 完成");
+ _logger.Info($" ✓ {detail.taskCode}-{detail.objId} 完成");
}
else
{
lock (DbWriteLock) { detail.taskStatus = 1; _taskDetailService.Update(detail); }
- _logger.Error($" ✗ {detail.objId} 失败,中断任务");
+ _logger.Error($" ✗ {detail.taskCode}-{detail.objId} 失败,中断任务");
return;
}
}
@@ -158,28 +164,253 @@ public class MaterialInStoreExecutor
_logger.Info($"任务 {task.taskCode} 执行完成");
}
- private async Task DispatchAgvAsync(LiveTaskDetail detail)
+ private async Task DispatchAgvAsync(LiveTaskDetail detail, LiveTaskDetail? nextDetail = null)
{
- // AGV 不限并发,直接下发
- _logger.Info($" [AGV] 开始 {detail.startPoint} → {detail.endPoint}");
- await Task.Delay(10_000); // 模拟执行 10s
- _logger.Info($" [AGV] 完成 {detail.startPoint} → {detail.endPoint}");
- return true;
+ const string agvBaseUrl = "http://localhost:5200";
+ const string hoistBaseUrl = "http://localhost:5100";
+ const int pollIntervalMs = 2000;
+
+ using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
+
+ _logger.Info($" [AGV] 下发 {detail.taskCode}-{detail.objId}: {detail.startPoint} → {detail.endPoint}");
+
+ // 1. 调用 /task/receive 下发 AGV 任务
+ try
+ {
+ string[] startPointArray = detail.startPoint.Split("_");
+ string[] endPointArray = detail.endPoint.Split("_");
+ var res = await httpClient.PostAsJsonAsync($"{agvBaseUrl}/api/task/receive",
+ new { taskCode = detail.taskCode, startPoint = startPointArray[2], endPoint = endPointArray[2] });
+ res.EnsureSuccessStatusCode();
+ var receiveResult = await res.Content.ReadFromJsonAsync();
+ if (receiveResult is not { isSuccess: true })
+ {
+ _logger.Error($" [AGV] {detail.taskCode} 下发失败: AGV 调度中心拒绝任务");
+ return false;
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.Error($" [AGV] {detail.taskCode} 下发失败: {ex.Message}");
+ return false;
+ }
+
+ _logger.Info($" [AGV] {detail.taskCode} 下发成功,开始轮询任务状态...");
+
+ // 2. 循环调用 /task/status 获取 AGV 任务状态
+ var hoistDispatched = false;
+ while (true)
+ {
+ await Task.Delay(pollIntervalMs);
+
+ try
+ {
+ var statusRes = await httpClient.GetFromJsonAsync(
+ $"{agvBaseUrl}/api/task/status?taskCode={Uri.EscapeDataString(detail.taskCode!)}");
+ var taskStatus = statusRes?.taskStatus ?? string.Empty;
+
+ _logger.Info($" [AGV] {detail.taskCode} 任务状态: {taskStatus}");
+
+ switch (taskStatus)
+ {
+ case "FINISHED":
+ case "MANUALED":
+ _logger.Info($" [AGV] {detail.taskCode} 完成 {detail.startPoint} → {detail.endPoint}");
+ return true;
+ case "CANCELLED":
+ _logger.Error($" [AGV] {detail.taskCode} 任务已取消 {detail.startPoint} → {detail.endPoint}");
+ return false;
+ case "WAIT":
+ _logger.Info($" [AGV] {detail.taskCode} 等待 {detail.startPoint} → {detail.endPoint}");
+ if (!hoistDispatched && nextDetail is { deviceType: 2 })
+ {
+ hoistDispatched = await TryDispatchHoistForWaitAsync(httpClient, hoistBaseUrl, nextDetail);
+ if (hoistDispatched)
+ await ContinueAgvAsync(httpClient, agvBaseUrl, nextDetail);
+ }
+ break;
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.Error($" [AGV] 状态查询异常: {ex.Message}");
+ }
+ }
+ }
+
+ /// AGV 等待时筛选空闲提升机并下发提升机任务
+ private async Task TryDispatchHoistForWaitAsync(HttpClient httpClient, string hoistBaseUrl, LiveTaskDetail detail)
+ {
+ var hostCode = DetermineHoistCode(detail);
+ _logger.Info($" [AGV] 任务等待中,筛选空闲提升机 ({hostCode})...");
+
+ try
+ {
+ FreeHoistResponse? freeRes;
+ while (true)
+ {
+ freeRes = await httpClient.GetFromJsonAsync(
+ $"{hoistBaseUrl}/api/hoist/free?hostCode={Uri.EscapeDataString(hostCode!)}");
+
+ if (freeRes is { found: true })
+ break;
+
+ _logger.Info($" [AGV] 暂无空闲提升机 ({hostCode}),等待重试...");
+ await Task.Delay(1000);
+ }
+
+ var dispatchRes = await httpClient.PostAsJsonAsync($"{hoistBaseUrl}/api/task/dispatch",
+ new
+ {
+ hostCode = freeRes.hostCode,
+ serialNo = freeRes.deviceSerialNo,
+ taskCode = detail.taskCode,
+ startPoint = detail.startPoint,
+ endPoint = detail.endPoint
+ });
+
+ var result = await dispatchRes.Content.ReadFromJsonAsync();
+ if (result is not { success: true })
+ {
+ _logger.Error($" [AGV] 提升机下发失败: {result?.msg ?? "未知错误"}");
+ return false;
+ }
+
+ var execDevice = $"{freeRes.hostCode}_{freeRes.deviceSerialNo}";
+ _logger.Info($" [AGV] 提升机 {execDevice} 已下发");
+
+ lock (DbWriteLock)
+ {
+ detail.execDevice = execDevice;
+ _taskDetailService.Update(detail);
+ }
+
+ return true;
+ }
+ catch (Exception ex)
+ {
+ _logger.Error($" [AGV] 提升机调度失败: {ex.Message}");
+ return false;
+ }
+ }
+
+ /// 通知 AGV 调度中心继续执行
+ private async Task ContinueAgvAsync(HttpClient httpClient, string agvBaseUrl, LiveTaskDetail hoistDetail)
+ {
+ try
+ {
+ var res = await httpClient.PostAsJsonAsync($"{agvBaseUrl}/api/task/continue",
+ new
+ {
+ taskCode = hoistDetail.taskCode,
+ startPoint = hoistDetail.startPoint,
+ endPoint = hoistDetail.endPoint,
+ execDevice = hoistDetail.execDevice
+ });
+ res.EnsureSuccessStatusCode();
+
+ var result = await res.Content.ReadFromJsonAsync();
+ if (result is { isSuccess: true })
+ _logger.Info($" [AGV] 继续执行已下发 (提升机 {hoistDetail.execDevice})");
+ else
+ _logger.Error($" [AGV] 继续执行下发失败: AGV 调度中心拒绝");
+ }
+ catch (Exception ex)
+ {
+ _logger.Error($" [AGV] 继续执行下发异常: {ex.Message}");
+ }
+ }
+
+ /// 根据 startPoint 确定提升机编号:15栋入库→1#Host, 15栋出库→2#Host, 14栋→3#Host, 13栋→4#Host
+ private static string DetermineHoistCode(LiveTaskDetail detail)
+ {
+ var pt = detail.startPoint ?? "";
+
+ if (detail.taskType == 2 && detail.taskCategory == 2 && pt.Contains("15#"))
+ return "2#Host";
+
+ if (pt.Contains("13#")) return "4#Host";
+ if (pt.Contains("14#")) return "3#Host";
+ if (pt.Contains("15#")) return "1#Host";
+ return "1#Host";
}
private async Task DispatchHoistAsync(LiveTaskDetail detail)
{
- // 每栋楼提升机限 2 台并发
+ const string hoistBaseUrl = "http://localhost:5100";
+
var building = ExtractBuilding(detail.startPoint);
var semaphore = HoistSemaphores.GetOrAdd(building, _ => new SemaphoreSlim(2, 2));
await semaphore.WaitAsync();
try
{
- _logger.Info($" [提升机] {building}#楼 开始 (可用:{semaphore.CurrentCount}) {detail.startPoint} → {detail.endPoint}");
- await Task.Delay(20_000); // 模拟执行 20s
- _logger.Info($" [提升机] {building}#楼 完成 {detail.startPoint} → {detail.endPoint}");
- return true;
+ if (string.IsNullOrWhiteSpace(detail.execDevice))
+ {
+ _logger.Error($" [提升机] {building}#楼 未分配执行设备");
+ return false;
+ }
+
+ var parts = detail.execDevice.Split('_');
+ if (parts.Length != 2 || !int.TryParse(parts[1], out var serialNo))
+ {
+ _logger.Error($" [提升机] {building}#楼 设备编号格式错误: {detail.execDevice}");
+ return false;
+ }
+ var hostCode = parts[0];
+
+ _logger.Info($" [提升机] {building}#楼 启动 {detail.startPoint} → {detail.endPoint} (设备:{detail.execDevice})");
+
+ using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
+ var res = await httpClient.PostAsJsonAsync($"{hoistBaseUrl}/api/hoist/receive-pallet",
+ new
+ {
+ hostCode = hostCode,
+ serialNo = serialNo,
+ taskCode = detail.taskCode,
+ palletBarcode = detail.palletBarcode ?? "",
+ startPoint = detail.startPoint,
+ endPoint = detail.endPoint
+ });
+
+ var result = await res.Content.ReadFromJsonAsync();
+ if (result is not { success: true })
+ {
+ _logger.Error($" [提升机] {building}#楼 启动失败: {result?.msg ?? "未知错误"}");
+ return false;
+ }
+
+ _logger.Info($" [提升机] {building}#楼 已启动 {detail.startPoint} → {detail.endPoint}");
+
+ lock (DbWriteLock)
+ {
+ detail.taskStatus = 2;
+ detail.execDevice = $"{hostCode}_{serialNo}";
+ _taskDetailService.Update(detail);
+ }
+
+ // 轮询等待提升机任务完成
+ _logger.Info($" [提升机] {building}#楼 等待任务完成...");
+ using var waitClient = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
+ while (true)
+ {
+ var waitRes = await waitClient.GetFromJsonAsync(
+ $"{hoistBaseUrl}/api/hoist/wait-complete?hostCode={Uri.EscapeDataString(hostCode)}&deviceSerialNo={serialNo}");
+
+ if (waitRes is { isComplete: true })
+ {
+ _logger.Info($" [提升机] {building}#楼 任务完成 {detail.startPoint} → {detail.endPoint}");
+
+ return true;
+ }
+
+ await Task.Delay(2000);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.Error($" [提升机] {building}#楼 异常: {ex.Message}");
+ return false;
}
finally
{
@@ -199,4 +430,36 @@ public class MaterialInStoreExecutor
var match = System.Text.RegularExpressions.Regex.Match(pointCode, @"^(\d+)");
return match.Success ? int.Parse(match.Groups[1].Value) : 0;
}
+
+ // ---- API 响应 DTO ----
+
+ private class AGVTaskReceiveResponse
+ {
+ public bool isSuccess { get; set; } = false;
+ }
+
+ private class AGVTaskStatusResponse
+ {
+ public string taskStatus { get; set; } = string.Empty;
+ }
+
+ private class FreeHoistResponse
+ {
+ public bool found { get; set; }
+ public string? deviceCode { get; set; }
+ public string? deviceName { get; set; }
+ public string? hostCode { get; set; }
+ public int deviceSerialNo { get; set; }
+ }
+
+ private class WaitCompleteResponse
+ {
+ public bool isComplete { get; set; }
+ }
+
+ private class ApiResult
+ {
+ public bool success { get; set; }
+ public string? msg { get; set; }
+ }
}
diff --git a/Sln.Wcs.UI/Converters/CodeToTextConverter.cs b/Sln.Wcs.UI/Converters/CodeToTextConverter.cs
new file mode 100644
index 0000000..a9f3407
--- /dev/null
+++ b/Sln.Wcs.UI/Converters/CodeToTextConverter.cs
@@ -0,0 +1,32 @@
+using System;
+using System.Globalization;
+using Avalonia.Data.Converters;
+
+namespace Sln.Wcs.UI.Converters;
+
+public class CodeToTextConverter : IValueConverter
+{
+ public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ if (value is null) return "--";
+ var code = System.Convert.ToInt32(value);
+ var category = parameter as string;
+
+ return category switch
+ {
+ "TaskType" => code switch { 1 => "入库", 2 => "出库", _ => "--" },
+ "TaskCategory" => code switch { 1 => "包材", 2 => "成品", 3 => "托盘", _ => "--" },
+ "TaskStatus" => code switch { 1 => "待执行", 2 => "执行中", 3 => "已完成", _ => "--" },
+ "ExecutionMode" => code switch { 0 => "自动", 1 => "手动", _ => "--" },
+ "DeviceType" => code switch { 0 => "输送线", 1 => "AGV", 2 => "提升机", _ => "--" },
+ "DeviceStatus" => code switch { 0 => "正常", 1 => "在忙", 2 => "异常", _ => "--" },
+ "IsFlag" => code switch { 0 => "否", 1 => "是", _ => "--" },
+ "OperationType" => code switch { 0 => "默认读写", 1 => "只读", 2 => "只写", _ => "--" },
+ "LocationStatus" => code switch { 0 => "未使用", 1 => "已使用", 2 => "锁库", 3 => "异常", _ => "--" },
+ _ => code.ToString()
+ };
+ }
+
+ public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => throw new NotSupportedException();
+}
diff --git a/Sln.Wcs.UI/ViewModels/Task/ManualTaskViewModel.cs b/Sln.Wcs.UI/ViewModels/Task/ManualTaskViewModel.cs
index b2836b7..a8dc340 100644
--- a/Sln.Wcs.UI/ViewModels/Task/ManualTaskViewModel.cs
+++ b/Sln.Wcs.UI/ViewModels/Task/ManualTaskViewModel.cs
@@ -185,14 +185,17 @@ public partial class ManualTaskViewModel : ObservableObject
}
}
var freeUrl = $"{HoistBaseUrl}/api/hoist/free?hostCode={Uri.EscapeDataString(hostCode)}";
- var freeRes = await _http.GetFromJsonAsync(freeUrl);
-
- if (freeRes is not { found: true })
+ FreeHoistResponse? freeRes;
+ while (true)
{
- StatusText = "暂无空闲提升机,请稍后重新获取";
+ freeRes = await _http.GetFromJsonAsync(freeUrl);
+
+ if (freeRes is { found: true })
+ break;
+
+ StatusText = "暂无空闲提升机,等待中...";
HoistInfo = "忙碌";
- _logger.Info($"手动任务 {SelectedTask.taskCode} 获取空闲提升机失败:无空闲设备");
- return;
+ await System.Threading.Tasks.Task.Delay(1000);
}
StatusText = $"已获取空闲提升机 {freeRes.deviceName ?? freeRes.deviceCode},正在下发任务...";
@@ -332,7 +335,28 @@ public partial class ManualTaskViewModel : ObservableObject
});
_logger.Info($"手动任务 {SelectedTask.taskCode} 物料到位确认,已调用 receive-pallet");
- StatusText = $"任务 {SelectedTask.taskCode} 物料已到位,提升机已启动";
+ StatusText = $"任务 {SelectedTask.taskCode} 物料已到位,提升机已启动,等待完成...";
+
+ // 轮询等待提升机任务完成
+ var waitUrl = $"{HoistBaseUrl}/api/hoist/wait-complete?hostCode={Uri.EscapeDataString(execHostCode)}&deviceSerialNo={execSerialNo}";
+ while (true)
+ {
+ var waitRes = await _http.GetFromJsonAsync(waitUrl);
+
+ if (waitRes is { isComplete: true })
+ break;
+
+ await System.Threading.Tasks.Task.Delay(2000);
+ }
+
+ await System.Threading.Tasks.Task.Run(() =>
+ {
+ hoistDetail.taskStatus = 3;
+ _taskDetailService.Update(hoistDetail);
+ });
+
+ StatusText = $"任务 {SelectedTask.taskCode} 提升机已完成";
+ _logger.Info($"手动任务 {SelectedTask.taskCode} 提升机任务完成");
LoadDetails(SelectedTask.taskCode);
}
catch (HttpRequestException ex)
@@ -362,6 +386,11 @@ public partial class ManualTaskViewModel : ObservableObject
public int deviceSerialNo { get; set; }
}
+ private class WaitCompleteResponse
+ {
+ public bool isComplete { get; set; }
+ }
+
private class ApiResult
{
public bool success { get; set; }
diff --git a/TODO.md b/TODO.md
index 7f85de4..c3f7b37 100644
--- a/TODO.md
+++ b/TODO.md
@@ -2,31 +2,39 @@
## 已完成
-- [x] HoistServer (端口 5100) — 提升机调度 API,Swagger `/swagger`,4 个 Hub 方法
-- [x] HikRoBotServer (端口 5200) — AGV 调度 API,Swagger `/swagger`
-- [x] UI 引擎启停控制 — 系统监控页分别启动/停止两个服务器进程
-- [x] 码垛机 HMI 页面 — 静态界面,手动操作区带开关按钮
-- [x] 删除确认弹窗 — 所有删除操作弹出 ConfirmDialog
-- [x] 浅色/深色主题 — 一键切换,全视图适配
+- [✅] HoistServer (端口 5100) — 提升机调度 API,Swagger `/swagger`,4 个 Hub 方法
+- [✅] HikRoBotServer (端口 5200) — AGV 调度 API,Swagger `/swagger`
+- [✅] UI 引擎启停控制 — 系统监控页分别启动/停止两个服务器进程
+- [✅] 码垛机 HMI 页面 — 静态界面,手动操作区带开关按钮
+- [✅] 删除确认弹窗 — 所有删除操作弹出 ConfirmDialog
+- [✅] 浅色/深色主题 — 一键切换,全视图适配
## 待完成
### 1.调度逻辑修改
-- [ ] 任务明细与操作设备关联:提升机-获取到空闲设备后将确定分配任务的提升机编号存入
-
-- [ ] HoistDispatcher、HikRoBotDispatcher任务下发后需要监控任务执行情况,任务执行完成后,才可以返回ok给Strategy
-- [ ] AGV 任务下发后开始获取任务状态(调用/task/status),只有状态为FINISHED/MANUALED完成或手动完成时才可以进行后序任务,CANCELLED时终端调度任务后序也不执行
- 等待步骤执行WAIT => 筛选提升机、下发提升机任务“参考手动任务执行界面的任务执行事件逻辑”
+- [✅] 任务明细与操作设备关联:提升机-获取到空闲设备后将确定分配任务的提升机编号存入
+- [✅] HoistDispatcher、HikRoBotDispatcher任务下发后需要监控任务执行情况,任务执行完成后,才可以返回ok给Strategy
+- [✅] AGV 任务下发后开始获取任务状态(调用/task/status),只有状态为FINISHED/MANUALED完成或手动完成时才可以进行后序任务,CANCELLED时终端调度任务后序也不执行
+ WAIT => 等待步骤执行:筛选提升机、下发提升机任务“参考手动任务执行界面的任务执行事件逻辑”
FINISHED/MANUALED => 完成/手动完成,可以进行后序任务
CANCELLED => 任务取消,终止调度任务
-- [ ] Strategy执行到提升机任务时调用提升机调度中心的接收物料接口“receive-pallet”,验证物料并下发提升机启动信号
-
-
+- [] Strategy执行到提升机任务时调用提升机调度中心的接收物料接口“receive-pallet”,验证物料并下发提升机启动信号
+- [] WCS 任务起始点转换为AGV 定位码:提升机接驳位(根据楼层+提升机编号判断)、AGV 等待点(楼层/15 栋判断出入库提升机)
### 任务创建修改
-- [ ] 添加手动放、手动取操作,如果是手动放,任务从提升机开始,手动取也是以提升机结束
-- [ ] 手动放:任务执行则下发提升机任务
+- [✅] 添加手动放、手动取操作,如果是手动放,任务从提升机开始,手动取也是以提升机结束
+- [✅] 手动放:任务执行则下发提升机任务
+
+### MaterialInStoreExecutor 优化
+- [ ] ExecuteAsync + Thread.Join 阻塞主循环(`:92-114`)—— 改为 Task.Run + WhenAll,独立轮询不互相阻塞
+- [ ] AGV 状态轮询加超时保护(`:200`)—— 避免 AGV 无终态时线程永久卡死
+- [ ] 空闲提升机轮询加超时保护(`:248`)—— 避免 PLC 离线时永久卡死
+- [ ] 等待提升机完成轮询加超时保护(`:393`)—— 同上
+- [ ] DbWriteLock 全局锁优化(`:24`)—— 改为独立 Scope/Context 避免并发写阻塞
+- [ ] new Thread + GetAwaiter 改 Task.Run(`:92-111`)—— 减少线程浪费
+- [ ] HttpClient 复用(`:173,362,392`)—— 使用 IHttpClientFactory 或静态实例
+- [ ] DispatchConveyorAsync 补充输送线逻辑(`:419-422`)
### 2. 任务执行引擎(分组并行方案)
- [ ] 包材入库逻辑测试
diff --git a/docs/MaterialInStoreExecutor-优化记录.md b/docs/MaterialInStoreExecutor-优化记录.md
new file mode 100644
index 0000000..41c354c
--- /dev/null
+++ b/docs/MaterialInStoreExecutor-优化记录.md
@@ -0,0 +1,103 @@
+# MaterialInStoreExecutor 优化记录
+
+## 调度流程
+
+```
+RunLoopAsync (每 5 秒轮询 DB)
+ ExecuteAsync
+ 查 DB: taskType=1, taskCategory=1, taskStatus=1 的待执行任务
+ 每个任务启动一个 Thread(限 10 并发,TaskSemaphore)
+ ProcessOneAsync
+ 任务状态 → 2
+ 按 objId 串行遍历明细:
+ ├─ AGV → DispatchAgvAsync
+ │ 1. POST /task/receive 下发
+ │ 2. 轮询 GET /task/status 每 2s
+ │ FINISHED/MANUALED → 完成
+ │ CANCELLED → 失败中断
+ │ WAIT → 下一明细是提升机?
+ │ → 轮询 GET /hoist/free 空闲提升机(每 1s)
+ │ → POST /task/dispatch 下发提升机
+ │ → POST /task/continue 通知 AGV 继续
+ ├─ 提升机 → DispatchHoistAsync
+ │ 获取楼栋信号量(限 2 并发)
+ │ → POST /hoist/receive-pallet 启动
+ │ → 轮询 GET /hoist/wait-complete(每 2s)
+ │ → 完成
+ └─ 输送线 → DispatchConveyorAsync(目前直接返回 true)
+ 成功 → detail.taskStatus = 3,继续下一条
+ 失败 → detail.taskStatus = 1,中断整个任务
+ Thread.Join() — 等全部线程结束后才进入下一轮 DB 轮询
+```
+
+## 待优化项
+
+### 1. [高] ExecuteAsync + Thread.Join 阻塞主循环 (`:92-114`)
+
+**现象**:所有任务线程全部 `Join` 完成后才进入下一次 5 秒轮询。如果一个任务执行 10 分钟,期间新入库的任务完全不被调度。
+
+**建议**:去掉 `Thread.Join`,`RunLoopAsync` 独立轮询,任务用 `Task.Run` 异步执行,互不阻塞。
+
+---
+
+### 2. [高] AGV 状态轮询无超时保护 (`:200`)
+
+**现象**:`while (true)` 无限循环,AGV 永远不返回终态(FINISHED/MANUALED/CANCELLED)时线程永久卡死。
+
+**建议**:加超时时间或最大重试次数。
+
+---
+
+### 3. [高] 空闲提升机轮询无超时 (`:248`)
+
+**现象**:PLC 离线或提升机全忙时永远卡住。
+
+**建议**:加超时,超时后返回失败。
+
+---
+
+### 4. [高] 等待提升机完成轮询无超时 (`:393`)
+
+**现象**:同上。
+
+**建议**:加超时。
+
+---
+
+### 5. [中] DbWriteLock 全局瓶颈 (`:24`)
+
+**现象**:所有 DB 写操作共享同一把锁,10 个并发任务写状态时互相排队。
+
+**建议**:改为每次 DB 操作用独立 Scope/Context,或使用 SqlSugar 的异步方法避免上下文冲突。
+
+---
+
+### 6. [中] new Thread + .GetAwaiter().GetResult() 浪费线程 (`:92-111`)
+
+**现象**:每个任务消耗一个专用线程,内部全是 async/await,线程大部分时间在空等。绕过 `CancellationToken` 的异步传播。
+
+**建议**:改为 `Task.Run` + `await Task.WhenAll`。
+
+---
+
+### 7. [低] HttpClient 重复创建 (`:173, 362, 392`)
+
+**现象**:每次 HTTP 调用 `new HttpClient`,频繁创建可能导致 socket 耗尽。
+
+**建议**:使用 `IHttpClientFactory` 或类级别静态 `HttpClient` 复用。
+
+---
+
+### 8. [低] DispatchConveyorAsync 空操作 (`:419-422`)
+
+**现象**:输送线直接返回 `true`,逻辑未实现。
+
+**建议**:待后续补充输送线调度逻辑。
+
+---
+
+## 优化记录
+
+| 日期 | 内容 |
+|------|------|
+| 2026-06-23 | 初版记录,待测试后优化 |
diff --git a/任务执行调度-当前实现记录.md b/任务执行调度-当前实现记录.md
deleted file mode 100644
index 758c43c..0000000
--- a/任务执行调度-当前实现记录.md
+++ /dev/null
@@ -1,228 +0,0 @@
-# 任务执行调度 — 当前实现记录
-
-> 记录时间:2026-06-13
-> 状态:包材入库 (category=1, type=1) 任务驱动并行执行已跑通
-
----
-
-## 一、整体架构
-
-```
-UI (SystemMonitorView)
- ├─ [▶ 启动] / [■ 停止] 包材入库调度
- └─ SystemMonitorViewModel
- └─ MaterialInStoreExecutor (Singleton)
-
-Sln.Wcs.Strategy / MaterialInStoreExecutor
- ├─ 轮询待执行任务 (每 5 秒)
- ├─ 多任务并行 (Thread,最多 10 个)
- ├─ 单任务内串行 (逐条 detail)
- └─ 设备下发 (AGV/提升机/输送线)
-
-Sln.Wcs.Business / TaskCreateService
- └─ 拓扑路由 → 自动生成 LiveTaskQueue + List
-```
-
-## 二、任务创建
-
-### 输入
-
-| 参数 | 说明 | 示例 |
-|------|------|------|
-| startBuilding | 起始楼栋 | 13 |
-| startFloor | 起始楼层 | 1 |
-| startLocation | 起始位置号 | 101 |
-| endBuilding | 目标楼栋 | 15 |
-| endFloor | 目标楼层 | 3 |
-| endLocation | 目标位置号 | 102 |
-| taskType | 1入库 / 2出库 | 1 |
-| taskCategory | 1包材 / 2成品 / 3托盘 | 1 |
-| palletBarcode | 托盘条码 | PALLET001 |
-| materialCode | 物料编码 | M001 |
-
-### 输出
-
-```
-LiveTaskQueue
- ├─ taskCode: 自动生成 (yyyyMMddHHmmss + 随机数)
- ├─ taskType, taskCategory
- ├─ startPoint, endPoint: 自动拼接为 {building}#_L{floor}_{location}
- ├─ taskStatus: 1 (待执行)
- ├─ taskSteps: 明细数量
- └─ taskDetails: List
-```
-
-### 拓扑规则
-
-- 所有楼栋 13# / 14# / 15# 均为 1~5F
-- 每栋楼有 1 台提升机,可直达任意楼层(全连接)
-- 跨栋仅 2F 连通:
- - (13,2) ↔ (14,2):廊桥交接,两段 AGV(13# AGV → BRIDGE_13_14,14# AGV 从 BRIDGE_13_14 接走)
- - (14,2) ↔ (15,2):14# AGV 可驶入 15#,不生成过渡段
-
-### 示例:13栋1楼101 → 15栋3楼102
-
-```
-路由节点: (13,1) → (13,2) → (14,2) → (15,2) → (15,3)
-
-Detail 1: AGV 13#_L1_101 → 13#_L1_HOIST
-Detail 2: 提升机 13#_L1_HOIST → 13#_L2_HOIST
-Detail 3: AGV 13#_L2_HOIST → BRIDGE_13_14 (13# AGV)
-Detail 4: AGV BRIDGE_13_14 → 15#_L2_HOIST (14# AGV 直达)
-Detail 5: 提升机 15#_L2_HOIST → 15#_L3_HOIST
-Detail 6: AGV 15#_L3_HOIST → 15#_L3_102
-```
-
-## 三、任务执行
-
-### 执行器:MaterialInStoreExecutor
-
-**位置**:`Sln.Wcs.Strategy/MaterialInStoreExecutor.cs`
-
-**查询条件**:
-```csharp
-taskType == 1 && taskCategory == 1 && taskStatus == 1
-```
-
-**轮询间隔**:5 秒
-
-### 主循环
-
-```
-Start()
- └─ Task.Run → RunLoopAsync
- └─ while (!cancelled):
- ExecuteAsync() ← 阻塞等待所有任务线程完成
- await Task.Delay(5000)
-```
-
-### 并行策略
-
-```
-ExecuteAsync:
- 查询所有待执行任务
- → foreach task → new Thread:
- TaskSemaphore.Wait() ← 最多 10 线程
- ProcessOneAsync(task).GetAwaiter().GetResult()
- TaskSemaphore.Release()
- → Thread.Join() 等待全部完成
-```
-
-### 单任务处理
-
-```
-ProcessOneAsync(task):
- 🔒 lock → taskStatus = 2, Update(task)
-
- foreach detail (按 objId 排序):
- if detail 已完成 → continue
-
- 🔒 lock → detailStatus = 2, Update(detail)
- 🔓
- 下发设备 (根据 deviceType):
- 1 → DispatchAgvAsync (10s 模拟延迟)
- 2 → DispatchHoistAsync (20s 模拟延迟,按楼栋限 2 并发)
- 0 → DispatchConveyorAsync
- 🔒 lock → detailStatus = 3, Update(detail)
- 🔓
-
- 🔒 lock → taskStatus = 3, Update(task)
-```
-
-### 并发控制
-
-| 控制点 | 机制 | 值 |
-|--------|------|-----|
-| 任务级并发 | `SemaphoreSlim` | 最多 10 个 |
-| 提升机并发 | `ConcurrentDictionary` | 每栋楼 2 台 |
-| AGV 并发 | 不限 | — |
-| DB 写入 | `lock (DbWriteLock)` 串行化 | — |
-| 启停状态 | `lock (_lock)` + `volatile bool` | — |
-
-### 线程安全
-
-- **启停**:`lock (_lock)` 保护 `_cts` / `_isRunning`
-- **DB 写入**:`lock (DbWriteLock)` 保护 SqlSugar Update(Context 为单例,多线程写入冲突)
-- **提升机信号量**:`ConcurrentDictionary` 线程安全
-- **任务信号量**:`SemaphoreSlim` 本身线程安全
-- **延迟模拟**:`await Task.Delay` 在锁外,多任务并行不受影响
-
-## 四、仓储线程安全
-
-**位置**:`Sln.Wcs.Repository/Repository.cs`
-
-```csharp
-public Repository(ISqlSugarClient db)
-{
- itenant = db.AsTenant();
- Context = (SqlSugarClient)db; // 直接用 SqlSugarScope,内部管理连接池
-}
-```
-
-`SqlSugarScope` 注册为 Singleton,通过 `[Tenant]` 特性自动路由数据库。
-
-## 五、设备下发(当前为模拟)
-
-| 设备 | 方法 | 延迟 | 并发限制 |
-|------|------|------|---------|
-| AGV | `DispatchAgvAsync` | 10 秒 | 无 |
-| 提升机 | `DispatchHoistAsync` | 20 秒 | 每栋楼 2 台 |
-| 输送线 | `DispatchConveyorAsync` | 无 | 无 |
-
-后续对接:AGV → `HikRoBotDispatchHub.ReciveTask`,提升机 → `HoistDispatchHub.TaskDispatch`
-
-## 六、UI 控制
-
-**系统监控页面** — 顶部卡片:
-
-```
-┌──────────────────────────────────────────────┐
-│ ● 包材入库调度 运行中 [▶ 启动] [■ 停止] │
-└──────────────────────────────────────────────┘
-```
-
-按钮绑定:
-- 启动 → `MaterialInStoreExecutor.Start()`
-- 停止 → `MaterialInStoreExecutor.Stop()`
-- 状态指示灯:绿色 = 运行中,红色 = 已停止
-
-## 七、DI 注册
-
-**App.axaml.cs**:
-
-```csharp
-// Assembly 扫描
-Assembly.LoadFrom("Sln.Wcs.Strategy.dll")
-
-// Singleton 注册
-services.AddSingleton();
-```
-
-**依赖关系**:
-```
-MaterialInStoreExecutor (Singleton)
- ├─ SerilogHelper (Singleton)
- ├─ ILiveTaskQueueService (Transient, 来自 Scrutor 扫描)
- └─ ILiveTaskDetailService (Transient, 来自 Scrutor 扫描)
-```
-
-## 八、关键文件
-
-| 文件 | 说明 |
-|------|------|
-| `Sln.Wcs.Business/TaskCreateService.cs` | 拓扑路由 + 任务创建 |
-| `Sln.Wcs.Strategy/MaterialInStoreExecutor.cs` | 包材入库执行器 |
-| `Sln.Wcs.Repository/Repository.cs` | 仓储基类(线程安全改造) |
-| `Sln.Wcs.UI/ViewModels/SystemMonitorViewModel.cs` | 系统监控 VM(启停控制) |
-| `Sln.Wcs.UI/Views/SystemMonitorView.axaml` | 系统监控页面 |
-| `Sln.Wcs.UI/App.axaml.cs` | DI 注册 |
-| `接驳位驱动调度方案.md` | 调度方案设计文档 |
-
-## 九、待优化项
-
-1. **真实设备对接**:替换 `DispatchAgvAsync` / `DispatchHoistAsync` 中的模拟延迟为实际 API 调用
-2. **扩展其他类型**:成品入库/出库、托盘入库/出库等(参考 MaterialInStoreExecutor 模板)
-3. **DB 写入优化**:去掉 `lock (DbWriteLock)`,从根本上解决 SqlSugar 多线程写入问题(可能需要连接串配置 `Max Pool Size` 或改造仓储层)
-4. **任务优先级**:当前 FIFO,可按优先级排序
-5. **异常恢复**:任务中途停止后重新启动的状态恢复
-6. **手动触发**:UI 上支持手动输入接驳位 + RFID 触发单条明细执行
diff --git a/任务驱动的任务调度方案.md b/任务驱动的任务调度方案.md
deleted file mode 100644
index 7079192..0000000
--- a/任务驱动的任务调度方案.md
+++ /dev/null
@@ -1,170 +0,0 @@
-# 任务驱动的任务调度方案
-
-## 一、核心思路
-
-```
-不是一次性下发全部步骤,也不是等待接驳位信号
-
-任务创建后 (status=1)
- → 执行器轮询到 → 启动线程
- → 单任务内逐条串行下发明细
- → 前一条完成 → 自动执行下一条
- → 全部完成 → 任务结束
-
-多任务之间并行执行,互不干扰
-```
-
-## 二、执行流程
-
-```
-Start() 启动后台轮询
- │
- └─ RunLoopAsync (每 5 秒一轮)
- │
- └─ ExecuteAsync
- │
- ├─ 查询 taskType=1, taskCategory=1, taskStatus=1
- │
- ├─ foreach task → new Thread:
- │ │
- │ ├─ TaskSemaphore.Wait() ← 最多 10 线程
- │ │
- │ └─ ProcessOneAsync(task):
- │ │
- │ ├─ taskStatus → 2
- │ │
- │ ├─ foreach detail (按 objId):
- │ │ │
- │ │ ├─ detailStatus → 2
- │ │ ├─ 下发设备
- │ │ │ 1 → AGV (不限并发)
- │ │ │ 2 → 提升机 (每栋限 2)
- │ │ │ 0 → 输送线
- │ │ ├─ 等待执行完成
- │ │ └─ detailStatus → 3
- │ │
- │ └─ taskStatus → 3
- │
- └─ Thread.Join() 等本轮全部完成 → 5 秒后下一轮
-```
-
-## 三、并发模型
-
-```
-任务 A: Detail 1 → Detail 2 → Detail 3 → ...
-任务 B: Detail 1 → Detail 2 → Detail 3 → ... 三个任务同时跑
-任务 C: Detail 1 → Detail 2 → Detail 3 → ...
-
-单任务内: 串行 (前一条完成才执行下一条)
-多任务间: 并行 (各自独立线程)
-```
-
-### 限制
-
-| 控制项 | 机制 | 值 |
-|--------|------|-----|
-| 最大并行任务数 | `SemaphoreSlim` | 10 |
-| 提升机并行/栋 | `ConcurrentDictionary` | 2 |
-| AGV 并行 | 无限制 | — |
-| DB 写入 | `lock (DbWriteLock)` | 串行 |
-
-## 四、单任务处理详情
-
-```
-ProcessOneAsync(task):
-
- Step 1 ─ 标记任务执行中
- lock (DbWriteLock):
- task.taskStatus = 2
- Update(task)
-
- Step 2 ─ 逐条处理明细 (按 objId 排序)
- for each detail:
-
- 跳过已完成 (status 2 or 3)
-
- lock (DbWriteLock):
- detail.taskStatus = 2
- Update(detail)
-
- ↓ 下发设备 (锁外,可并行)
- deviceType:
- 1 → DispatchAgvAsync AGV 模拟 10s
- 2 → DispatchHoistAsync 提升机模拟 20s
- 0 → DispatchConveyorAsync 输送线
-
- 成功:
- lock (DbWriteLock):
- detail.taskStatus = 3
- Update(detail)
-
- 失败:
- lock (DbWriteLock):
- detail.taskStatus = 1 回退待重试
- Update(detail)
- return (中断任务)
-
- Step 3 ─ 标记任务完成
- lock (DbWriteLock):
- task.taskStatus = 3
- Update(task)
-```
-
-## 五、设备下发
-
-| 设备类型 | deviceType | 方法 | 模拟延迟 | 并发控制 |
-|---------|-----------|------|---------|---------|
-| AGV | 1 | `DispatchAgvAsync` | 10s | 无限制 |
-| 提升机 | 2 | `DispatchHoistAsync` | 20s | 每栋 2 台 |
-| 输送线 | 0 | `DispatchConveyorAsync` | 即时 | — |
-
-### 提升机并发控制
-
-```csharp
-// 从 startPoint 提取楼栋号: "14#_L2_HOIST" → 14
-var building = ExtractBuilding(detail.startPoint);
-
-// 获取该楼栋信号量,首次自动创建 SemaphoreSlim(2, 2)
-var semaphore = HoistSemaphores.GetOrAdd(building, _ => new SemaphoreSlim(2, 2));
-
-await semaphore.WaitAsync(); // 等待有空闲提升机
-// ... 执行 ...
-semaphore.Release(); // 释放
-```
-
-## 六、执行示例
-
-3 个包材入库任务,每个 6 条明细:
-
-```
-T+0s [线程1] 任务A Detail1 AGV开始 [线程2] 任务B Detail1 AGV开始 [线程3] 任务C Detail1 AGV开始
-T+10s [线程1] 任务A Detail1 完成 [线程2] 任务B Detail1 完成 [线程3] 任务C Detail1 完成
-T+10s [线程1] 任务A Detail2 提升机开始 [线程2] 任务B Detail2 提升机开始 [线程3] 等待(13#提升机满)
-T+30s [线程1] 任务A Detail2 完成 [线程2] 任务B Detail2 完成 [线程3] 任务C Detail2 提升机开始
-T+30s [线程1] 任务A Detail3 AGV开始 [线程2] 任务B Detail3 AGV开始
-...并行继续...
-```
-
-## 七、状态流转
-
-```
-LiveTaskQueue.taskStatus:
- 1 (待执行) → 2 (执行中) → 3 (已完成)
-
-LiveTaskDetail.taskStatus:
- 1 (待执行) → 2 (执行中) → 3 (已完成)
- ↘ 失败回退 → 1 (待重试)
-```
-
-## 八、UI 控制
-
-系统监控页面顶部卡片,点击启动/停止:
-
-```
-┌──────────────────────────────────────────────────┐
-│ ● 包材入库调度 运行中/已停止 [▶ 启动] [■ 停止] │
-└──────────────────────────────────────────────────┘
-```
-
-- `Start()` → 创建 `CancellationTokenSource`,启动 `RunLoopAsync`
-- `Stop()` → 取消 token,等待当前线程结束后标记停止
diff --git a/接驳位驱动调度方案.md b/接驳位驱动调度方案.md
deleted file mode 100644
index 8baa787..0000000
--- a/接驳位驱动调度方案.md
+++ /dev/null
@@ -1,390 +0,0 @@
-# 接驳位驱动的任务调度方案
-
-## 一、核心概念
-
-| 概念 | 物理含义 | 数据对应 |
-|------|---------|---------|
-| 接驳位 | 货物放置/取走的物理位置 | `LiveTaskDetail.startPoint / endPoint`,设备参数中 `接驳位状态`(0空闲/1占用) |
-| RFID 条码 | 托盘上的电子标签 | `LiveTaskDetail.palletBarcode` |
-| 任务 | 一个完整的搬运工单 | `LiveTaskQueue` |
-| 子任务步骤 | 任务中的一段搬运步骤 | `LiveTaskDetail`(`objId` 决定先后顺序) |
-
-### 任务与子任务的关系
-
-一个 `LiveTaskQueue` 包含多个 `LiveTaskDetail`,按 `objId` 从小到大串行执行。前一步的 `endPoint` 等于下一步的 `startPoint`,通过接驳位串联:
-
-```
-LiveTaskQueue (任务, taskCode = "T001")
- │
- ├─ LiveTaskDetail objId=1: AGV 13#_L1_01 → BRIDGE_13_14 (子任务步骤1)
- ├─ LiveTaskDetail objId=2: AGV BRIDGE_13_14 → 14#_L1_HOIST (子任务步骤2)
- ├─ LiveTaskDetail objId=3: 提升机 14#_L1_HOIST → 14#_L2_HOIST (子任务步骤3)
- ├─ LiveTaskDetail objId=4: AGV 14#_L2_HOIST → 15#_L2_HOIST (子任务步骤4)
- ├─ LiveTaskDetail objId=5: 提升机 15#_L2_HOIST → 15#_L4_HOIST (子任务步骤5)
- └─ LiveTaskDetail objId=6: AGV 15#_L4_HOIST → 15#_L4_03 (子任务步骤6)
-```
-
-### 数据库关键字段
-
-- **LiveTaskQueue**: `taskCode`, `taskType`(1入库/2出库), `taskCategory`(1包材/2成品/3托盘), `taskStatus`(1待执行/2执行中/3已完成), `startPoint`, `endPoint`
-- **LiveTaskDetail**: `taskCode`, `objId`, `palletBarcode`, `startPoint`, `endPoint`, `deviceType`(0输送线/1AGV/2提升机), `taskStatus`
-- **BaseDeviceParam**: `deviceCode`, `paramName`(如"接驳位状态"), `paramValue`(0空闲/1占用)
-
----
-
-## 二、现场拓扑
-
-### 楼栋与设备
-
-```
- 廊桥(2F)
- ┌───────────┐
- │ AGV交接 │
- └─────┬─────┘
- │
- 13#(1F) ──13#提升机── 13#(2F)─┘ 14#(2F) ──AGV── 15#(2F) ──15#提升机── 15#(1F~5F)
- (1F↔2F) │
- 14#提升机(1F↔2F)
- │
- 14#(1F)
-```
-
-### AGV 运行区域
-
-| 区域 | 行驶范围 | 说明 |
-|------|---------|------|
-| 13# AGV | 13# 栋 1F~2F | 不能驶入 14# |
-| 14# AGV | 14# 栋 1F~2F + 至 15# 2F | 负责中间段搬运 |
-| 15# AGV | 仅在 15# 栋内 | 不能驶入 14# |
-
-### 核心约束
-
-- **AGV 不能跨栋**:13# 和 14# 之间在 2F 有廊桥交接点,两边 AGV 在此完成货物交换
-- **所有楼栋均为 1~5F**,各栋有 1 台提升机连通所有楼层
-- **跨栋必须经 2F**:不管起点在几楼,跨栋必须经 2F 的廊桥(13↔14)或 AGV 通道(14↔15)
-
-### 接驳位点表
-
-| 接驳位 | 编码 | 所属 AGV |
-|--------|------|---------|
-| 13# 各层普通点 | `13#_L{f}_{n}` | 13# AGV |
-| 13# 提升机入口/出口 | `13#_L{f}_HOIST` | 13# AGV |
-| 廊桥交接点(2F) | `BRIDGE_13_14` | 13# AGV ↔ 14# AGV 交接 |
-| 14# 各层普通点 | `14#_L{f}_{n}` | 14# AGV |
-| 14# 提升机入口/出口 | `14#_L{f}_HOIST` | 14# AGV |
-| 15# 提升机 2F 入口 | `15#_L2_HOIST` | 14# AGV ↔ 15# AGV |
-| 15# 提升机各层出口 | `15#_L{f}_HOIST` | 15# AGV |
-| 15# 各层普通点 | `15#_L{f}_{n}` | 15# AGV |
-
-### 拓扑连通图
-
-```
-节点: (building, floor)
-
-边 (提升机, 同栋跨层):
- (13,1) ←→ (13,2)
- (14,1) ←→ (14,2)
- (15,2) ←→ (15,1)
- (15,2) ←→ (15,3)
- (15,2) ←→ (15,4)
- (15,2) ←→ (15,5)
-
-边 (AGV, 同层跨栋):
- (13,2) ←→ (14,2) (廊桥交接)
- (14,2) ←→ (15,2) (AGV直连)
-```
-
----
-
-## 三、任务创建方案 —— 路径分解与明细生成
-
-### 输入
-
-```
-起始楼栋: startBuilding (13/14/15)
-起始楼层: startFloor (1~5)
-起始位置: startLocation (位置号,如 "01")
-结束楼栋: endBuilding (13/14/15)
-结束楼层: endFloor (1~5)
-结束位置: endLocation (位置号,如 "02")
-```
-
-系统自动拼接为 `{building}#_L{floor}_{location}` 格式。
-
-### 输出
-
-返回一个完整的 `LiveTaskQueue`,包含按 `objId` 排序的 `List`。
-
-### 生成算法
-
-```
-输入: (b_s, f_s, loc_s), (b_e, f_e, loc_e)
-
-Step 1 — BFS 最短路径
- 在拓扑图中找 (b_s, f_s) → ... → (b_e, f_e) 的节点序列
-
-Step 2 — 沿节点路径逐段生成明细
- currentPos = 实际起点
-
- for each 相邻节点 (b1,f1) → (b2,f2):
-
- 跨栋 (b1 != b2):
- if 13↔14 (廊桥交接):
- 生成 [AGV] currentPos → BRIDGE_13_14
- 生成 [AGV] BRIDGE_13_14 → 接驳位(b2,f2)
- 注: 廊桥在 2F,两边都须先经提升机到达 2F
- else (14↔15, AGV直连):
- 生成 [AGV] currentPos → 接驳位(b2,f2)
- currentPos = 接驳位(b2,f2)
-
- 同栋跨层 (b1 == b2, f1 != f2):
- 生成 [AGV] currentPos → 提升机入口(b,f1)
- 生成 [提升机] 提升机入口(b,f1) → 提升机出口(b,f2)
- currentPos = 提升机出口(b,f2)
-
-Step 3 — 最终送达
- if currentPos != 实际终点:
- 生成 [AGV] currentPos → 实际终点
-```
-
- ┌─ 同栋同层 (b1 == b2 && f1 == f2) ──────────────┐
- │
- │ 跳过(不会出现在最短路径中)
- │
- └────────────────────────────────────────────────┘
-
-Step 3 — 替换首尾实际坐标
-
- 首条 Detail.startPoint → pt_s
- 尾条 Detail.endPoint → pt_e
- 中间节点起点/终点 → 使用对应的接驳位编码
-```
-
-### 各场景示例(修正后——廊桥在 2F)
-
-| 场景 | 输入 | 生成 Detail 序列 |
-|------|------|-----------------|
-| **同栋同层** | 14,1,"01" → 14,1,"05" | `[1] AGV: 14#_L1_01 → 14#_L1_05` |
-| **同栋跨层** | 14,1,"01" → 14,2,"03" | `[1] AGV: 14#_L1_01 → 14#_L1_HOIST` `[2] 提升机: 14#_L1_HOIST → 14#_L2_HOIST` `[3] AGV: 14#_L2_HOIST → 14#_L2_03` |
-| **13→14 (均为1F)** | 13,1,"01" → 14,1,"03" | `[1] AGV: 13#_L1_01 → 13#_L1_HOIST` `[2] 提升机: 13#_L1_HOIST → 13#_L2_HOIST` `[3] AGV: 13#_L2_HOIST → BRIDGE_13_14` `[4] AGV: BRIDGE_13_14 → 14#_L2_01` `[5] AGV: 14#_L2_01 → 14#_L2_HOIST` `[6] 提升机: 14#_L2_HOIST → 14#_L1_HOIST` `[7] AGV: 14#_L1_HOIST → 14#_L1_03` |
-| **13→14 (目标2F)** | 13,1,"01" → 14,2,"05" | `[1] AGV: 13#_L1_01 → 13#_L1_HOIST` `[2] 提升机: 13#_L1_HOIST → 13#_L2_HOIST` `[3] AGV: 13#_L2_HOIST → BRIDGE_13_14` `[4] AGV: BRIDGE_13_14 → 14#_L2_01` `[5] AGV: 14#_L2_01 → 14#_L2_05` |
-| **14→15** | 14,1,"01" → 15,4,"02" | `[1] AGV: 14#_L1_01 → 14#_L1_HOIST` `[2] 提升机: 14#_L1_HOIST → 14#_L2_HOIST` `[3] AGV: 14#_L2_HOIST → 15#_L2_01` `[4] AGV: 15#_L2_01 → 15#_L2_HOIST` `[5] 提升机: 15#_L2_HOIST → 15#_L4_HOIST` `[6] AGV: 15#_L4_HOIST → 15#_L4_02` |
-| **13→15 完整链路** | 13,1,"01" → 15,4,"02" | `[1] AGV: 13#_L1_01 → 13#_L1_HOIST` `[2] 提升机: 13#_L1_HOIST → 13#_L2_HOIST` `[3] AGV: 13#_L2_HOIST → BRIDGE_13_14` `[4] AGV: BRIDGE_13_14 → 14#_L2_01` `[5] AGV: 14#_L2_01 → 15#_L2_01` `[6] AGV: 15#_L2_01 → 15#_L2_HOIST` `[7] 提升机: 15#_L2_HOIST → 15#_L4_HOIST` `[8] AGV: 15#_L4_HOIST → 15#_L4_02` |
-| **14→15 2F直连** | 14,2,"01" → 15,2,"03" | `[1] AGV: 14#_L2_01 → 15#_L2_03` |
-
-### 伪代码
-
-```
-LiveTaskQueue CreateTask(
- int b_s, int f_s, string loc_s,
- int b_e, int f_e, string loc_e,
- int taskType, int taskCategory, string palletBarcode, string materialCode)
-{
- var startPoint = $"{b_s}#_L{f_s}_{loc_s}";
- var endPoint = $"{b_e}#_L{f_e}_{loc_e}";
- var nodes = BFS(b_s, f_s, b_e, f_e); // 拓扑最短路径
- string currentPos = startPoint;
-
- for (int i = 0; i < nodes.Count - 1; i++)
- {
- var (b1, f1) = nodes[i];
- var (b2, f2) = nodes[i + 1];
-
- if (b1 != b2) // 跨栋
- {
- if (13↔14)
- {
- Add [AGV] currentPos → BRIDGE_13_14;
- Add [AGV] BRIDGE_13_14 → DockingPoint(b2,f2);
- }
- else // 14↔15
- {
- Add [AGV] currentPos → DockingPoint(b2,f2);
- }
- currentPos = DockingPoint(b2,f2);
- }
- else if (f1 != f2) // 同栋跨层
- {
- var entry = $"{b1}#_L{f1}_HOIST";
- var exit = $"{b2}#_L{f2}_HOIST";
- Add [AGV] currentPos → entry;
- Add [提升机] entry → exit;
- currentPos = exit;
- }
- }
-
- if (details.Count == 0 || currentPos != endPoint)
- Add [AGV] currentPos → endPoint;
-
- return new LiveTaskQueue { ... };
-}
-### 拓扑路由表
-
-```
-FindPath(b_s, f_s, b_e, f_e):
-
- 以 BFS 在拓扑图中搜索,典型路径:
-
- (13,1) → (13,2): [(13,1), (13,2)]
- (13,1) → (14,1): [(13,1), (13,2), (14,2), (14,1)]
- (13,1) → (14,2): [(13,1), (13,2), (14,2)]
- (13,1) → (15,2): [(13,1), (13,2), (14,2), (15,2)]
- (13,1) → (15,4): [(13,1), (13,2), (14,2), (15,2), (15,4)]
- (14,1) → (15,4): [(14,1), (14,2), (15,2), (15,4)]
- (15,4) → (13,1): [(15,4), (15,2), (14,2), (13,2), (13,1)]
- 反向: 路径取反
-```
-
----
-
-## 四、调度触发链路
-
-```
-人工放货到接驳位
- → PLC 传感器检测到货物 → 接驳位状态 = 1
- → PLC 读 RFID → 得到托盘条码
- → WCS 收到事件: (positionCode, rfidBarcode)
- → 进入匹配调度逻辑
-```
-
-## 五、匹配调度逻辑
-
-```
-输入: positionCode (接驳位编码), rfidBarcode (RFID条码)
-
-Step 1 ─ 匹配明细
- 查 LiveTaskDetail WHERE
- palletBarcode == rfidBarcode
- AND startPoint == positionCode
- AND taskStatus == 1 (待执行)
-
- 未匹配 → 告警,返回
-
- 匹配到 detail
-
-Step 2 ─ 前序依赖校验
- 查同 taskCode 下 objId < detail.objId 的所有明细
- 全部 taskStatus == 3 (已完成) ?
-
- 否 → 等待(不应发生,正常前序完成才会到这一步),返回
-
-Step 3 ─ 接驳位加锁
- 获取该接驳位的锁(信号量)
- 二次确认 detail.taskStatus 仍为 1(防并发)
-
-Step 4 ─ 标记执行中
- detail.taskStatus = 2
- 若 LiveTaskQueue.taskStatus == 1 → 也改为 2
-
-Step 5 ─ 下发设备
- detail.deviceType:
- 1 → AGV
- 2 → 提升机
- 0 → 输送线
-
-Step 6 ─ 结果处理
- 成功:
- detail.taskStatus = 3
- 释放接驳位 (status = 0)
- 检查整个 task 是否全部完成 → taskStatus = 3
-
- 失败:
- detail.taskStatus 回退为 1
- 下次重试
-```
-
-## 六、链条式执行(以 13#_1F → 15#_4F 为例)
-
-```
-Task: P001, 13#_L1_01 → 15#_L4_02
- 路径节点: (13,1) → (13,2) → (14,2) → (15,2) → (15,4)
-
- Detail 1: AGV 13#_L1_01 → 13#_L1_HOIST
- Detail 2: 提升机 13#_L1_HOIST → 13#_L2_HOIST
- Detail 3: AGV 13#_L2_HOIST → BRIDGE_13_14
- Detail 4: AGV BRIDGE_13_14 → 14#_L2_01
- Detail 5: AGV 14#_L2_01 → 15#_L2_01
- Detail 6: AGV 15#_L2_01 → 15#_L2_HOIST
- Detail 7: 提升机 15#_L2_HOIST → 15#_L4_HOIST
- Detail 8: AGV 15#_L4_HOIST → 15#_L4_02
-
-════════════════════ 时间线 ════════════════════
-
- T0: 人工放货到 13#_L1_01,读 RFID
- → 匹配 Detail 1,无前序 ✓ → 13# AGV 取走
- → 13#_L1_01 释放
-
- T1: 13# AGV 运到 13#_L1_HOIST,放下
- → 匹配 Detail 2,前序 1 ✓
- → 13# 提升机启动,13#_L1_HOIST 释放
-
- T2: 提升机运到 2F,到达 13#_L2_HOIST
- → 匹配 Detail 3,前序 1,2 ✓
- → 13# AGV 取走,运往廊桥
-
- T3: 13# AGV 到达 BRIDGE_13_14 (2F廊桥)
- → 匹配 Detail 4,前序 1~3 ✓
- → 14# AGV 取走,BRIDGE_13_14 释放
-
- T4: 14# AGV 运到 14#_L2_01,放下
- → 匹配 Detail 5,前序 1~4 ✓
- → 14# AGV 运往 15# (同层2F,直连)
-
- T5: 14# AGV 到达 15#_L2_01,放下
- → 匹配 Detail 6,前序 1~5 ✓
- → 15# AGV 转运到提升机入口
-
- T6: 15# AGV 到达 15#_L2_HOIST,放下
- → 匹配 Detail 7,前序 1~6 ✓
- → 15# 提升机启动
-
- T7: 提升机运到 4F,到达 15#_L4_HOIST
- → 匹配 Detail 8,前序 1~7 ✓
- → 15# AGV 取走,运到 15#_L4_02
-
- 任务 P001 全部完成 ✓
-```
-
-## 七、并发模型
-
-```
-接驳位 A ─┐
-接驳位 B ─┤ 各自独立并行(不同锁保护)
-接驳位 C ─┘
-
-同一接驳位内 → 串行(同一把锁保护)
-```
-
-不同任务在不同接驳位上可以同时执行,互不干扰。
-
-## 八、异常场景
-
-| 场景 | 处理 |
-|------|------|
-| RFID + 位置匹配不到待执行明细 | 货物放错位置或任务未创建 → 告警 |
-| 前序明细未完成但货物已到 | 等待 |
-| 设备下发失败 | detail 状态回退为 1,下次循环重试 |
-| 同一明细被并发处理 | Step 3 二次确认防御 |
-| 接驳位长时间占用不释放 | 需超时告警机制(后续) |
-| 全部明细完成 | task 标记为已完成(status = 3) |
-
-## 九、依赖设备信息
-
-### 设备类型 (deviceType)
-
-| 值 | 类型 | 对应服务 |
-|----|------|---------|
-| 0 | 输送线 | PLC 直接控制 |
-| 1 | AGV | HikRoBotServer (端口 5200) |
-| 2 | 提升机 | HoistServer (端口 5100) |
-
-### 客户端 API
-
-**HoistServer** (端口 5100):
-- `/api/hoist/receive-pallet` - 接收托盘
-- `/api/hoist/task-run` - 提升机启动
-- `/api/task/dispatch` - 下发调度任务
-- `/api/hoist/free` - 获取空闲提升机
-
-**HikRoBotServer** (端口 5200):
-- `/api/task/receive` - 下发 AGV 任务