新增 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
+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;
}
}