feat(uns): equipment CRUD with #122 driver-cluster guard
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Parameter object carrying the operator-editable fields for an equipment create or update,
|
||||
/// so <see cref="IUnsTreeService.CreateEquipmentAsync"/> and
|
||||
/// <see cref="IUnsTreeService.UpdateEquipmentAsync"/> avoid an unwieldy positional signature.
|
||||
/// The <c>EquipmentId</c> and <c>EquipmentUuid</c> are system-generated (decision #125) and are
|
||||
/// therefore not part of this input. Optional string fields that arrive whitespace-only are
|
||||
/// collapsed to <c>null</c> by the service.
|
||||
/// </summary>
|
||||
/// <param name="Name">UNS level-5 segment; matches <c>^[a-z0-9-]{1,32}$</c>.</param>
|
||||
/// <param name="MachineCode">Operator colloquial id; unique fleet-wide.</param>
|
||||
/// <param name="UnsLineId">Logical FK to the owning <see cref="Configuration.Entities.UnsLine"/>.</param>
|
||||
/// <param name="DriverInstanceId">Optional driver binding; whitespace/empty means driver-less.</param>
|
||||
/// <param name="ZTag">Optional ERP equipment id.</param>
|
||||
/// <param name="SAPID">Optional SAP PM equipment id.</param>
|
||||
/// <param name="Manufacturer">Optional OPC 40010 manufacturer name.</param>
|
||||
/// <param name="Model">Optional OPC 40010 model designation.</param>
|
||||
/// <param name="SerialNumber">Optional OPC 40010 serial number.</param>
|
||||
/// <param name="HardwareRevision">Optional OPC 40010 hardware revision.</param>
|
||||
/// <param name="SoftwareRevision">Optional OPC 40010 software revision.</param>
|
||||
/// <param name="YearOfConstruction">Optional OPC 40010 year of construction.</param>
|
||||
/// <param name="AssetLocation">Optional OPC 40010 asset location.</param>
|
||||
/// <param name="ManufacturerUri">Optional OPC 40010 manufacturer URI.</param>
|
||||
/// <param name="DeviceManualUri">Optional OPC 40010 device-manual URI.</param>
|
||||
/// <param name="Enabled">Whether the equipment is surfaced in deployments.</param>
|
||||
public sealed record EquipmentInput(
|
||||
string Name,
|
||||
string MachineCode,
|
||||
string UnsLineId,
|
||||
string? DriverInstanceId,
|
||||
string? ZTag,
|
||||
string? SAPID,
|
||||
string? Manufacturer,
|
||||
string? Model,
|
||||
string? SerialNumber,
|
||||
string? HardwareRevision,
|
||||
string? SoftwareRevision,
|
||||
short? YearOfConstruction,
|
||||
string? AssetLocation,
|
||||
string? ManufacturerUri,
|
||||
string? DeviceManualUri,
|
||||
bool Enabled);
|
||||
@@ -100,4 +100,40 @@ public interface IUnsTreeService
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>Success, a concurrency failure, or a delete-failed failure.</returns>
|
||||
Task<UnsMutationResult> DeleteLineAsync(string unsLineId, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new equipment under a UNS line. The <c>EquipmentId</c> is system-generated
|
||||
/// (decision #125: <c>EQ-</c> + the first 12 hex chars of a fresh <c>EquipmentUuid</c>).
|
||||
/// Fails if the line is unset, if the MachineCode is already used fleet-wide, or if the
|
||||
/// decision-#122 driver-cluster guard trips. Whitespace-only DriverInstanceId/ZTag/SAPID
|
||||
/// collapse to <c>null</c>.
|
||||
/// </summary>
|
||||
/// <param name="input">The operator-editable equipment fields.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>Success, a missing-line failure, a duplicate-MachineCode failure, or a #122 guard failure.</returns>
|
||||
Task<UnsMutationResult> CreateEquipmentAsync(EquipmentInput input, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates an equipment's mutable fields (driver binding, line, name, MachineCode, external
|
||||
/// ids, and the OPC 40010 identification fields). The decision-#122 driver-cluster guard blocks
|
||||
/// binding to a driver in a different cluster than the equipment's line. Uses last-write-wins
|
||||
/// optimistic concurrency on <see cref="Configuration.Entities.Equipment.RowVersion"/>.
|
||||
/// </summary>
|
||||
/// <param name="equipmentId">The equipment to update.</param>
|
||||
/// <param name="input">The new operator-editable equipment fields.</param>
|
||||
/// <param name="rowVersion">The concurrency token the caller last read.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>Success, a missing-row failure, a #122 guard failure, or a concurrency failure.</returns>
|
||||
Task<UnsMutationResult> UpdateEquipmentAsync(string equipmentId, EquipmentInput input, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes an equipment. A missing row is treated as success (already gone). Uses last-write-wins
|
||||
/// optimistic concurrency; a delete that fails because tags or virtual tags still reference the
|
||||
/// equipment surfaces a guidance message.
|
||||
/// </summary>
|
||||
/// <param name="equipmentId">The equipment to delete.</param>
|
||||
/// <param name="rowVersion">The concurrency token the caller last read.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>Success, a concurrency failure, or a delete-failed failure.</returns>
|
||||
Task<UnsMutationResult> DeleteEquipmentAsync(string equipmentId, byte[] rowVersion, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -336,4 +336,171 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
return new UnsMutationResult(false, $"Delete failed: {ex.Message}. Likely because equipment still references this line — remove or re-home it first.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UnsMutationResult> CreateEquipmentAsync(EquipmentInput input, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input.UnsLineId))
|
||||
{
|
||||
return new UnsMutationResult(false, "Pick a UNS line.");
|
||||
}
|
||||
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
if (await db.Equipment.AnyAsync(e => e.MachineCode == input.MachineCode, ct))
|
||||
{
|
||||
return new UnsMutationResult(false, $"MachineCode '{input.MachineCode}' already exists in this fleet.");
|
||||
}
|
||||
|
||||
var guard = await CheckDriverClusterGuardAsync(db, input, ct);
|
||||
if (guard is not null)
|
||||
{
|
||||
return guard.Value;
|
||||
}
|
||||
|
||||
var uuid = Guid.NewGuid();
|
||||
var equipmentId = $"EQ-{uuid.ToString("N")[..12]}";
|
||||
|
||||
db.Equipment.Add(new Equipment
|
||||
{
|
||||
EquipmentId = equipmentId,
|
||||
EquipmentUuid = uuid,
|
||||
DriverInstanceId = string.IsNullOrWhiteSpace(input.DriverInstanceId) ? null : input.DriverInstanceId,
|
||||
UnsLineId = input.UnsLineId,
|
||||
Name = input.Name,
|
||||
MachineCode = input.MachineCode,
|
||||
ZTag = string.IsNullOrWhiteSpace(input.ZTag) ? null : input.ZTag,
|
||||
SAPID = string.IsNullOrWhiteSpace(input.SAPID) ? null : input.SAPID,
|
||||
Manufacturer = input.Manufacturer,
|
||||
Model = input.Model,
|
||||
SerialNumber = input.SerialNumber,
|
||||
HardwareRevision = input.HardwareRevision,
|
||||
SoftwareRevision = input.SoftwareRevision,
|
||||
YearOfConstruction = input.YearOfConstruction,
|
||||
AssetLocation = input.AssetLocation,
|
||||
ManufacturerUri = input.ManufacturerUri,
|
||||
DeviceManualUri = input.DeviceManualUri,
|
||||
Enabled = input.Enabled,
|
||||
});
|
||||
await db.SaveChangesAsync(ct);
|
||||
return new UnsMutationResult(true, null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UnsMutationResult> UpdateEquipmentAsync(
|
||||
string equipmentId,
|
||||
EquipmentInput input,
|
||||
byte[] rowVersion,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
var entity = await db.Equipment.FirstOrDefaultAsync(e => e.EquipmentId == equipmentId, ct);
|
||||
if (entity is null)
|
||||
{
|
||||
return new UnsMutationResult(false, "Row no longer exists.");
|
||||
}
|
||||
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
|
||||
|
||||
var guard = await CheckDriverClusterGuardAsync(db, input, ct);
|
||||
if (guard is not null)
|
||||
{
|
||||
return guard.Value;
|
||||
}
|
||||
|
||||
entity.DriverInstanceId = string.IsNullOrWhiteSpace(input.DriverInstanceId) ? null : input.DriverInstanceId;
|
||||
entity.UnsLineId = input.UnsLineId;
|
||||
entity.Name = input.Name;
|
||||
entity.MachineCode = input.MachineCode;
|
||||
entity.ZTag = string.IsNullOrWhiteSpace(input.ZTag) ? null : input.ZTag;
|
||||
entity.SAPID = string.IsNullOrWhiteSpace(input.SAPID) ? null : input.SAPID;
|
||||
entity.Manufacturer = input.Manufacturer;
|
||||
entity.Model = input.Model;
|
||||
entity.SerialNumber = input.SerialNumber;
|
||||
entity.HardwareRevision = input.HardwareRevision;
|
||||
entity.SoftwareRevision = input.SoftwareRevision;
|
||||
entity.YearOfConstruction = input.YearOfConstruction;
|
||||
entity.AssetLocation = input.AssetLocation;
|
||||
entity.ManufacturerUri = input.ManufacturerUri;
|
||||
entity.DeviceManualUri = input.DeviceManualUri;
|
||||
entity.Enabled = input.Enabled;
|
||||
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(ct);
|
||||
return new UnsMutationResult(true, null);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
return new UnsMutationResult(false, "Another user changed this equipment while you were editing.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UnsMutationResult> DeleteEquipmentAsync(
|
||||
string equipmentId,
|
||||
byte[] rowVersion,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
var entity = await db.Equipment.FirstOrDefaultAsync(e => e.EquipmentId == equipmentId, ct);
|
||||
if (entity is null)
|
||||
{
|
||||
return new UnsMutationResult(true, null);
|
||||
}
|
||||
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
|
||||
db.Equipment.Remove(entity);
|
||||
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(ct);
|
||||
return new UnsMutationResult(true, null);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
return new UnsMutationResult(false, "Another user changed this equipment while you were viewing it.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new UnsMutationResult(false, $"Delete failed: {ex.Message}. Likely because tags or virtual tags reference this equipment — remove them first.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decision #122: an equipment may only bind to a driver in the same cluster as its line.
|
||||
/// Only runs when a driver is requested. Resolves the line's cluster (line → area → cluster)
|
||||
/// and the driver's cluster; when both are known and differ, returns the guard failure.
|
||||
/// Returns <c>null</c> when the bind is allowed (driver-less, unresolvable cluster, or match).
|
||||
/// </summary>
|
||||
private static async Task<UnsMutationResult?> CheckDriverClusterGuardAsync(
|
||||
OtOpcUaConfigDbContext db,
|
||||
EquipmentInput input,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input.DriverInstanceId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var line = await db.UnsLines.FirstOrDefaultAsync(l => l.UnsLineId == input.UnsLineId, ct);
|
||||
var area = line is null ? null : await db.UnsAreas.FirstOrDefaultAsync(a => a.UnsAreaId == line.UnsAreaId, ct);
|
||||
var lineCluster = area?.ClusterId;
|
||||
|
||||
var driverCluster = await db.DriverInstances
|
||||
.Where(d => d.DriverInstanceId == input.DriverInstanceId)
|
||||
.Select(d => d.ClusterId)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (driverCluster is not null && lineCluster is not null && driverCluster != lineCluster)
|
||||
{
|
||||
return new UnsMutationResult(
|
||||
false,
|
||||
$"Driver '{input.DriverInstanceId}' is in cluster '{driverCluster}' but the line is in cluster '{lineCluster}' (decision #122).");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user