201 lines
8.8 KiB
Plaintext
201 lines
8.8 KiB
Plaintext
@page "/clusters/{ClusterId}/draft/{GenerationId:long}/import-equipment"
|
|
@using Microsoft.AspNetCore.Components.Authorization
|
|
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
|
@inject DriverInstanceService DriverSvc
|
|
@inject UnsService UnsSvc
|
|
@inject EquipmentImportBatchService BatchSvc
|
|
@inject NavigationManager Nav
|
|
@inject AuthenticationStateProvider AuthProvider
|
|
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<div>
|
|
<h1 class="mb-0">Equipment CSV import</h1>
|
|
<small class="text-muted">Cluster <code>@ClusterId</code> · draft generation @GenerationId</small>
|
|
</div>
|
|
<a class="btn btn-outline-secondary" href="/clusters/@ClusterId/draft/@GenerationId">Back to draft</a>
|
|
</div>
|
|
|
|
<div class="alert alert-info small mb-3">
|
|
Accepts <code>@EquipmentCsvImporter.VersionMarker</code>-headered CSV per Stream B.3.
|
|
Required columns: @string.Join(", ", EquipmentCsvImporter.RequiredColumns).
|
|
Optional columns cover the OPC 40010 Identification fields. Paste the file contents
|
|
or upload directly — the parser runs client-stream-side and shows a row-level preview
|
|
before anything lands in the draft. ZTag + SAPID uniqueness across the fleet is NOT
|
|
enforced here yet (see task #197); for now the finalise may fail at commit time if a
|
|
reservation conflict exists.
|
|
</div>
|
|
|
|
<div class="card mb-3">
|
|
<div class="card-body">
|
|
<div class="row g-3">
|
|
<div class="col-md-5">
|
|
<label class="form-label">Target driver instance (for every accepted row)</label>
|
|
<select class="form-select" @bind="_driverInstanceId">
|
|
<option value="">-- select driver --</option>
|
|
@if (_drivers is not null)
|
|
{
|
|
@foreach (var d in _drivers) { <option value="@d.DriverInstanceId">@d.DriverInstanceId</option> }
|
|
}
|
|
</select>
|
|
</div>
|
|
<div class="col-md-5">
|
|
<label class="form-label">Target UNS line (for every accepted row)</label>
|
|
<select class="form-select" @bind="_unsLineId">
|
|
<option value="">-- select line --</option>
|
|
@if (_unsLines is not null)
|
|
{
|
|
@foreach (var l in _unsLines) { <option value="@l.UnsLineId">@l.UnsLineId — @l.Name</option> }
|
|
}
|
|
</select>
|
|
</div>
|
|
<div class="col-md-2 pt-4">
|
|
<InputFile OnChange="HandleFileAsync" class="form-control form-control-sm" accept=".csv,.txt"/>
|
|
</div>
|
|
</div>
|
|
<div class="mt-3">
|
|
<label class="form-label">CSV content (paste or uploaded)</label>
|
|
<textarea class="form-control font-monospace" rows="8" @bind="_csvText"
|
|
placeholder="# OtOpcUaCsv v1 ZTag,MachineCode,SAPID,EquipmentId,…"/>
|
|
</div>
|
|
<div class="mt-3">
|
|
<button class="btn btn-sm btn-outline-primary" @onclick="ParseAsync" disabled="@_busy">Parse</button>
|
|
<button class="btn btn-sm btn-primary ms-2" @onclick="StageAndFinaliseAsync"
|
|
disabled="@(_parseResult is null || _parseResult.AcceptedRows.Count == 0 || string.IsNullOrWhiteSpace(_driverInstanceId) || string.IsNullOrWhiteSpace(_unsLineId) || _busy)">
|
|
Stage + Finalise
|
|
</button>
|
|
@if (_parseError is not null) { <span class="alert alert-danger ms-3 py-1 px-2 small">@_parseError</span> }
|
|
@if (_result is not null) { <span class="alert alert-success ms-3 py-1 px-2 small">@_result</span> }
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
@if (_parseResult is not null)
|
|
{
|
|
<div class="row g-3">
|
|
<div class="col-md-6">
|
|
<div class="card">
|
|
<div class="card-header bg-success text-white">
|
|
Accepted (@_parseResult.AcceptedRows.Count)
|
|
</div>
|
|
<div class="card-body p-0" style="max-height: 400px; overflow-y: auto;">
|
|
@if (_parseResult.AcceptedRows.Count == 0)
|
|
{
|
|
<p class="text-muted p-3 mb-0">No accepted rows.</p>
|
|
}
|
|
else
|
|
{
|
|
<table class="table table-sm table-striped mb-0">
|
|
<thead>
|
|
<tr><th>ZTag</th><th>Machine</th><th>Name</th><th>Line</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var r in _parseResult.AcceptedRows)
|
|
{
|
|
<tr>
|
|
<td><code>@r.ZTag</code></td>
|
|
<td>@r.MachineCode</td>
|
|
<td>@r.Name</td>
|
|
<td>@r.UnsLineName</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<div class="card">
|
|
<div class="card-header bg-danger text-white">
|
|
Rejected (@_parseResult.RejectedRows.Count)
|
|
</div>
|
|
<div class="card-body p-0" style="max-height: 400px; overflow-y: auto;">
|
|
@if (_parseResult.RejectedRows.Count == 0)
|
|
{
|
|
<p class="text-muted p-3 mb-0">No rejections.</p>
|
|
}
|
|
else
|
|
{
|
|
<table class="table table-sm table-striped mb-0">
|
|
<thead><tr><th>Line</th><th>Reason</th></tr></thead>
|
|
<tbody>
|
|
@foreach (var e in _parseResult.RejectedRows)
|
|
{
|
|
<tr>
|
|
<td>@e.LineNumber</td>
|
|
<td class="small">@e.Reason</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
@code {
|
|
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
|
[Parameter] public long GenerationId { get; set; }
|
|
|
|
private List<DriverInstance>? _drivers;
|
|
private List<UnsLine>? _unsLines;
|
|
private string _driverInstanceId = string.Empty;
|
|
private string _unsLineId = string.Empty;
|
|
private string _csvText = string.Empty;
|
|
private EquipmentCsvParseResult? _parseResult;
|
|
private string? _parseError;
|
|
private string? _result;
|
|
private bool _busy;
|
|
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
_drivers = await DriverSvc.ListAsync(GenerationId, CancellationToken.None);
|
|
_unsLines = await UnsSvc.ListLinesAsync(GenerationId, CancellationToken.None);
|
|
}
|
|
|
|
private async Task HandleFileAsync(InputFileChangeEventArgs e)
|
|
{
|
|
// 5 MiB cap — refuses pathological uploads that would OOM the server.
|
|
using var stream = e.File.OpenReadStream(maxAllowedSize: 5 * 1024 * 1024);
|
|
using var reader = new StreamReader(stream);
|
|
_csvText = await reader.ReadToEndAsync();
|
|
}
|
|
|
|
private void ParseAsync()
|
|
{
|
|
_parseError = null;
|
|
_parseResult = null;
|
|
_result = null;
|
|
try { _parseResult = EquipmentCsvImporter.Parse(_csvText); }
|
|
catch (InvalidCsvFormatException ex) { _parseError = ex.Message; }
|
|
catch (Exception ex) { _parseError = $"Parse failed: {ex.Message}"; }
|
|
}
|
|
|
|
private async Task StageAndFinaliseAsync()
|
|
{
|
|
if (_parseResult is null) return;
|
|
_busy = true;
|
|
_result = null;
|
|
_parseError = null;
|
|
try
|
|
{
|
|
var auth = await AuthProvider.GetAuthenticationStateAsync();
|
|
var createdBy = auth.User.Identity?.Name ?? "unknown";
|
|
|
|
var batch = await BatchSvc.CreateBatchAsync(ClusterId, createdBy, CancellationToken.None);
|
|
await BatchSvc.StageRowsAsync(batch.Id, _parseResult.AcceptedRows, _parseResult.RejectedRows, CancellationToken.None);
|
|
await BatchSvc.FinaliseBatchAsync(batch.Id, GenerationId, _driverInstanceId, _unsLineId, CancellationToken.None);
|
|
|
|
_result = $"Finalised batch {batch.Id:N} — {_parseResult.AcceptedRows.Count} rows added.";
|
|
// Pause 600 ms so the success banner is visible, then navigate back.
|
|
await Task.Delay(600);
|
|
Nav.NavigateTo($"/clusters/{ClusterId}/draft/{GenerationId}");
|
|
}
|
|
catch (Exception ex) { _parseError = $"Finalise failed: {ex.Message}"; }
|
|
finally { _busy = false; }
|
|
}
|
|
}
|