329 lines
16 KiB
C#
329 lines
16 KiB
C#
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);
|
|
}
|