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 PreviewAsync(WmsTransportRequest request) { if (!WmsTransportTriggerTypes.IsDefined(request.TriggerType)) throw new InvalidOperationException("触发类型无效"); var trigger = WmsTransportTriggerTypes.ParseOr(request.TriggerType); var ctx = await BuildContextAsync(); var rules = await LoadRulesAsync(request, trigger); var ruleResults = new List(); var candidates = new List(); 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, trigger, rule, sourceSelector, targetSelector, options); var rejectReasons = ruleCandidates.Count == 0 ? new List { "未找到满足条件的起点/终点组合" } : new List(); 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 PickBestAsync(WmsTransportRequest request) { var preview = await PreviewAsync(request); return preview.Candidates.FirstOrDefault(); } private async Task> LoadRulesAsync(WmsTransportRequest request, WmsTransportTriggerType trigger) { var query = _db.WmsTransportRules.AsNoTracking() .Where(x => x.Enabled && x.TriggerType == trigger); 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 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 now = DateTimeOffset.UtcNow; var activeReservations = await _db.WmsTransportReservations.AsNoTracking() .Where(x => x.Status == WmsReservationStatuses.Active && (x.ExpiresAt == null || x.ExpiresAt > now)) .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 BuildCandidates( PlannerContext ctx, WmsTransportRequest request, WmsTransportTriggerType trigger, WmsTransportRule rule, TransportSelector sourceSelector, TransportSelector targetSelector, TransportTaskOptions options) { var results = new List(); var sources = BuildSourceCandidates(ctx, request, trigger, sourceSelector, options); foreach (var src in sources) { var targets = BuildTargetCandidates(ctx, request, trigger, 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 BuildSourceCandidates( PlannerContext ctx, WmsTransportRequest request, WmsTransportTriggerType trigger, TransportSelector selector, TransportTaskOptions options) { var list = new List(); 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 && trigger == WmsTransportTriggerTypes.FinishedGoodsOffline && !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 BuildTargetCandidates( PlannerContext ctx, WmsTransportRequest request, WmsTransportTriggerType trigger, TransportSelector selector, TransportTaskOptions options, Guid sourceStorageId) { var list = new List(); 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 && trigger == WmsTransportTriggerTypes.MaterialCall && 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.ToString(), 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.ToString(), 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.ToString(), 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 SortSources(List list, TransportSelector selector) { IOrderedEnumerable? 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 SortTargets(List list, TransportSelector selector) { IOrderedEnumerable? 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 ApplySourceSort(IEnumerable 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 ApplySourceSort(IOrderedEnumerable 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 ApplyTargetSort(IEnumerable 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 ApplyTargetSort(IOrderedEnumerable list, RankingRule rule) => list.ThenBy(x => x.StorageCode); private sealed record PlannerContext( List Storages, List Containers, List Locations, List ContainerMaterials, List Materials, Dictionary StorageById, Dictionary ContainerById, Dictionary MaterialById, HashSet OccupiedStorageIds, HashSet ReservedContainers, HashSet ReservedTargetStorages, HashSet 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); }