Files
Migu2.0/MiGu.Server/Wms/WmsTransportPlanner.cs
T
wei.wu 51c3fc1994 持久层重构为独立 MiGu.DB 工程并优化集成
- 新增 MiGu.DB 项目,迁移所有领域实体与枚举,统一模型约定
- 实现 Entity/Repository/UoW/Provider/Exception 等接口与实现
- 支持数据修补机制,完善 Sqlite 初始迁移与数据库管理
- Server 侧移除 EF Core 相关,依赖 MiGu.DB,PlatformPersistence 适配
- 业务服务注入 UoW/Repository,状态字段统一用 enum 及辅助类
- 统一异常处理,Controller 映射 HTTP 状态码
- 配置项与文档补充数据库启动、SchemaMode、迁移说明
- 新增 GlobalUsings.Db.cs、WmsStatusAliases.cs 简化类型引用
- 新增 HttpActorContextMiddleware 支持操作者上下文一致性
- 新增 MiGuDbContextModelSnapshot 追踪数据库结构
- 优化代码结构,解耦领域与持久层,提升扩展性与安全性
2026-07-27 14:26:04 +08:00

336 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.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<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, trigger, 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, 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<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,
WmsTransportTriggerType trigger,
WmsTransportRule rule,
TransportSelector sourceSelector,
TransportSelector targetSelector,
TransportTaskOptions options)
{
var results = new List<TransportCandidatePair>();
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<SourceCandidate> BuildSourceCandidates(
PlannerContext ctx,
WmsTransportRequest request,
WmsTransportTriggerType trigger,
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 &&
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<TargetCandidate> BuildTargetCandidates(
PlannerContext ctx,
WmsTransportRequest request,
WmsTransportTriggerType trigger,
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 &&
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<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);
}