using Microsoft.EntityFrameworkCore; using ZB.MOM.WW.OtOpcUa.Admin.Services; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; namespace ZB.MOM.WW.OtOpcUa.Admin.Services; /// /// Staged-import orchestrator per Phase 6.4 Stream B.2-B.4. Covers the four operator /// actions: CreateBatch → StageRows (chunked) → FinaliseBatch (atomic apply into /// ) → DropBatch (rollback of pre-finalise state). /// /// /// FinaliseBatch runs inside one EF transaction + bulk-inserts accepted rows into /// . Rejected rows stay behind as audit evidence; the batch row /// gains so future writes know it's /// archived. DropBatch removes the batch + its cascaded rows. /// /// Idempotence: calling FinaliseBatch twice throws /// rather than double-inserting. Operator refreshes the admin page to see the first /// finalise completed. /// /// ExternalIdReservation merging (ZTag + SAPID uniqueness) is NOT done here — a /// narrower follow-up wires it once the concurrent-insert test matrix is green. /// public sealed class EquipmentImportBatchService(OtOpcUaConfigDbContext db) { /// Create a new empty batch header. Returns the row with Id populated. public async Task CreateBatchAsync(string clusterId, string createdBy, CancellationToken ct) { ArgumentException.ThrowIfNullOrWhiteSpace(clusterId); ArgumentException.ThrowIfNullOrWhiteSpace(createdBy); var batch = new EquipmentImportBatch { Id = Guid.NewGuid(), ClusterId = clusterId, CreatedBy = createdBy, CreatedAtUtc = DateTime.UtcNow, }; db.EquipmentImportBatches.Add(batch); await db.SaveChangesAsync(ct).ConfigureAwait(false); return batch; } /// /// Stage one chunk of rows into the batch. Caller usually feeds /// output here — each /// becomes one accepted , /// each rejected parser error becomes one row with false. /// public async Task StageRowsAsync( Guid batchId, IReadOnlyList acceptedRows, IReadOnlyList rejectedRows, CancellationToken ct) { var batch = await db.EquipmentImportBatches.FirstOrDefaultAsync(b => b.Id == batchId, ct).ConfigureAwait(false) ?? throw new ImportBatchNotFoundException($"Batch {batchId} not found."); if (batch.FinalisedAtUtc is not null) throw new ImportBatchAlreadyFinalisedException( $"Batch {batchId} finalised at {batch.FinalisedAtUtc:o}; no more rows can be staged."); foreach (var row in acceptedRows) { db.EquipmentImportRows.Add(new EquipmentImportRow { Id = Guid.NewGuid(), BatchId = batchId, IsAccepted = true, ZTag = row.ZTag, MachineCode = row.MachineCode, SAPID = row.SAPID, EquipmentId = row.EquipmentId, EquipmentUuid = row.EquipmentUuid, Name = row.Name, UnsAreaName = row.UnsAreaName, UnsLineName = row.UnsLineName, Manufacturer = row.Manufacturer, Model = row.Model, SerialNumber = row.SerialNumber, HardwareRevision = row.HardwareRevision, SoftwareRevision = row.SoftwareRevision, YearOfConstruction = row.YearOfConstruction, AssetLocation = row.AssetLocation, ManufacturerUri = row.ManufacturerUri, DeviceManualUri = row.DeviceManualUri, }); } foreach (var error in rejectedRows) { db.EquipmentImportRows.Add(new EquipmentImportRow { Id = Guid.NewGuid(), BatchId = batchId, IsAccepted = false, RejectReason = error.Reason, LineNumberInFile = error.LineNumber, // Required columns need values for EF; reject rows use sentinel placeholders. ZTag = "", MachineCode = "", SAPID = "", EquipmentId = "", EquipmentUuid = "", Name = "", UnsAreaName = "", UnsLineName = "", }); } batch.RowsStaged += acceptedRows.Count + rejectedRows.Count; batch.RowsAccepted += acceptedRows.Count; batch.RowsRejected += rejectedRows.Count; await db.SaveChangesAsync(ct).ConfigureAwait(false); } /// Drop the batch (pre-finalise rollback). Cascaded row delete removes staged rows. public async Task DropBatchAsync(Guid batchId, CancellationToken ct) { var batch = await db.EquipmentImportBatches.FirstOrDefaultAsync(b => b.Id == batchId, ct).ConfigureAwait(false); if (batch is null) return; if (batch.FinalisedAtUtc is not null) throw new ImportBatchAlreadyFinalisedException( $"Batch {batchId} already finalised at {batch.FinalisedAtUtc:o}; cannot drop."); db.EquipmentImportBatches.Remove(batch); await db.SaveChangesAsync(ct).ConfigureAwait(false); } /// /// Atomic finalise. Inserts every accepted row into the live /// table under the target generation + stamps /// . Failure rolls the whole tx /// back — never partially mutates. /// public async Task FinaliseBatchAsync( Guid batchId, long generationId, string driverInstanceIdForRows, string unsLineIdForRows, CancellationToken ct) { var batch = await db.EquipmentImportBatches .Include(b => b.Rows) .FirstOrDefaultAsync(b => b.Id == batchId, ct) .ConfigureAwait(false) ?? throw new ImportBatchNotFoundException($"Batch {batchId} not found."); if (batch.FinalisedAtUtc is not null) throw new ImportBatchAlreadyFinalisedException( $"Batch {batchId} already finalised at {batch.FinalisedAtUtc:o}."); // EF InMemory provider doesn't honour BeginTransaction; SQL Server provider does. // Tests run the happy path under in-memory; production SQL Server runs the atomic tx. var supportsTx = db.Database.IsRelational(); Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction? tx = null; if (supportsTx) tx = await db.Database.BeginTransactionAsync(ct).ConfigureAwait(false); try { foreach (var row in batch.Rows.Where(r => r.IsAccepted)) { db.Equipment.Add(new Equipment { EquipmentRowId = Guid.NewGuid(), GenerationId = generationId, EquipmentId = row.EquipmentId, EquipmentUuid = Guid.TryParse(row.EquipmentUuid, out var u) ? u : Guid.NewGuid(), DriverInstanceId = driverInstanceIdForRows, UnsLineId = unsLineIdForRows, Name = row.Name, MachineCode = row.MachineCode, ZTag = row.ZTag, SAPID = row.SAPID, Manufacturer = row.Manufacturer, Model = row.Model, SerialNumber = row.SerialNumber, HardwareRevision = row.HardwareRevision, SoftwareRevision = row.SoftwareRevision, YearOfConstruction = short.TryParse(row.YearOfConstruction, out var y) ? y : null, AssetLocation = row.AssetLocation, ManufacturerUri = row.ManufacturerUri, DeviceManualUri = row.DeviceManualUri, }); } batch.FinalisedAtUtc = DateTime.UtcNow; await db.SaveChangesAsync(ct).ConfigureAwait(false); if (tx is not null) await tx.CommitAsync(ct).ConfigureAwait(false); } catch { if (tx is not null) await tx.RollbackAsync(ct).ConfigureAwait(false); throw; } finally { if (tx is not null) await tx.DisposeAsync().ConfigureAwait(false); } } /// List batches created by the given user. Finalised batches are archived; include them on demand. public async Task> ListByUserAsync(string createdBy, bool includeFinalised, CancellationToken ct) { var query = db.EquipmentImportBatches.AsNoTracking().Where(b => b.CreatedBy == createdBy); if (!includeFinalised) query = query.Where(b => b.FinalisedAtUtc == null); return await query.OrderByDescending(b => b.CreatedAtUtc).ToListAsync(ct).ConfigureAwait(false); } } public sealed class ImportBatchNotFoundException(string message) : Exception(message); public sealed class ImportBatchAlreadyFinalisedException(string message) : Exception(message);