新增 WMS 搬运规则与任务管理
支持搬运规则维护、候选预览、任务生成、预占、下发、取消和完成,并完善仓储管理前端交互。
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user