新增 WMS 搬运规则与任务管理
支持搬运规则维护、候选预览、任务生成、预占、下发、取消和完成,并完善仓储管理前端交互。
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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, "调度适配器未配置,任务保留在平台侧"));
|
||||
}
|
||||
@@ -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; } = "{}";
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
Reference in New Issue
Block a user