feat(transport): preview diff + required-mapping detection + blockers (M8 C2)
This commit is contained in:
@@ -7,6 +7,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
@@ -65,6 +66,7 @@ public sealed class BundleImporter : IBundleImporter
|
||||
private readonly IExternalSystemRepository _externalRepo;
|
||||
private readonly INotificationRepository _notificationRepo;
|
||||
private readonly IInboundApiRepository _inboundApiRepo;
|
||||
private readonly ISiteRepository _siteRepo;
|
||||
private readonly IBundleSessionStore _sessionStore;
|
||||
private readonly BundleUnlockRateLimiter _unlockRateLimiter;
|
||||
private readonly IOptions<TransportOptions> _options;
|
||||
@@ -87,6 +89,7 @@ public sealed class BundleImporter : IBundleImporter
|
||||
/// <param name="externalRepo">External system repository for diff and apply.</param>
|
||||
/// <param name="notificationRepo">Notification repository for diff and apply.</param>
|
||||
/// <param name="inboundApiRepo">Inbound API repository for diff and apply.</param>
|
||||
/// <param name="siteRepo">Site repository — supplies the target sites and site-scoped data connections that the preview's site/connection auto-match resolves against (M8 C2).</param>
|
||||
/// <param name="auditService">Audit service for writing per-entity import audit rows.</param>
|
||||
/// <param name="correlationContext">Correlation context that carries the active BundleImportId.</param>
|
||||
/// <param name="dbContext">EF Core context used to commit the import transaction.</param>
|
||||
@@ -104,6 +107,7 @@ public sealed class BundleImporter : IBundleImporter
|
||||
IExternalSystemRepository externalRepo,
|
||||
INotificationRepository notificationRepo,
|
||||
IInboundApiRepository inboundApiRepo,
|
||||
ISiteRepository siteRepo,
|
||||
IAuditService auditService,
|
||||
IAuditCorrelationContext correlationContext,
|
||||
ScadaBridgeDbContext dbContext,
|
||||
@@ -122,6 +126,7 @@ public sealed class BundleImporter : IBundleImporter
|
||||
_externalRepo = externalRepo ?? throw new ArgumentNullException(nameof(externalRepo));
|
||||
_notificationRepo = notificationRepo ?? throw new ArgumentNullException(nameof(notificationRepo));
|
||||
_inboundApiRepo = inboundApiRepo ?? throw new ArgumentNullException(nameof(inboundApiRepo));
|
||||
_siteRepo = siteRepo ?? throw new ArgumentNullException(nameof(siteRepo));
|
||||
_auditService = auditService ?? throw new ArgumentNullException(nameof(auditService));
|
||||
_correlationContext = correlationContext ?? throw new ArgumentNullException(nameof(correlationContext));
|
||||
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
|
||||
@@ -427,10 +432,195 @@ public sealed class BundleImporter : IBundleImporter
|
||||
items.Add(_diff.CompareApiMethod(m, existing));
|
||||
}
|
||||
|
||||
// ---- Blocker detection ----
|
||||
items.AddRange(await DetectBlockersAsync(content, ct).ConfigureAwait(false));
|
||||
// ---- M8 site/instance-scoped types ----
|
||||
// Sites/DataConnections/Instances are referenced by stable string identity
|
||||
// (SiteIdentifier / connection Name / UniqueName) and resolved against the
|
||||
// TARGET environment's own surrogate keys. We auto-match the source site by
|
||||
// identifier; the result drives both the per-type diffs (a matched target
|
||||
// connection / instance feeds CompareDataConnection / CompareInstance) and
|
||||
// the operator-facing required-mapping list built below.
|
||||
//
|
||||
// Cache target-site lookups + their data connections so we don't re-query
|
||||
// the same site once per instance / connection / native-alarm override.
|
||||
var targetSiteByIdentifier = new Dictionary<string, Site?>(StringComparer.Ordinal);
|
||||
var targetConnectionsBySiteIdentifier =
|
||||
new Dictionary<string, IReadOnlyList<DataConnection>>(StringComparer.Ordinal);
|
||||
|
||||
return new ImportPreview(sessionId, items);
|
||||
async Task<Site?> ResolveTargetSiteAsync(string siteIdentifier)
|
||||
{
|
||||
if (targetSiteByIdentifier.TryGetValue(siteIdentifier, out var cached)) return cached;
|
||||
var site = await _siteRepo.GetSiteByIdentifierAsync(siteIdentifier, ct).ConfigureAwait(false);
|
||||
targetSiteByIdentifier[siteIdentifier] = site;
|
||||
return site;
|
||||
}
|
||||
|
||||
async Task<IReadOnlyList<DataConnection>> ResolveTargetConnectionsAsync(string siteIdentifier)
|
||||
{
|
||||
if (targetConnectionsBySiteIdentifier.TryGetValue(siteIdentifier, out var cached)) return cached;
|
||||
var site = await ResolveTargetSiteAsync(siteIdentifier).ConfigureAwait(false);
|
||||
IReadOnlyList<DataConnection> conns = site is null
|
||||
? Array.Empty<DataConnection>()
|
||||
: await _siteRepo.GetDataConnectionsBySiteIdAsync(site.Id, ct).ConfigureAwait(false);
|
||||
targetConnectionsBySiteIdentifier[siteIdentifier] = conns;
|
||||
return conns;
|
||||
}
|
||||
|
||||
// ---- Sites ----
|
||||
foreach (var siteDto in content.Sites)
|
||||
{
|
||||
var existing = await ResolveTargetSiteAsync(siteDto.SiteIdentifier).ConfigureAwait(false);
|
||||
items.Add(_diff.CompareSite(siteDto, existing));
|
||||
}
|
||||
|
||||
// ---- DataConnections (site-scoped; matched by name within the auto-matched target site) ----
|
||||
foreach (var dcDto in content.DataConnections)
|
||||
{
|
||||
var targetConns = await ResolveTargetConnectionsAsync(dcDto.SiteIdentifier).ConfigureAwait(false);
|
||||
var existing = targetConns.FirstOrDefault(c => string.Equals(c.Name, dcDto.Name, StringComparison.Ordinal));
|
||||
items.Add(_diff.CompareDataConnection(dcDto, existing));
|
||||
}
|
||||
|
||||
// ---- Instances (hydrated target + resolved template/site/area names; review item I2) ----
|
||||
foreach (var instDto in content.Instances)
|
||||
{
|
||||
// GetInstanceByUniqueNameAsync eagerly Includes all four child nav
|
||||
// collections (AttributeOverrides / AlarmOverrides / ConnectionBindings /
|
||||
// NativeAlarmSourceOverrides), so the entity handed to CompareInstance is
|
||||
// HYDRATED — its children diff correctly instead of every incoming child
|
||||
// reading as an addition (review item I2).
|
||||
var existing = await _templateRepo
|
||||
.GetInstanceByUniqueNameAsync(instDto.UniqueName, ct).ConfigureAwait(false);
|
||||
|
||||
string? existingTemplateName = null;
|
||||
string? existingSiteIdentifier = null;
|
||||
string? existingAreaName = null;
|
||||
if (existing is not null)
|
||||
{
|
||||
// The entity stores template/site/area as numeric FKs that can't be
|
||||
// compared cross-environment, so resolve each to the same stable
|
||||
// string identity the incoming DTO carries.
|
||||
var tmpl = await _templateRepo.GetTemplateByIdAsync(existing.TemplateId, ct).ConfigureAwait(false);
|
||||
existingTemplateName = tmpl?.Name;
|
||||
|
||||
var site = await _siteRepo.GetSiteByIdAsync(existing.SiteId, ct).ConfigureAwait(false);
|
||||
existingSiteIdentifier = site?.SiteIdentifier;
|
||||
|
||||
if (existing.AreaId is int areaId)
|
||||
{
|
||||
var area = await _templateRepo.GetAreaByIdAsync(areaId, ct).ConfigureAwait(false);
|
||||
existingAreaName = area?.Name;
|
||||
}
|
||||
}
|
||||
|
||||
items.Add(_diff.CompareInstance(
|
||||
instDto, existing, existingTemplateName, existingSiteIdentifier, existingAreaName));
|
||||
}
|
||||
|
||||
// ---- Required site/connection mappings (M8) ----
|
||||
var (requiredSites, requiredConnections) = await BuildRequiredMappingsAsync(
|
||||
content, ResolveTargetSiteAsync, ResolveTargetConnectionsAsync).ConfigureAwait(false);
|
||||
|
||||
// ---- Blocker detection ----
|
||||
items.AddRange(await DetectBlockersAsync(content, ResolveTargetConnectionsAsync, ct).ConfigureAwait(false));
|
||||
|
||||
return new ImportPreview(sessionId, items, requiredSites, requiredConnections);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collects the distinct set of source sites and (site, connection) pairs the
|
||||
/// bundle references, auto-matching each against the TARGET environment by
|
||||
/// identity, and returns the operator-facing required-mapping lists (M8 C2).
|
||||
/// <para>
|
||||
/// Site references are drawn from every instance, every site, and every
|
||||
/// data-connection in the bundle. Connection references are drawn from every
|
||||
/// instance connection-binding, every non-null native-alarm-source
|
||||
/// <c>ConnectionNameOverride</c>, and every bundled data-connection. A source
|
||||
/// site auto-matches when the target has a site with the same identifier; a
|
||||
/// connection auto-matches when that target site additionally carries a
|
||||
/// connection of the same name. No match leaves <c>AutoMatchTarget*</c> null —
|
||||
/// the operator must supply an explicit mapping (or accept create-new) at
|
||||
/// apply time. The diff path above already populated the resolver caches, so
|
||||
/// these lookups are served from memory.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static async Task<(IReadOnlyList<RequiredSiteMapping> Sites, IReadOnlyList<RequiredConnectionMapping> Connections)>
|
||||
BuildRequiredMappingsAsync(
|
||||
BundleContentDto content,
|
||||
Func<string, Task<Site?>> resolveTargetSite,
|
||||
Func<string, Task<IReadOnlyList<DataConnection>>> resolveTargetConnections)
|
||||
{
|
||||
// Distinct source-site identifiers, with a best-effort display name.
|
||||
// SiteDto carries a Name; instances / data-connections only carry the
|
||||
// identifier, so default that to the identifier when no SiteDto is present.
|
||||
var siteNameByIdentifier = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
void NoteSite(string identifier, string? name)
|
||||
{
|
||||
if (string.IsNullOrEmpty(identifier)) return;
|
||||
if (!siteNameByIdentifier.TryGetValue(identifier, out var existing)
|
||||
|| (string.Equals(existing, identifier, StringComparison.Ordinal) && !string.IsNullOrEmpty(name)))
|
||||
{
|
||||
siteNameByIdentifier[identifier] = string.IsNullOrEmpty(name) ? identifier : name;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var s in content.Sites) NoteSite(s.SiteIdentifier, s.Name);
|
||||
foreach (var i in content.Instances) NoteSite(i.SiteIdentifier, null);
|
||||
foreach (var dc in content.DataConnections) NoteSite(dc.SiteIdentifier, null);
|
||||
|
||||
// Distinct (sourceSite, connectionName) pairs referenced anywhere.
|
||||
var connectionRefs = new HashSet<(string Site, string Name)>();
|
||||
foreach (var i in content.Instances)
|
||||
{
|
||||
NoteSite(i.SiteIdentifier, null);
|
||||
foreach (var b in i.ConnectionBindings)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(b.ConnectionName))
|
||||
{
|
||||
connectionRefs.Add((i.SiteIdentifier, b.ConnectionName));
|
||||
}
|
||||
}
|
||||
foreach (var n in i.NativeAlarmSourceOverrides)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(n.ConnectionNameOverride))
|
||||
{
|
||||
connectionRefs.Add((i.SiteIdentifier, n.ConnectionNameOverride));
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach (var dc in content.DataConnections)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(dc.Name))
|
||||
{
|
||||
connectionRefs.Add((dc.SiteIdentifier, dc.Name));
|
||||
}
|
||||
}
|
||||
|
||||
var siteMappings = new List<RequiredSiteMapping>();
|
||||
foreach (var identifier in siteNameByIdentifier.Keys.OrderBy(k => k, StringComparer.Ordinal))
|
||||
{
|
||||
var target = await resolveTargetSite(identifier).ConfigureAwait(false);
|
||||
siteMappings.Add(new RequiredSiteMapping(
|
||||
SourceSiteIdentifier: identifier,
|
||||
SourceSiteName: siteNameByIdentifier[identifier],
|
||||
AutoMatchTargetIdentifier: target?.SiteIdentifier));
|
||||
}
|
||||
|
||||
var connectionMappings = new List<RequiredConnectionMapping>();
|
||||
foreach (var (site, name) in connectionRefs
|
||||
.OrderBy(r => r.Site, StringComparer.Ordinal)
|
||||
.ThenBy(r => r.Name, StringComparer.Ordinal))
|
||||
{
|
||||
// Auto-match only WITHIN the auto-matched target site: a connection of
|
||||
// the same name under a different site is not a valid match.
|
||||
var targetConns = await resolveTargetConnections(site).ConfigureAwait(false);
|
||||
var matched = targetConns.Any(c => string.Equals(c.Name, name, StringComparison.Ordinal));
|
||||
connectionMappings.Add(new RequiredConnectionMapping(
|
||||
SourceSiteIdentifier: site,
|
||||
SourceConnectionName: name,
|
||||
AutoMatchTargetName: matched ? name : null));
|
||||
}
|
||||
|
||||
return (siteMappings, connectionMappings);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -442,10 +632,92 @@ public sealed class BundleImporter : IBundleImporter
|
||||
/// the resolver's scan operates on entity Code while the importer's scan
|
||||
/// operates on DTO Code — same algorithm, different inputs.
|
||||
/// </summary>
|
||||
private async Task<IReadOnlyList<ImportPreviewItem>> DetectBlockersAsync(BundleContentDto content, CancellationToken ct)
|
||||
private async Task<IReadOnlyList<ImportPreviewItem>> DetectBlockersAsync(
|
||||
BundleContentDto content,
|
||||
Func<string, Task<IReadOnlyList<DataConnection>>> resolveTargetConnections,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var blockers = new List<ImportPreviewItem>();
|
||||
|
||||
// ---- M8: instance template + referenced-connection blockers ----
|
||||
// The set of template names the import can satisfy = (in-bundle templates)
|
||||
// ∪ (templates already in the target DB). An instance whose TemplateName is
|
||||
// in neither is unresolvable.
|
||||
var bundleTemplateNames = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var t in content.Templates) bundleTemplateNames.Add(t.Name);
|
||||
// Honour a rename: a bundled template resolved as Rename is created under
|
||||
// its new name, but at preview time no resolution has been chosen yet, so
|
||||
// the in-bundle name is the original DTO name — which is what an instance
|
||||
// references. (Explicit rename remap is a D-wave apply-time concern.)
|
||||
var targetTemplateNames = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var t in await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
targetTemplateNames.Add(t.Name);
|
||||
}
|
||||
|
||||
foreach (var inst in content.Instances)
|
||||
{
|
||||
if (string.IsNullOrEmpty(inst.TemplateName)) continue;
|
||||
if (bundleTemplateNames.Contains(inst.TemplateName)) continue;
|
||||
if (targetTemplateNames.Contains(inst.TemplateName)) continue;
|
||||
blockers.Add(new ImportPreviewItem(
|
||||
EntityType: "Instance",
|
||||
Name: inst.UniqueName,
|
||||
ExistingVersion: null,
|
||||
IncomingVersion: null,
|
||||
Kind: ConflictKind.Blocker,
|
||||
FieldDiffJson: null,
|
||||
BlockerReason: $"Template '{inst.TemplateName}' not found in bundle or target."));
|
||||
}
|
||||
|
||||
// A referenced (sourceSite, connectionName) pair is resolvable when it is
|
||||
// either carried in the bundle's DataConnections OR auto-matches a connection
|
||||
// of the same name in the auto-matched target site. Genuinely-missing
|
||||
// references are blockers. (Explicit operator connection maps are applied in
|
||||
// a later wave; preview's auto-match is identity-based.)
|
||||
var bundleConnections = new HashSet<(string Site, string Name)>();
|
||||
foreach (var dc in content.DataConnections)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(dc.Name)) bundleConnections.Add((dc.SiteIdentifier, dc.Name));
|
||||
}
|
||||
|
||||
// Distinct referenced pairs from instance bindings + native-alarm overrides.
|
||||
var referencedConnections = new HashSet<(string Site, string Name)>();
|
||||
foreach (var inst in content.Instances)
|
||||
{
|
||||
foreach (var b in inst.ConnectionBindings)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(b.ConnectionName))
|
||||
{
|
||||
referencedConnections.Add((inst.SiteIdentifier, b.ConnectionName));
|
||||
}
|
||||
}
|
||||
foreach (var n in inst.NativeAlarmSourceOverrides)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(n.ConnectionNameOverride))
|
||||
{
|
||||
referencedConnections.Add((inst.SiteIdentifier, n.ConnectionNameOverride));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var (site, name) in referencedConnections
|
||||
.OrderBy(r => r.Site, StringComparer.Ordinal)
|
||||
.ThenBy(r => r.Name, StringComparer.Ordinal))
|
||||
{
|
||||
if (bundleConnections.Contains((site, name))) continue;
|
||||
var targetConns = await resolveTargetConnections(site).ConfigureAwait(false);
|
||||
if (targetConns.Any(c => string.Equals(c.Name, name, StringComparison.Ordinal))) continue;
|
||||
blockers.Add(new ImportPreviewItem(
|
||||
EntityType: "Instance",
|
||||
Name: name,
|
||||
ExistingVersion: null,
|
||||
IncomingVersion: null,
|
||||
Kind: ConflictKind.Blocker,
|
||||
FieldDiffJson: null,
|
||||
BlockerReason: $"Connection '{site}/{name}' unresolved — present in neither bundle nor target."));
|
||||
}
|
||||
|
||||
// Known-resolvable names = (in-bundle) ∪ (already-in-target).
|
||||
var allSharedScripts = await _templateRepo.GetAllSharedScriptsAsync(ct).ConfigureAwait(false);
|
||||
var allExternalSystems = await _externalRepo.GetAllExternalSystemsAsync(ct).ConfigureAwait(false);
|
||||
|
||||
Reference in New Issue
Block a user