新增 WMS 搬运规则与任务管理

支持搬运规则维护、候选预览、任务生成、预占、下发、取消和完成,并完善仓储管理前端交互。
This commit is contained in:
ArtoriasWu
2026-06-25 11:02:22 +08:00
parent 1cb8091bbe
commit 15405ba114
101 changed files with 4167704 additions and 152 deletions
+1 -1
View File
@@ -34,7 +34,7 @@ public static class DeploymentCatalog
public static readonly IReadOnlyDictionary<string, string[]> ModuleToPages =
new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
["wms"] = new[] { "admin-config-facility" },
["wms"] = new[] { "admin-config-facility", "admin-config-warehouse" },
};
/// <summary>所有「可被选型控制」的页面 Key(Module → 页 映射值的并集)。</summary>
+72 -1
View File
@@ -12,10 +12,14 @@ namespace MiGu.Server.Controllers;
public sealed class WmsController : ControllerBase
{
private readonly WmsService _service;
private readonly WmsTransportRuleService _transportRules;
private readonly WmsTransportTaskService _transportTasks;
public WmsController(WmsService service)
public WmsController(WmsService service, WmsTransportRuleService transportRules, WmsTransportTaskService transportTasks)
{
_service = service;
_transportRules = transportRules;
_transportTasks = transportTasks;
}
[HttpGet("areas")]
@@ -130,6 +134,53 @@ public sealed class WmsController : ControllerBase
[HttpGet("container-material-history")]
public Task<List<ContainerMaterialHistory>> ContainerMaterialHistory([FromQuery] Guid? containerId, [FromQuery] Guid? materialId) =>
_service.MaterialHistory(containerId, materialId);
[HttpGet("transport-rules")]
public Task<List<WmsTransportRule>> TransportRules([FromQuery] string? triggerType, [FromQuery] string? q) =>
_transportRules.ListAsync(triggerType, q);
[HttpPost("transport-rules")]
public async Task<IActionResult> SaveTransportRule([FromBody] TransportRuleRequest req) =>
Ok(await _transportRules.SaveAsync(req, User.ActorName()));
[HttpPut("transport-rules/{id:guid}")]
public async Task<IActionResult> UpdateTransportRule(Guid id, [FromBody] TransportRuleRequest req) =>
Ok(await _transportRules.SaveAsync(req with { Id = id }, User.ActorName()));
[HttpDelete("transport-rules/{id:guid}")]
public async Task<IActionResult> DeleteTransportRule(Guid id, [FromQuery] long? version)
{
await _transportRules.DeleteAsync(id, version, User.ActorName());
return NoContent();
}
[HttpGet("transport-candidates")]
public async Task<IActionResult> TransportCandidates([FromQuery] TransportRequestQuery q) =>
Ok(await _transportTasks.PreviewCandidatesAsync(q.ToRequest()));
[HttpPost("transport-tasks/generate")]
public async Task<IActionResult> GenerateTransportTask([FromBody] TransportRequestQuery q) =>
Ok(await _transportTasks.GenerateAsync(q.ToRequest(), User.ActorName()));
[HttpGet("transport-tasks")]
public Task<List<WmsTransportTask>> TransportTasks([FromQuery] string? status, [FromQuery] string? triggerType) =>
_transportTasks.ListAsync(status, triggerType);
[HttpPost("transport-tasks/{id:guid}/reserve")]
public async Task<IActionResult> ReserveTransportTask(Guid id, [FromQuery] string? reason) =>
Ok(await _transportTasks.ReserveAsync(id, User.ActorName(), reason ?? ""));
[HttpPost("transport-tasks/{id:guid}/cancel")]
public async Task<IActionResult> CancelTransportTask(Guid id, [FromQuery] string? reason) =>
Ok(await _transportTasks.CancelAsync(id, User.ActorName(), reason ?? ""));
[HttpPost("transport-tasks/{id:guid}/complete")]
public async Task<IActionResult> CompleteTransportTask(Guid id, [FromQuery] string? reason) =>
Ok(await _transportTasks.CompleteAsync(id, User.ActorName(), reason ?? ""));
[HttpPost("transport-tasks/{id:guid}/dispatch")]
public async Task<IActionResult> DispatchTransportTask(Guid id) =>
Ok(await _transportTasks.DispatchAsync(id, User.ActorName()));
}
public sealed class WmsExceptionFilter : IExceptionFilter
@@ -141,3 +192,23 @@ public sealed class WmsExceptionFilter : IExceptionFilter
context.ExceptionHandled = true;
}
}
public sealed class TransportRequestQuery
{
public string TriggerType { get; set; } = WmsTransportTriggerTypes.MaterialCall;
public Guid? RuleId { get; set; }
public string? RequestSiteId { get; set; }
public Guid? MaterialId { get; set; }
public decimal? Quantity { get; set; }
public Guid? ContainerId { get; set; }
public Guid? SourceStorageId { get; set; }
public Guid? TargetStorageId { get; set; }
public string? BatchNo { get; set; }
public int Priority { get; set; }
public string Reason { get; set; } = "";
public WmsTransportRequest ToRequest() => new(
TriggerType, RuleId, RequestSiteId, MaterialId, Quantity,
ContainerId, SourceStorageId, TargetStorageId, BatchNo,
Priority, Reason, null);
}
@@ -18,6 +18,10 @@ public sealed class PlatformDbContext : DbContext
public DbSet<ContainerMaterial> ContainerMaterials => Set<ContainerMaterial>();
public DbSet<ContainerLocationHistory> ContainerLocationHistories => Set<ContainerLocationHistory>();
public DbSet<ContainerMaterialHistory> ContainerMaterialHistories => Set<ContainerMaterialHistory>();
public DbSet<WmsTransportRule> WmsTransportRules => Set<WmsTransportRule>();
public DbSet<WmsTransportTask> WmsTransportTasks => Set<WmsTransportTask>();
public DbSet<WmsTransportReservation> WmsTransportReservations => Set<WmsTransportReservation>();
public DbSet<WmsTransportTaskHistory> WmsTransportTaskHistories => Set<WmsTransportTaskHistory>();
public DbSet<SimpleField> SimpleFields => Set<SimpleField>();
public DbSet<UserDashboardShortcut> UserDashboardShortcuts => Set<UserDashboardShortcut>();
@@ -29,6 +33,12 @@ public sealed class PlatformDbContext : DbContext
var nullableGuid = new ValueConverter<Guid?, string?>(
v => v.HasValue ? v.Value.ToString("D") : null,
v => string.IsNullOrWhiteSpace(v) ? null : Guid.Parse(v));
var dateTimeOffset = new ValueConverter<DateTimeOffset, string>(
v => v.UtcDateTime.ToString("O"),
v => DateTimeOffset.Parse(v));
var nullableDateTimeOffset = new ValueConverter<DateTimeOffset?, string?>(
v => v.HasValue ? v.Value.UtcDateTime.ToString("O") : null,
v => string.IsNullOrWhiteSpace(v) ? null : DateTimeOffset.Parse(v));
foreach (var entity in modelBuilder.Model.GetEntityTypes())
{
@@ -36,6 +46,10 @@ public sealed class PlatformDbContext : DbContext
modelBuilder.Entity(entity.ClrType).Property(p.Name).HasConversion(guid).HasMaxLength(36);
foreach (var p in entity.ClrType.GetProperties().Where(p => p.PropertyType == typeof(Guid?)))
modelBuilder.Entity(entity.ClrType).Property(p.Name).HasConversion(nullableGuid).HasMaxLength(36);
foreach (var p in entity.ClrType.GetProperties().Where(p => p.PropertyType == typeof(DateTimeOffset)))
modelBuilder.Entity(entity.ClrType).Property(p.Name).HasConversion(dateTimeOffset).HasMaxLength(40);
foreach (var p in entity.ClrType.GetProperties().Where(p => p.PropertyType == typeof(DateTimeOffset?)))
modelBuilder.Entity(entity.ClrType).Property(p.Name).HasConversion(nullableDateTimeOffset).HasMaxLength(40);
}
ConfigureEntityBase<WarehouseArea>(modelBuilder, "wms_areas");
@@ -44,9 +58,13 @@ public sealed class PlatformDbContext : DbContext
ConfigureEntityBase<Material>(modelBuilder, "wms_materials");
ConfigureEntityBase<ContainerLocation>(modelBuilder, "wms_container_locations");
ConfigureEntityBase<ContainerMaterial>(modelBuilder, "wms_container_materials");
ConfigureEntityBase<WmsTransportRule>(modelBuilder, "wms_transport_rules");
ConfigureEntityBase<WmsTransportTask>(modelBuilder, "wms_transport_tasks");
ConfigureEntityBase<WmsTransportReservation>(modelBuilder, "wms_transport_reservations");
ConfigureHistory<ContainerLocationHistory>(modelBuilder, "wms_container_location_history");
ConfigureHistory<ContainerMaterialHistory>(modelBuilder, "wms_container_material_history");
ConfigureTransportTaskHistory(modelBuilder);
modelBuilder.Entity<WarehouseArea>().HasIndex(x => x.Code).IsUnique();
modelBuilder.Entity<Storage>().HasIndex(x => x.Code).IsUnique();
@@ -57,6 +75,15 @@ public sealed class PlatformDbContext : DbContext
modelBuilder.Entity<ContainerLocation>().HasIndex(x => new { x.LocationType, x.LocationId });
modelBuilder.Entity<ContainerMaterial>().HasIndex(x => new { x.ContainerId, x.MaterialId, x.BatchNo, x.SerialNo }).IsUnique();
modelBuilder.Entity<WmsTransportRule>().HasIndex(x => x.Code).IsUnique();
modelBuilder.Entity<WmsTransportRule>().HasIndex(x => new { x.TriggerType, x.Enabled, x.Priority });
modelBuilder.Entity<WmsTransportTask>().HasIndex(x => x.Status);
modelBuilder.Entity<WmsTransportTask>().HasIndex(x => x.ContainerId);
modelBuilder.Entity<WmsTransportTask>().HasIndex(x => x.TargetStorageId);
modelBuilder.Entity<WmsTransportReservation>().HasIndex(x => new { x.ContainerId, x.Status });
modelBuilder.Entity<WmsTransportReservation>().HasIndex(x => new { x.TargetStorageId, x.Status });
modelBuilder.Entity<WmsTransportTask>().Property(x => x.Quantity).HasPrecision(18, 4);
modelBuilder.Entity<ContainerMaterial>().Property(x => x.Quantity).HasPrecision(18, 4);
modelBuilder.Entity<ContainerMaterialHistory>().Property(x => x.QuantityDelta).HasPrecision(18, 4);
@@ -165,4 +192,19 @@ public sealed class PlatformDbContext : DbContext
e.HasIndex(x => x.OperatedAt);
e.HasIndex(x => x.EventType);
}
private static void ConfigureTransportTaskHistory(ModelBuilder modelBuilder)
{
var e = modelBuilder.Entity<WmsTransportTaskHistory>();
e.ToTable("wms_transport_task_history");
e.HasKey(x => x.Id);
e.Property(x => x.FromStatus).HasMaxLength(32);
e.Property(x => x.ToStatus).HasMaxLength(32);
e.Property(x => x.Operator).HasMaxLength(128);
e.Property(x => x.Reason).HasMaxLength(500);
e.Property(x => x.ErrorMessage).HasMaxLength(1000);
e.Property(x => x.SnapshotJson).HasColumnType("text");
e.HasIndex(x => x.TaskId);
e.HasIndex(x => x.OperatedAt);
}
}
@@ -40,6 +40,10 @@ public static class PlatformPersistence
services.AddScoped<WmsReferenceValidator>();
services.AddScoped<WmsService>();
services.AddScoped<WmsTransportRuleService>();
services.AddScoped<WmsTransportPlanner>();
services.AddScoped<WmsTransportTaskService>();
services.AddScoped<IWmsDispatchAdapter, NoopWmsDispatchAdapter>();
services.AddScoped<SimpleFieldService>();
services.AddScoped<MiGu.Server.Dashboard.DashboardShortcutService>();
return services;
@@ -53,6 +57,7 @@ public static class PlatformPersistence
// EnsureCreated 只在「库文件不存在」时建表;已有 platform.db 时新增实体不会自动补表。
await EnsureSimpleFieldsTableAsync(db);
await EnsureUserDashboardShortcutsTableAsync(db);
await EnsureWmsTransportSchemaAsync(db);
}
/// <summary>为已存在的数据库补建 simple_fields 表(幂等)。</summary>
@@ -101,6 +106,134 @@ public static class PlatformPersistence
}
}
/// <summary>为已存在的数据库补建 WMS 搬运规则/任务相关表及库位扩展列(幂等)。</summary>
private static async Task EnsureWmsTransportSchemaAsync(PlatformDbContext db)
{
if (!db.Database.IsSqlite()) return;
await EnsureSqliteColumnAsync(db, "wms_storages", "Status", "TEXT NOT NULL DEFAULT 'Available'");
await EnsureSqliteColumnAsync(db, "wms_storages", "Usage", "TEXT NOT NULL DEFAULT ''");
await EnsureSqliteColumnAsync(db, "wms_storages", "Priority", "INTEGER NOT NULL DEFAULT 0");
await EnsureSqliteColumnAsync(db, "wms_storages", "ZoneCode", "TEXT NOT NULL DEFAULT ''");
await EnsureSqliteColumnAsync(db, "wms_storages", "AllowInbound", "INTEGER NOT NULL DEFAULT 1");
await EnsureSqliteColumnAsync(db, "wms_storages", "AllowOutbound", "INTEGER NOT NULL DEFAULT 1");
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS wms_transport_rules (
Id TEXT NOT NULL CONSTRAINT PK_wms_transport_rules PRIMARY KEY,
CreatedAt TEXT NOT NULL,
UpdatedAt TEXT NOT NULL,
IsDeleted INTEGER NOT NULL,
DeletedAt TEXT,
DeletedBy TEXT,
Version INTEGER NOT NULL,
IsLock INTEGER NOT NULL,
CreatedBy TEXT NOT NULL,
UpdatedBy TEXT NOT NULL,
Remark TEXT NOT NULL,
Extend TEXT NOT NULL,
Code TEXT NOT NULL,
Name TEXT NOT NULL,
TriggerType TEXT NOT NULL,
Enabled INTEGER NOT NULL,
Priority INTEGER NOT NULL,
SourceSelectorJson TEXT NOT NULL,
TargetSelectorJson TEXT NOT NULL,
TaskOptionsJson TEXT NOT NULL
);
""");
await db.Database.ExecuteSqlRawAsync("""
CREATE UNIQUE INDEX IF NOT EXISTS IX_wms_transport_rules_Code ON wms_transport_rules (Code);
""");
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS wms_transport_tasks (
Id TEXT NOT NULL CONSTRAINT PK_wms_transport_tasks PRIMARY KEY,
CreatedAt TEXT NOT NULL,
UpdatedAt TEXT NOT NULL,
IsDeleted INTEGER NOT NULL,
DeletedAt TEXT,
DeletedBy TEXT,
Version INTEGER NOT NULL,
IsLock INTEGER NOT NULL,
CreatedBy TEXT NOT NULL,
UpdatedBy TEXT NOT NULL,
Remark TEXT NOT NULL,
Extend TEXT NOT NULL,
BusinessType TEXT NOT NULL,
RuleId TEXT,
SourceStorageId TEXT NOT NULL,
TargetStorageId TEXT NOT NULL,
ContainerId TEXT NOT NULL,
MaterialId TEXT,
Quantity REAL,
Status TEXT NOT NULL,
DispatchMissionId TEXT NOT NULL DEFAULT '',
DeliveryId TEXT NOT NULL DEFAULT '',
DispatchStatus TEXT NOT NULL DEFAULT '',
Reason TEXT NOT NULL DEFAULT '',
SnapshotJson TEXT NOT NULL,
ErrorMessage TEXT NOT NULL DEFAULT '',
TaskPriority INTEGER NOT NULL DEFAULT 0
);
""");
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS wms_transport_reservations (
Id TEXT NOT NULL CONSTRAINT PK_wms_transport_reservations PRIMARY KEY,
CreatedAt TEXT NOT NULL,
UpdatedAt TEXT NOT NULL,
IsDeleted INTEGER NOT NULL,
DeletedAt TEXT,
DeletedBy TEXT,
Version INTEGER NOT NULL,
IsLock INTEGER NOT NULL,
CreatedBy TEXT NOT NULL,
UpdatedBy TEXT NOT NULL,
Remark TEXT NOT NULL,
Extend TEXT NOT NULL,
TaskId TEXT NOT NULL,
ContainerId TEXT NOT NULL,
SourceStorageId TEXT NOT NULL,
TargetStorageId TEXT NOT NULL,
Status TEXT NOT NULL,
ExpiresAt TEXT
);
""");
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS wms_transport_task_history (
Id TEXT NOT NULL CONSTRAINT PK_wms_transport_task_history PRIMARY KEY,
TaskId TEXT NOT NULL,
FromStatus TEXT NOT NULL,
ToStatus TEXT NOT NULL,
Operator TEXT NOT NULL,
OperatedAt TEXT NOT NULL,
Reason TEXT NOT NULL,
ErrorMessage TEXT NOT NULL,
SnapshotJson TEXT NOT NULL
);
""");
}
private static async Task EnsureSqliteColumnAsync(PlatformDbContext db, string table, string column, string definition)
{
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open)
await conn.OpenAsync();
await using var cmd = conn.CreateCommand();
cmd.CommandText = $"PRAGMA table_info({table});";
await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
var name = reader.GetString(1);
if (string.Equals(name, column, StringComparison.OrdinalIgnoreCase))
return;
}
await db.Database.ExecuteSqlRawAsync($"ALTER TABLE {table} ADD COLUMN {column} {definition};");
}
/// <summary>为已存在的数据库补建 user_dashboard_shortcuts 表(幂等)。</summary>
private static async Task EnsureUserDashboardShortcutsTableAsync(PlatformDbContext db)
{
+14
View File
@@ -0,0 +1,14 @@
namespace MiGu.Server.Wms;
public interface IWmsDispatchAdapter
{
Task<DispatchResult> DispatchAsync(DispatchTransportCommand command, CancellationToken ct = default);
}
public sealed record DispatchResult(bool Success, string? MissionId, string? DeliveryId, string? ErrorMessage);
public sealed class NoopWmsDispatchAdapter : IWmsDispatchAdapter
{
public Task<DispatchResult> DispatchAsync(DispatchTransportCommand command, CancellationToken ct = default) =>
Task.FromResult(new DispatchResult(false, null, null, "调度适配器未配置,任务保留在平台侧"));
}
+109
View File
@@ -36,6 +36,12 @@ public sealed class Storage : EntityBase
[MaxLength(64)] public string StorageType { get; set; } = "Storage";
[MaxLength(64)] public string SiteId { get; set; } = "";
public int Capacity { get; set; }
[MaxLength(32)] public string Status { get; set; } = StorageStatuses.Available;
[MaxLength(64)] public string Usage { get; set; } = "";
public int Priority { get; set; }
[MaxLength(64)] public string ZoneCode { get; set; } = "";
public bool AllowInbound { get; set; } = true;
public bool AllowOutbound { get; set; } = true;
public bool Enabled { get; set; } = true;
}
@@ -118,3 +124,106 @@ public static class ContainerMaterialStatuses
public const string Frozen = "Frozen";
public static readonly HashSet<string> All = new(StringComparer.OrdinalIgnoreCase) { Loaded, Unloaded, Adjusted, Frozen };
}
public static class StorageStatuses
{
public const string Available = "Available";
public const string Idle = "Idle";
public const string Occupied = "Occupied";
public const string Disabled = "Disabled";
public static readonly HashSet<string> All = new(StringComparer.OrdinalIgnoreCase) { Available, Idle, Occupied, Disabled };
}
public static class StorageTypes
{
public const string Storage = "Storage";
public const string LineSide = "LineSide";
public const string OfflinePoint = "OfflinePoint";
public const string Buffer = "Buffer";
public const string FinishedGoods = "FinishedGoods";
}
public static class WmsTransportTriggerTypes
{
public const string MaterialCall = "MaterialCall";
public const string FinishedGoodsOffline = "FinishedGoodsOffline";
public const string AutoTransfer = "AutoTransfer";
public static readonly HashSet<string> All = new(StringComparer.OrdinalIgnoreCase)
{ MaterialCall, FinishedGoodsOffline, AutoTransfer };
}
public static class WmsTransportTaskStatuses
{
public const string Pending = "Pending";
public const string Reserved = "Reserved";
public const string Dispatched = "Dispatched";
public const string InTransit = "InTransit";
public const string Completed = "Completed";
public const string Failed = "Failed";
public const string Cancelled = "Cancelled";
public static readonly HashSet<string> Active = new(StringComparer.OrdinalIgnoreCase)
{ Pending, Reserved, Dispatched, InTransit };
public static readonly HashSet<string> All = new(StringComparer.OrdinalIgnoreCase)
{ Pending, Reserved, Dispatched, InTransit, Completed, Failed, Cancelled };
}
public static class WmsReservationStatuses
{
public const string Active = "Active";
public const string Released = "Released";
public const string Expired = "Expired";
}
public sealed class WmsTransportRule : EntityBase
{
[MaxLength(64)] public string Code { get; set; } = "";
[MaxLength(128)] public string Name { get; set; } = "";
[MaxLength(64)] public string TriggerType { get; set; } = WmsTransportTriggerTypes.MaterialCall;
public bool Enabled { get; set; } = true;
public int Priority { get; set; }
public string SourceSelectorJson { get; set; } = "{}";
public string TargetSelectorJson { get; set; } = "{}";
public string TaskOptionsJson { get; set; } = "{}";
}
public sealed class WmsTransportTask : EntityBase
{
[MaxLength(64)] public string BusinessType { get; set; } = "";
public Guid? RuleId { get; set; }
public Guid SourceStorageId { get; set; }
public Guid TargetStorageId { get; set; }
public Guid ContainerId { get; set; }
public Guid? MaterialId { get; set; }
public decimal? Quantity { get; set; }
[MaxLength(32)] public string Status { get; set; } = WmsTransportTaskStatuses.Pending;
[MaxLength(64)] public string DispatchMissionId { get; set; } = "";
[MaxLength(64)] public string DeliveryId { get; set; } = "";
[MaxLength(32)] public string DispatchStatus { get; set; } = "";
[MaxLength(500)] public string Reason { get; set; } = "";
public string SnapshotJson { get; set; } = "{}";
[MaxLength(1000)] public string ErrorMessage { get; set; } = "";
public int TaskPriority { get; set; }
}
public sealed class WmsTransportReservation : EntityBase
{
public Guid TaskId { get; set; }
public Guid ContainerId { get; set; }
public Guid SourceStorageId { get; set; }
public Guid TargetStorageId { get; set; }
[MaxLength(32)] public string Status { get; set; } = WmsReservationStatuses.Active;
public DateTimeOffset? ExpiresAt { get; set; }
}
public sealed class WmsTransportTaskHistory
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid TaskId { get; set; }
[MaxLength(32)] public string FromStatus { get; set; } = "";
[MaxLength(32)] public string ToStatus { get; set; } = "";
[MaxLength(128)] public string Operator { get; set; } = "";
public DateTimeOffset OperatedAt { get; set; }
[MaxLength(500)] public string Reason { get; set; } = "";
[MaxLength(1000)] public string ErrorMessage { get; set; } = "";
public string SnapshotJson { get; set; } = "{}";
}
+23
View File
@@ -84,6 +84,12 @@ public sealed class WmsService
entity.StorageType = req.StorageType.TrimOr("Storage");
entity.SiteId = req.SiteId.TrimOr("");
entity.Capacity = req.Capacity;
entity.Status = req.Status.TrimOr(StorageStatuses.Available);
entity.Usage = req.Usage.TrimOr("");
entity.Priority = req.Priority;
entity.ZoneCode = req.ZoneCode.TrimOr("");
entity.AllowInbound = req.AllowInbound;
entity.AllowOutbound = req.AllowOutbound;
entity.Enabled = req.Enabled;
ApplyCommon(entity, req, actor);
await _db.SaveChangesAsync();
@@ -157,6 +163,17 @@ public sealed class WmsService
if (!ContainerLocationStatuses.All.Contains(req.Status))
throw new InvalidOperationException("容器位置状态无效");
if (string.Equals(req.LocationType, ContainerLocationTypes.Storage, StringComparison.OrdinalIgnoreCase) &&
Guid.TryParse(req.LocationId, out var targetStorageId))
{
var occupied = await _db.ContainerLocations.AsNoTracking()
.AnyAsync(x => x.LocationType == ContainerLocationTypes.Storage &&
x.LocationId == req.LocationId &&
x.ContainerId != req.ContainerId);
if (occupied)
throw new InvalidOperationException("目标库位已被其他容器占用,库位与容器为 1 对 1");
}
var current = await _db.ContainerLocations.FirstOrDefaultAsync(x => x.ContainerId == req.ContainerId);
var before = current == null ? null : Snapshot(current);
var now = DateTimeOffset.UtcNow;
@@ -449,6 +466,12 @@ public sealed record StorageRequest(
string StorageType,
string SiteId,
int Capacity,
string Status,
string Usage,
int Priority,
string ZoneCode,
bool AllowInbound,
bool AllowOutbound,
bool Enabled,
bool IsLock,
string Remark,
+328
View File
@@ -0,0 +1,328 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using MiGu.Server.Persistence;
namespace MiGu.Server.Wms;
public sealed class WmsTransportPlanner
{
private readonly PlatformDbContext _db;
private readonly JsonSerializerOptions _json = new(JsonSerializerDefaults.Web);
public WmsTransportPlanner(PlatformDbContext db) => _db = db;
public async Task<TransportCandidatePreview> PreviewAsync(WmsTransportRequest request)
{
if (!WmsTransportTriggerTypes.All.Contains(request.TriggerType))
throw new InvalidOperationException("触发类型无效");
var ctx = await BuildContextAsync();
var rules = await LoadRulesAsync(request);
var ruleResults = new List<RulePreviewResult>();
var candidates = new List<TransportCandidatePair>();
foreach (var rule in rules)
{
var sourceSelector = TransportSelectorParser.ParseSelector(rule.SourceSelectorJson);
var targetSelector = TransportSelectorParser.ParseSelector(rule.TargetSelectorJson);
var options = TransportSelectorParser.ParseTaskOptions(rule.TaskOptionsJson);
var ruleCandidates = BuildCandidates(ctx, request, rule, sourceSelector, targetSelector, options);
var rejectReasons = ruleCandidates.Count == 0
? new List<string> { "未找到满足条件的起点/终点组合" }
: new List<string>();
ruleResults.Add(new RulePreviewResult(rule.Id, rule.Code, rule.Name, ruleCandidates.Count, rejectReasons));
candidates.AddRange(ruleCandidates);
}
candidates = candidates
.OrderByDescending(x => x.Score)
.ThenBy(x => x.SourceStorageCode, StringComparer.Ordinal)
.ThenBy(x => x.ContainerCode, StringComparer.Ordinal)
.Take(rules.FirstOrDefault() is { } r
? TransportSelectorParser.ParseTaskOptions(r.TaskOptionsJson).MaxCandidateCount
: 20)
.ToList();
return new TransportCandidatePreview(request, candidates, ruleResults);
}
public async Task<TransportCandidatePair?> PickBestAsync(WmsTransportRequest request)
{
var preview = await PreviewAsync(request);
return preview.Candidates.FirstOrDefault();
}
private async Task<List<WmsTransportRule>> LoadRulesAsync(WmsTransportRequest request)
{
var query = _db.WmsTransportRules.AsNoTracking()
.Where(x => x.Enabled && x.TriggerType == request.TriggerType);
if (request.RuleId.HasValue)
query = query.Where(x => x.Id == request.RuleId.Value);
return await query.OrderByDescending(x => x.Priority).ThenBy(x => x.Code).ToListAsync();
}
private async Task<PlannerContext> BuildContextAsync()
{
var storages = await _db.Storages.AsNoTracking().Where(x => x.Enabled).ToListAsync();
var containers = await _db.Containers.AsNoTracking().Where(x => x.Enabled).ToListAsync();
var locations = await _db.ContainerLocations.AsNoTracking()
.Where(x => x.LocationType == ContainerLocationTypes.Storage).ToListAsync();
var materials = await _db.Materials.AsNoTracking().Where(x => x.Enabled).ToListAsync();
var containerMaterials = await _db.ContainerMaterials.AsNoTracking().ToListAsync();
var activeReservations = await _db.WmsTransportReservations.AsNoTracking()
.Where(x => x.Status == WmsReservationStatuses.Active).ToListAsync();
var activeTasks = await _db.WmsTransportTasks.AsNoTracking()
.Where(x => WmsTransportTaskStatuses.Active.Contains(x.Status)).ToListAsync();
var storageById = storages.ToDictionary(x => x.Id);
var containerById = containers.ToDictionary(x => x.Id);
var materialById = materials.ToDictionary(x => x.Id);
var occupiedStorageIds = locations
.Where(x => Guid.TryParse(x.LocationId, out _))
.Select(x => Guid.Parse(x.LocationId))
.ToHashSet();
var reservedContainers = activeReservations.Select(x => x.ContainerId).ToHashSet();
var reservedTargetStorages = activeReservations.Select(x => x.TargetStorageId).ToHashSet();
var taskReservedContainers = activeTasks.Select(x => x.ContainerId).ToHashSet();
return new PlannerContext(
storages, containers, locations, containerMaterials, materials,
storageById, containerById, materialById,
occupiedStorageIds, reservedContainers, reservedTargetStorages, taskReservedContainers);
}
private List<TransportCandidatePair> BuildCandidates(
PlannerContext ctx,
WmsTransportRequest request,
WmsTransportRule rule,
TransportSelector sourceSelector,
TransportSelector targetSelector,
TransportTaskOptions options)
{
var results = new List<TransportCandidatePair>();
var sources = BuildSourceCandidates(ctx, request, sourceSelector, options);
foreach (var src in sources)
{
var targets = BuildTargetCandidates(ctx, request, targetSelector, options, src.StorageId);
foreach (var tgt in targets)
{
var score = rule.Priority * 1000 + src.Score + tgt.Score;
results.Add(new TransportCandidatePair(
rule.Id, rule.Code,
src.StorageId, src.StorageCode,
tgt.StorageId, tgt.StorageCode,
src.ContainerId, src.ContainerCode,
src.MaterialId, src.MaterialCode, src.Quantity,
score, []));
}
}
return results;
}
private List<SourceCandidate> BuildSourceCandidates(
PlannerContext ctx,
WmsTransportRequest request,
TransportSelector selector,
TransportTaskOptions options)
{
var list = new List<SourceCandidate>();
foreach (var loc in ctx.Locations)
{
if (!Guid.TryParse(loc.LocationId, out var storageId)) continue;
if (request.SourceStorageId.HasValue && request.SourceStorageId.Value != storageId) continue;
if (!ctx.StorageById.TryGetValue(storageId, out var storage)) continue;
if (request.ContainerId.HasValue && request.ContainerId.Value != loc.ContainerId) continue;
if (!ctx.ContainerById.TryGetValue(loc.ContainerId, out var container)) continue;
if (!MatchesStorage(storage, selector.Storage, ctx, requireOccupied: true)) continue;
if (!MatchesContainer(container, selector.Container, ctx)) continue;
var cm = FindMatchingMaterial(ctx, loc.ContainerId, request, selector.Material);
if (selector.Material is { MaterialIds.Count: > 0 } or { Categories.Count: > 0 } or { MinQuantity: not null }
&& cm == null)
continue;
if (request.RequestSiteId is { Length: > 0 } siteId &&
string.Equals(request.TriggerType, WmsTransportTriggerTypes.FinishedGoodsOffline, StringComparison.OrdinalIgnoreCase) &&
!string.Equals(storage.SiteId, siteId, StringComparison.OrdinalIgnoreCase))
continue;
list.Add(new SourceCandidate(
storageId, storage.Code, loc.ContainerId, container.Code,
cm?.MaterialId, cm == null ? null : ctx.MaterialById.GetValueOrDefault(cm.MaterialId)?.Code,
cm?.Quantity,
ScoreSource(storage, container, loc, cm, selector)));
}
return SortSources(list, selector);
}
private List<TargetCandidate> BuildTargetCandidates(
PlannerContext ctx,
WmsTransportRequest request,
TransportSelector selector,
TransportTaskOptions options,
Guid sourceStorageId)
{
var list = new List<TargetCandidate>();
foreach (var storage in ctx.Storages)
{
if (request.TargetStorageId.HasValue && request.TargetStorageId.Value != storage.Id) continue;
if (!options.AllowSameStorage && storage.Id == sourceStorageId) continue;
if (!MatchesStorage(storage, selector.Storage, ctx, requireOccupied: false)) continue;
if (ctx.OccupiedStorageIds.Contains(storage.Id)) continue;
if (ctx.ReservedTargetStorages.Contains(storage.Id)) continue;
if (request.RequestSiteId is { Length: > 0 } siteId &&
string.Equals(request.TriggerType, WmsTransportTriggerTypes.MaterialCall, StringComparison.OrdinalIgnoreCase) &&
selector.Storage?.SiteIds is { Count: 0 } &&
!string.Equals(storage.SiteId, siteId, StringComparison.OrdinalIgnoreCase))
continue;
list.Add(new TargetCandidate(storage.Id, storage.Code, ScoreTarget(storage, selector)));
}
return SortTargets(list, selector);
}
private static ContainerMaterial? FindMatchingMaterial(
PlannerContext ctx,
Guid containerId,
WmsTransportRequest request,
MaterialSelectorFilter? filter)
{
var rows = ctx.ContainerMaterials.Where(x => x.ContainerId == containerId).ToList();
if (rows.Count == 0) return null;
foreach (var row in rows)
{
if (request.MaterialId.HasValue && row.MaterialId != request.MaterialId.Value) continue;
if (filter?.MaterialIds is { Count: > 0 } ids && !ids.Contains(row.MaterialId.ToString("D"))) continue;
if (filter?.StatusIn is { Count: > 0 } st && !st.Contains(row.Status, StringComparer.OrdinalIgnoreCase)) continue;
if (filter?.MinQuantity is { } min && row.Quantity < min) continue;
if (request.Quantity is { } reqQty && row.Quantity < reqQty) continue;
if (request.BatchNo is { Length: > 0 } batch && !string.Equals(row.BatchNo, batch, StringComparison.OrdinalIgnoreCase)) continue;
if (filter?.Categories is { Count: > 0 } cats &&
ctx.MaterialById.TryGetValue(row.MaterialId, out var mat) &&
!cats.Contains(mat.Category, StringComparer.OrdinalIgnoreCase))
continue;
return row;
}
return request.MaterialId.HasValue || filter?.MaterialIds is { Count: > 0 } ? null : rows.FirstOrDefault();
}
private static bool MatchesStorage(Storage storage, StorageSelectorFilter? filter, PlannerContext ctx, bool requireOccupied)
{
if (filter == null) return true;
if (filter.AreaIds is { Count: > 0 } && !filter.AreaIds.Contains(storage.AreaId.ToString("D"))) return false;
if (filter.StorageTypes is { Count: > 0 } && !filter.StorageTypes.Contains(storage.StorageType, StringComparer.OrdinalIgnoreCase)) return false;
if (filter.ZoneCodes is { Count: > 0 } && !filter.ZoneCodes.Contains(storage.ZoneCode, StringComparer.OrdinalIgnoreCase)) return false;
if (filter.StatusIn is { Count: > 0 } && !filter.StatusIn.Contains(storage.Status, StringComparer.OrdinalIgnoreCase)) return false;
if (filter.SiteIds is { Count: > 0 } && !filter.SiteIds.Contains(storage.SiteId, StringComparer.OrdinalIgnoreCase)) return false;
if (filter.AllowInbound == true && !storage.AllowInbound) return false;
if (filter.AllowOutbound == true && !storage.AllowOutbound) return false;
if (filter.RequireSiteId && string.IsNullOrWhiteSpace(storage.SiteId)) return false;
if (filter.ExcludeStorageIds is { Count: > 0 } && filter.ExcludeStorageIds.Contains(storage.Id.ToString("D"))) return false;
var occupied = ctx.OccupiedStorageIds.Contains(storage.Id);
if (filter.RequireEmpty && occupied) return false;
if ((filter.RequireOccupied || requireOccupied) && !occupied && requireOccupied) return false;
return true;
}
private static bool MatchesContainer(Container container, ContainerSelectorFilter? filter, PlannerContext ctx)
{
if (filter == null) return true;
if (filter.ContainerTypes is { Count: > 0 } && !filter.ContainerTypes.Contains(container.ContainerType, StringComparer.OrdinalIgnoreCase)) return false;
if (filter.StatusIn is { Count: > 0 } && !filter.StatusIn.Contains(container.Status, StringComparer.OrdinalIgnoreCase)) return false;
if (filter.ExcludeReserved &&
(ctx.ReservedContainers.Contains(container.Id) || ctx.TaskReservedContainers.Contains(container.Id)))
return false;
return true;
}
private static int ScoreSource(Storage storage, Container container, ContainerLocation loc, ContainerMaterial? cm, TransportSelector selector) =>
storage.Priority * 10 + (cm?.Quantity is > 0 ? 5 : 0);
private static int ScoreTarget(Storage storage, TransportSelector selector) => storage.Priority * 10;
private static List<SourceCandidate> SortSources(List<SourceCandidate> list, TransportSelector selector)
{
IOrderedEnumerable<SourceCandidate>? ordered = null;
foreach (var rule in selector.Ranking)
{
ordered = ordered == null
? ApplySourceSort(list, rule)
: ApplySourceSort(ordered, rule);
}
return (ordered ?? list.OrderBy(x => x.StorageCode)).ToList();
}
private static List<TargetCandidate> SortTargets(List<TargetCandidate> list, TransportSelector selector)
{
IOrderedEnumerable<TargetCandidate>? ordered = null;
foreach (var rule in selector.Ranking)
{
ordered = ordered == null
? ApplyTargetSort(list, rule)
: ApplyTargetSort(ordered, rule);
}
return (ordered ?? list.OrderBy(x => x.StorageCode)).ToList();
}
private static IOrderedEnumerable<SourceCandidate> ApplySourceSort(IEnumerable<SourceCandidate> list, RankingRule rule) =>
rule.Field.ToLowerInvariant() switch
{
"storagepriority" or "priority" => rule.Direction.Equals("desc", StringComparison.OrdinalIgnoreCase)
? list.OrderByDescending(x => x.Score)
: list.OrderBy(x => x.Score),
"quantity" => rule.Direction.Equals("desc", StringComparison.OrdinalIgnoreCase)
? list.OrderByDescending(x => x.Quantity ?? 0)
: list.OrderBy(x => x.Quantity ?? 0),
_ => list.OrderBy(x => x.StorageCode)
};
private static IOrderedEnumerable<SourceCandidate> ApplySourceSort(IOrderedEnumerable<SourceCandidate> list, RankingRule rule) =>
rule.Field.ToLowerInvariant() switch
{
"quantity" => rule.Direction.Equals("desc", StringComparison.OrdinalIgnoreCase)
? list.ThenByDescending(x => x.Quantity ?? 0)
: list.ThenBy(x => x.Quantity ?? 0),
_ => list.ThenBy(x => x.StorageCode)
};
private static IOrderedEnumerable<TargetCandidate> ApplyTargetSort(IEnumerable<TargetCandidate> list, RankingRule rule) =>
rule.Field.ToLowerInvariant() switch
{
"storagepriority" or "priority" => rule.Direction.Equals("desc", StringComparison.OrdinalIgnoreCase)
? list.OrderByDescending(x => x.Score)
: list.OrderBy(x => x.Score),
_ => list.OrderBy(x => x.StorageCode)
};
private static IOrderedEnumerable<TargetCandidate> ApplyTargetSort(IOrderedEnumerable<TargetCandidate> list, RankingRule rule) =>
list.ThenBy(x => x.StorageCode);
private sealed record PlannerContext(
List<Storage> Storages,
List<Container> Containers,
List<ContainerLocation> Locations,
List<ContainerMaterial> ContainerMaterials,
List<Material> Materials,
Dictionary<Guid, Storage> StorageById,
Dictionary<Guid, Container> ContainerById,
Dictionary<Guid, Material> MaterialById,
HashSet<Guid> OccupiedStorageIds,
HashSet<Guid> ReservedContainers,
HashSet<Guid> ReservedTargetStorages,
HashSet<Guid> TaskReservedContainers);
private sealed record SourceCandidate(
Guid StorageId, string StorageCode,
Guid ContainerId, string ContainerCode,
Guid? MaterialId, string? MaterialCode, decimal? Quantity, int Score);
private sealed record TargetCandidate(Guid StorageId, string StorageCode, int Score);
}
+115
View File
@@ -0,0 +1,115 @@
using Microsoft.EntityFrameworkCore;
using MiGu.Server.Persistence;
namespace MiGu.Server.Wms;
public sealed class WmsTransportRuleService
{
private readonly PlatformDbContext _db;
public WmsTransportRuleService(PlatformDbContext db) => _db = db;
public Task<List<WmsTransportRule>> ListAsync(string? triggerType = null, string? q = null)
{
var query = _db.WmsTransportRules.AsNoTracking().OrderByDescending(x => x.Priority).ThenBy(x => x.Code).AsQueryable();
if (!string.IsNullOrWhiteSpace(triggerType))
query = query.Where(x => x.TriggerType == triggerType);
if (!string.IsNullOrWhiteSpace(q))
{
var s = q.Trim();
query = query.Where(x => x.Code.Contains(s) || x.Name.Contains(s));
}
return query.ToListAsync();
}
public async Task<WmsTransportRule> SaveAsync(TransportRuleRequest req, string actor)
{
if (!WmsTransportTriggerTypes.All.Contains(req.TriggerType))
throw new InvalidOperationException("触发类型无效");
WmsTransportRule entity;
if (req.Id.HasValue)
entity = await FindEditable(req.Id.Value, req.Version);
else
{
entity = new WmsTransportRule();
StampCreate(entity, actor);
_db.WmsTransportRules.Add(entity);
}
await EnsureUniqueCode(req.Code, entity.Id);
entity.Code = req.Code.Trim();
entity.Name = req.Name.Trim();
entity.TriggerType = req.TriggerType.Trim();
entity.Enabled = req.Enabled;
entity.Priority = req.Priority;
entity.SourceSelectorJson = TransportSelectorParser.NormalizeSelectorJson(req.SourceSelectorJson);
entity.TargetSelectorJson = TransportSelectorParser.NormalizeSelectorJson(req.TargetSelectorJson);
entity.TaskOptionsJson = TransportSelectorParser.NormalizeTaskOptionsJson(req.TaskOptionsJson);
ApplyCommon(entity, req, actor);
await _db.SaveChangesAsync();
return entity;
}
public async Task DeleteAsync(Guid id, long? version, string actor)
{
var entity = await FindEditable(id, version);
entity.IsDeleted = true;
entity.DeletedAt = DateTimeOffset.UtcNow;
entity.DeletedBy = actor;
entity.UpdatedBy = actor;
await _db.SaveChangesAsync();
}
private async Task<WmsTransportRule> FindEditable(Guid id, long? version)
{
var entity = await _db.WmsTransportRules.FirstOrDefaultAsync(x => x.Id == id)
?? throw new InvalidOperationException("规则不存在");
if (version.HasValue && entity.Version != version.Value)
throw new InvalidOperationException("数据已被其他用户修改,请刷新后重试");
if (entity.IsLock) throw new InvalidOperationException("规则已锁定,不能修改");
return entity;
}
private async Task EnsureUniqueCode(string code, Guid id)
{
if (await _db.WmsTransportRules.AnyAsync(x => x.Code == code.Trim() && x.Id != id))
throw new InvalidOperationException("规则编码已存在");
}
private static void StampCreate(EntityBase entity, string actor)
{
entity.CreatedBy = actor;
entity.UpdatedBy = actor;
}
private static void ApplyCommon(EntityBase entity, CommonRequest req, string actor)
{
entity.IsLock = req.IsLock;
entity.Remark = req.Remark.TrimOr("");
entity.Extend = NormalizeExtend(req.Extend);
entity.UpdatedBy = actor;
}
private static string NormalizeExtend(string? extend)
{
if (string.IsNullOrWhiteSpace(extend)) return "{}";
if (extend.Length > 4000) throw new InvalidOperationException("扩展字段过长");
return extend;
}
}
public sealed record TransportRuleRequest(
Guid? Id,
long? Version,
string Code,
string Name,
string TriggerType,
bool Enabled,
int Priority,
string SourceSelectorJson,
string TargetSelectorJson,
string TaskOptionsJson,
bool IsLock,
string Remark,
string Extend) : CommonRequest(Id, Version, IsLock, Remark, Extend);
+153
View File
@@ -0,0 +1,153 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace MiGu.Server.Wms;
public sealed class TransportSelector
{
public int SchemaVersion { get; set; } = 1;
public StorageSelectorFilter? Storage { get; set; }
public ContainerSelectorFilter? Container { get; set; }
public MaterialSelectorFilter? Material { get; set; }
public List<RankingRule> Ranking { get; set; } = [];
}
public sealed class StorageSelectorFilter
{
public List<string> AreaIds { get; set; } = [];
public List<string> StorageTypes { get; set; } = [];
public List<string> ZoneCodes { get; set; } = [];
public List<string> StatusIn { get; set; } = [];
public List<string> SiteIds { get; set; } = [];
public bool? AllowInbound { get; set; }
public bool? AllowOutbound { get; set; }
public bool RequireSiteId { get; set; }
public bool RequireEmpty { get; set; }
public bool RequireOccupied { get; set; }
public List<string> ExcludeStorageIds { get; set; } = [];
}
public sealed class ContainerSelectorFilter
{
public List<string> ContainerTypes { get; set; } = [];
public List<string> StatusIn { get; set; } = [];
public bool ExcludeReserved { get; set; } = true;
}
public sealed class MaterialSelectorFilter
{
public List<string> MaterialIds { get; set; } = [];
public List<string> Categories { get; set; } = [];
public List<string> StatusIn { get; set; } = [];
public decimal? MinQuantity { get; set; }
public string BatchNoMode { get; set; } = "Any";
}
public sealed class RankingRule
{
public string Field { get; set; } = "";
public string Direction { get; set; } = "asc";
}
public sealed class TransportTaskOptions
{
public bool AutoReserve { get; set; } = true;
public bool RequireManualConfirm { get; set; }
public bool AllowSameStorage { get; set; }
public int ReservationTtlSeconds { get; set; } = 600;
public string DispatchMode { get; set; } = "Manual";
public int MaxCandidateCount { get; set; } = 20;
}
public static class TransportSelectorParser
{
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public static TransportSelector ParseSelector(string? json)
{
if (string.IsNullOrWhiteSpace(json) || json.Trim() == "{}")
return new TransportSelector();
try
{
return JsonSerializer.Deserialize<TransportSelector>(json, Json) ?? new TransportSelector();
}
catch (JsonException ex)
{
throw new InvalidOperationException($"Selector JSON 无效: {ex.Message}");
}
}
public static TransportTaskOptions ParseTaskOptions(string? json)
{
if (string.IsNullOrWhiteSpace(json) || json.Trim() == "{}")
return new TransportTaskOptions();
try
{
return JsonSerializer.Deserialize<TransportTaskOptions>(json, Json) ?? new TransportTaskOptions();
}
catch (JsonException ex)
{
throw new InvalidOperationException($"TaskOptions JSON 无效: {ex.Message}");
}
}
public static string NormalizeSelectorJson(string? json) =>
JsonSerializer.Serialize(ParseSelector(json), Json);
public static string NormalizeTaskOptionsJson(string? json) =>
JsonSerializer.Serialize(ParseTaskOptions(json), Json);
}
public sealed record WmsTransportRequest(
string TriggerType,
Guid? RuleId,
string? RequestSiteId,
Guid? MaterialId,
decimal? Quantity,
Guid? ContainerId,
Guid? SourceStorageId,
Guid? TargetStorageId,
string? BatchNo,
int Priority,
string Reason,
Dictionary<string, string>? Tags);
public sealed record TransportCandidatePair(
Guid RuleId,
string RuleCode,
Guid SourceStorageId,
string SourceStorageCode,
Guid TargetStorageId,
string TargetStorageCode,
Guid ContainerId,
string ContainerCode,
Guid? MaterialId,
string? MaterialCode,
decimal? Quantity,
int Score,
List<string> RejectReasons);
public sealed record TransportCandidatePreview(
WmsTransportRequest Request,
List<TransportCandidatePair> Candidates,
List<RulePreviewResult> RuleResults);
public sealed record RulePreviewResult(
Guid RuleId,
string RuleCode,
string RuleName,
int CandidateCount,
List<string> RejectReasons);
public sealed record DispatchTransportCommand(
Guid TaskId,
string SourceSiteId,
string TargetSiteId,
string ContainerCode,
int Priority,
string BusinessType,
string Reason);
+299
View File
@@ -0,0 +1,299 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using MiGu.Server.Persistence;
namespace MiGu.Server.Wms;
public sealed class WmsTransportTaskService
{
private readonly PlatformDbContext _db;
private readonly WmsTransportPlanner _planner;
private readonly WmsService _wms;
private readonly IWmsDispatchAdapter _dispatch;
private readonly JsonSerializerOptions _json = new(JsonSerializerDefaults.Web);
public WmsTransportTaskService(
PlatformDbContext db,
WmsTransportPlanner planner,
WmsService wms,
IWmsDispatchAdapter dispatch)
{
_db = db;
_planner = planner;
_wms = wms;
_dispatch = dispatch;
}
public async Task<List<WmsTransportTask>> ListAsync(string? status = null, string? triggerType = null)
{
var query = _db.WmsTransportTasks.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(status))
query = query.Where(x => x.Status == status);
if (!string.IsNullOrWhiteSpace(triggerType))
query = query.Where(x => x.BusinessType == triggerType);
var rows = await query.ToListAsync();
return rows.OrderByDescending(x => x.CreatedAt).Take(500).ToList();
}
public Task<TransportCandidatePreview> PreviewCandidatesAsync(WmsTransportRequest request) =>
_planner.PreviewAsync(request);
public async Task<WmsTransportTask> GenerateAsync(WmsTransportRequest request, string actor)
{
var picked = await _planner.PickBestAsync(request)
?? throw new InvalidOperationException("未找到可用的搬运候选");
WmsTransportRule? rule = null;
if (picked.RuleId != Guid.Empty)
rule = await _db.WmsTransportRules.AsNoTracking().FirstOrDefaultAsync(x => x.Id == picked.RuleId);
var options = TransportSelectorParser.ParseTaskOptions(rule?.TaskOptionsJson);
await using var tx = await _db.Database.BeginTransactionAsync();
try
{
await EnsureNoActiveReservationAsync(picked.ContainerId, picked.TargetStorageId);
await EnsureStorageAvailableAsync(picked.SourceStorageId, picked.TargetStorageId);
var snapshot = BuildSnapshot(picked, request);
var status = options.RequireManualConfirm || !options.AutoReserve
? WmsTransportTaskStatuses.Pending
: WmsTransportTaskStatuses.Reserved;
var task = new WmsTransportTask
{
BusinessType = request.TriggerType,
RuleId = picked.RuleId == Guid.Empty ? null : picked.RuleId,
SourceStorageId = picked.SourceStorageId,
TargetStorageId = picked.TargetStorageId,
ContainerId = picked.ContainerId,
MaterialId = picked.MaterialId,
Quantity = picked.Quantity ?? request.Quantity,
Status = status,
Reason = request.Reason.TrimOr(""),
SnapshotJson = JsonSerializer.Serialize(snapshot, _json),
TaskPriority = request.Priority
};
StampCreate(task, actor);
_db.WmsTransportTasks.Add(task);
await _db.SaveChangesAsync();
if (status == WmsTransportTaskStatuses.Reserved)
await CreateReservationAsync(task, options, actor);
await AppendHistoryAsync(task.Id, "", task.Status, actor, request.Reason, "", task.SnapshotJson);
await tx.CommitAsync();
return task;
}
catch
{
await tx.RollbackAsync();
throw;
}
}
public async Task<WmsTransportTask> ReserveAsync(Guid taskId, string actor, string reason = "")
{
var task = await FindTaskAsync(taskId);
if (task.Status != WmsTransportTaskStatuses.Pending)
throw new InvalidOperationException("只有 Pending 状态的任务可以预占");
var rule = task.RuleId.HasValue
? await _db.WmsTransportRules.AsNoTracking().FirstOrDefaultAsync(x => x.Id == task.RuleId.Value)
: null;
var options = TransportSelectorParser.ParseTaskOptions(rule?.TaskOptionsJson);
await using var tx = await _db.Database.BeginTransactionAsync();
try
{
await EnsureNoActiveReservationAsync(task.ContainerId, task.TargetStorageId);
await EnsureStorageAvailableAsync(task.SourceStorageId, task.TargetStorageId);
await CreateReservationAsync(task, options, actor);
await ChangeStatusAsync(task, WmsTransportTaskStatuses.Reserved, actor, reason);
await tx.CommitAsync();
return task;
}
catch
{
await tx.RollbackAsync();
throw;
}
}
public async Task<WmsTransportTask> CancelAsync(Guid taskId, string actor, string reason = "")
{
var task = await FindTaskAsync(taskId);
if (task.Status is WmsTransportTaskStatuses.Completed or WmsTransportTaskStatuses.Cancelled)
throw new InvalidOperationException("任务已结束,不能取消");
await ReleaseReservationsAsync(task.Id);
await ChangeStatusAsync(task, WmsTransportTaskStatuses.Cancelled, actor, reason);
return task;
}
public async Task<WmsTransportTask> CompleteAsync(Guid taskId, string actor, string reason = "")
{
var task = await FindTaskAsync(taskId);
if (task.Status is WmsTransportTaskStatuses.Completed or WmsTransportTaskStatuses.Cancelled)
throw new InvalidOperationException("任务已结束");
var source = await _db.Storages.AsNoTracking().FirstOrDefaultAsync(x => x.Id == task.SourceStorageId)
?? throw new InvalidOperationException("源库位不存在");
var target = await _db.Storages.AsNoTracking().FirstOrDefaultAsync(x => x.Id == task.TargetStorageId)
?? throw new InvalidOperationException("目标库位不存在");
if (string.IsNullOrWhiteSpace(target.SiteId))
throw new InvalidOperationException("目标库位缺少 SiteId,无法完成搬运");
await using var tx = await _db.Database.BeginTransactionAsync();
try
{
await _wms.BindOrTransferLocation(new ContainerLocationRequest(
null, null, task.ContainerId, ContainerLocationTypes.Storage,
task.TargetStorageId.ToString("D"), ContainerLocationStatuses.Active,
DateTimeOffset.UtcNow, "TransportTask", reason, false, "", "{}"), actor);
await ReleaseReservationsAsync(task.Id);
await ChangeStatusAsync(task, WmsTransportTaskStatuses.Completed, actor, reason);
await tx.CommitAsync();
return task;
}
catch
{
await tx.RollbackAsync();
throw;
}
}
public async Task<WmsTransportTask> DispatchAsync(Guid taskId, string actor)
{
var task = await FindTaskAsync(taskId);
if (task.Status != WmsTransportTaskStatuses.Reserved)
throw new InvalidOperationException("只有 Reserved 状态的任务可以下发");
var source = await _db.Storages.AsNoTracking().FirstAsync(x => x.Id == task.SourceStorageId);
var target = await _db.Storages.AsNoTracking().FirstAsync(x => x.Id == task.TargetStorageId);
var container = await _db.Containers.AsNoTracking().FirstAsync(x => x.Id == task.ContainerId);
var result = await _dispatch.DispatchAsync(new DispatchTransportCommand(
task.Id, source.SiteId, target.SiteId, container.Code, task.TaskPriority,
task.BusinessType, task.Reason));
if (result.Success)
{
task.DispatchMissionId = result.MissionId ?? "";
task.DeliveryId = result.DeliveryId ?? "";
task.DispatchStatus = "Dispatched";
await ChangeStatusAsync(task, WmsTransportTaskStatuses.Dispatched, actor, "调度下发");
}
else
{
task.ErrorMessage = result.ErrorMessage ?? "下发失败";
task.DispatchStatus = "Failed";
await ChangeStatusAsync(task, WmsTransportTaskStatuses.Failed, actor, result.ErrorMessage ?? "下发失败");
}
return task;
}
private async Task EnsureNoActiveReservationAsync(Guid containerId, Guid targetStorageId)
{
if (await _db.WmsTransportReservations.AnyAsync(x =>
x.Status == WmsReservationStatuses.Active && x.ContainerId == containerId))
throw new InvalidOperationException("容器已被其他任务预占");
if (await _db.WmsTransportReservations.AnyAsync(x =>
x.Status == WmsReservationStatuses.Active && x.TargetStorageId == targetStorageId))
throw new InvalidOperationException("目标库位已被其他任务预占");
}
private async Task EnsureStorageAvailableAsync(Guid sourceStorageId, Guid targetStorageId)
{
var targetOccupied = await _db.ContainerLocations.AnyAsync(x =>
x.LocationType == ContainerLocationTypes.Storage && x.LocationId == targetStorageId.ToString("D"));
if (targetOccupied)
throw new InvalidOperationException("目标库位已被占用");
var sourceHasContainer = await _db.ContainerLocations.AnyAsync(x =>
x.LocationType == ContainerLocationTypes.Storage &&
x.LocationId == sourceStorageId.ToString("D"));
if (!sourceHasContainer)
throw new InvalidOperationException("源库位没有容器");
}
private async Task CreateReservationAsync(WmsTransportTask task, TransportTaskOptions options, string actor)
{
var reservation = new WmsTransportReservation
{
TaskId = task.Id,
ContainerId = task.ContainerId,
SourceStorageId = task.SourceStorageId,
TargetStorageId = task.TargetStorageId,
Status = WmsReservationStatuses.Active,
ExpiresAt = options.ReservationTtlSeconds > 0
? DateTimeOffset.UtcNow.AddSeconds(options.ReservationTtlSeconds)
: null
};
StampCreate(reservation, actor);
_db.WmsTransportReservations.Add(reservation);
await _db.SaveChangesAsync();
}
private async Task ReleaseReservationsAsync(Guid taskId)
{
var rows = await _db.WmsTransportReservations.Where(x => x.TaskId == taskId && x.Status == WmsReservationStatuses.Active).ToListAsync();
foreach (var row in rows)
row.Status = WmsReservationStatuses.Released;
await _db.SaveChangesAsync();
}
private async Task ChangeStatusAsync(WmsTransportTask task, string toStatus, string actor, string reason)
{
var from = task.Status;
task.Status = toStatus;
task.UpdatedBy = actor;
await _db.SaveChangesAsync();
await AppendHistoryAsync(task.Id, from, toStatus, actor, reason, task.ErrorMessage, task.SnapshotJson);
}
private async Task AppendHistoryAsync(Guid taskId, string from, string to, string actor, string reason, string error, string snapshot)
{
_db.WmsTransportTaskHistories.Add(new WmsTransportTaskHistory
{
TaskId = taskId,
FromStatus = from,
ToStatus = to,
Operator = actor,
OperatedAt = DateTimeOffset.UtcNow,
Reason = reason.TrimOr(""),
ErrorMessage = error.TrimOr(""),
SnapshotJson = snapshot
});
await _db.SaveChangesAsync();
}
private async Task<WmsTransportTask> FindTaskAsync(Guid id) =>
await _db.WmsTransportTasks.FirstOrDefaultAsync(x => x.Id == id)
?? throw new InvalidOperationException("搬运任务不存在");
private static object BuildSnapshot(TransportCandidatePair picked, WmsTransportRequest request) => new
{
request.TriggerType,
picked.RuleId,
picked.RuleCode,
picked.SourceStorageId,
picked.SourceStorageCode,
picked.TargetStorageId,
picked.TargetStorageCode,
picked.ContainerId,
picked.ContainerCode,
picked.MaterialId,
picked.MaterialCode,
Quantity = picked.Quantity ?? request.Quantity,
GeneratedAt = DateTimeOffset.UtcNow
};
private static void StampCreate(EntityBase entity, string actor)
{
entity.CreatedBy = actor;
entity.UpdatedBy = actor;
}
}
+2 -2
View File
@@ -8,8 +8,8 @@
},
"SimpleLite": {
"Enabled": true,
"ExecutablePath": "E:\\Work\\Core\\Simple-FR\\Simple\\SimpleLite\\bin\\Debug\\SimpleLite.exe",
"WorkingDirectory": "E:\\Work\\Core\\Simple-FR\\Simple\\SimpleLite\\bin\\Debug",
"ExecutablePath": "D:\\Code\\Products\\MIGU2.0\\SimpleLite\\SimpleLite.exe",
"WorkingDirectory": "D:\\Code\\Products\\MIGU2.0\\SimpleLite",
"ProjectionPort": 8222,
"ReadinessTimeoutMs": 8000,
"FollowParent": false
+2 -2
View File
@@ -25,8 +25,8 @@
"_comment_SimpleLite": "登录后拉起 SimpleLite;改路径请编辑下方 SimpleLite 节点。诊断: http://localhost:8080/api/health/simplelite",
"SimpleLite": {
"Enabled": true,
"ExecutablePath": "..\\..\\Simple\\SimpleLite\\bin\\Debug\\SimpleLite.exe",
"WorkingDirectory": "..\\..\\Simple\\SimpleLite\\bin\\Debug",
"ExecutablePath": "D:\\Code\\Products\\MIGU2.0\\SimpleLite\\SimpleLite.exe",
"WorkingDirectory": "D:\\Code\\Products\\MIGU2.0\\SimpleLite",
"Arguments": "",
"ReadinessTimeoutMs": 15000,
"ReadinessPollIntervalMs": 250,
Binary file not shown.