V13-FE-011: finalize search list layout slice
This commit is contained in:
+39
@@ -0,0 +1,39 @@
|
||||
using FastEndpoints;
|
||||
using Hangfire;
|
||||
using Modules.Common.Imports.Jobs;
|
||||
using Shared.Excel;
|
||||
|
||||
namespace Modules.Common.Imports.Endpoints;
|
||||
|
||||
public sealed class CommitEndpoint(ImportRepository repository, IBackgroundJobClient jobs)
|
||||
: EndpointWithoutRequest<ImportSessionDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/imports/sessions/{sessionId:guid}/commit");
|
||||
Permissions("imports.execute");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var id = Route<Guid>("sessionId");
|
||||
var tenant = ImportIdentity.TenantId(User);
|
||||
var actor = ImportIdentity.UserId(User);
|
||||
var session = await repository.GetAsync(id, tenant, actor, false, ct);
|
||||
if (session is null) { await Send.NotFoundAsync(ct); return; }
|
||||
if (session.Status != ImportStatuses.Validated)
|
||||
{
|
||||
AddError("검증 완료된 Import만 반영할 수 있습니다.");
|
||||
await Send.ErrorsAsync(cancellation: ct);
|
||||
return;
|
||||
}
|
||||
var acquired = await repository.TryTransitionAsync(id, [ImportStatuses.Validated], ImportStatuses.Committing, 1, ct);
|
||||
if (acquired)
|
||||
{
|
||||
try { jobs.Enqueue<CommitImportJob>(job => job.RunAsync(id, actor, CancellationToken.None)); }
|
||||
catch { await repository.SetStatusAsync(id, ImportStatuses.Validated, 100, ct); throw; }
|
||||
}
|
||||
var current = await repository.GetAsync(id, tenant, actor, false, ct);
|
||||
await Send.OkAsync(current!, ct);
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
using FastEndpoints;
|
||||
using Shared.Excel;
|
||||
|
||||
namespace Modules.Common.Imports.Endpoints;
|
||||
|
||||
public sealed class CreateSessionRequest
|
||||
{
|
||||
public string ImportType { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class CreateSessionEndpoint(
|
||||
ImportDefinitionRegistry registry,
|
||||
ImportRepository repository,
|
||||
ClosedXmlWorkbookService workbook,
|
||||
ImportMappingEngine mappingEngine,
|
||||
IImportMappingSuggester mappingSuggester,
|
||||
XlsxSafetyInspector safety)
|
||||
: Endpoint<CreateSessionRequest, ImportSessionDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/imports/sessions");
|
||||
AllowFileUploads();
|
||||
Permissions("imports.execute");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CreateSessionRequest req, CancellationToken ct)
|
||||
{
|
||||
if (!registry.TryGet(req.ImportType, out var importDefinition) || importDefinition is null)
|
||||
{
|
||||
await Send.NotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var file = Files.FirstOrDefault();
|
||||
if (file is null || file.Length == 0)
|
||||
{
|
||||
AddError("Excel 파일을 선택하세요.");
|
||||
await Send.ErrorsAsync(cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var definition = importDefinition.Definition;
|
||||
if (file.Length > definition.MaxFileSizeBytes)
|
||||
{
|
||||
AddError($"파일 크기는 최대 {definition.MaxFileSizeBytes / 1024 / 1024}MB입니다.");
|
||||
await Send.ErrorsAsync(cancellation: ct);
|
||||
return;
|
||||
}
|
||||
if (!Path.GetExtension(file.FileName).Equals(".xlsx", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
AddError(".xlsx 파일만 업로드할 수 있습니다.");
|
||||
await Send.ErrorsAsync(cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
await using var source = file.OpenReadStream();
|
||||
using var buffer = new MemoryStream();
|
||||
await source.CopyToAsync(buffer, ct);
|
||||
var bytes = buffer.ToArray();
|
||||
try { safety.EnsureSafe(bytes); }
|
||||
catch (InvalidDataException ex)
|
||||
{
|
||||
AddError(ex.Message);
|
||||
await Send.ErrorsAsync(cancellation: ct);
|
||||
return;
|
||||
}
|
||||
await using var read = new MemoryStream(bytes, writable: false);
|
||||
var columns = workbook.ReadHeaders(read);
|
||||
var tenantId = ImportIdentity.TenantId(User);
|
||||
var userId = ImportIdentity.UserId(User);
|
||||
var saved = await repository.GetSavedMappingAsync(tenantId, userId, definition.Id, columns, ct);
|
||||
var mapping = mappingEngine.Map(columns, definition, saved).ToList();
|
||||
var unresolved = mapping.Where(x => x.TargetField is null).Select(x => x.SourceColumn).ToArray();
|
||||
if (unresolved.Length > 0)
|
||||
{
|
||||
var suggestions = await mappingSuggester.SuggestAsync(unresolved, definition, ct);
|
||||
var allowed = definition.Fields.Select(x => x.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var suggestion in suggestions.Where(x => x.TargetField is not null && allowed.Contains(x.TargetField)))
|
||||
{
|
||||
var index = mapping.FindIndex(x => x.SourceColumn.Equals(suggestion.SourceColumn, StringComparison.OrdinalIgnoreCase) && x.TargetField is null);
|
||||
if (index >= 0) mapping[index] = suggestion with { Source = "ai" };
|
||||
}
|
||||
}
|
||||
var sessionId = await repository.CreateSessionAsync(
|
||||
tenantId, userId, definition, file.FileName,
|
||||
file.ContentType ?? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
bytes, columns, mapping, ct);
|
||||
|
||||
var session = await repository.GetAsync(sessionId, tenantId, userId, includeErrors: false, ct);
|
||||
await Send.OkAsync(session!, ct);
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
using FastEndpoints;
|
||||
using Shared.Excel;
|
||||
|
||||
namespace Modules.Common.Imports.Endpoints;
|
||||
|
||||
public sealed class ErrorWorkbookEndpoint(ImportRepository repository, ClosedXmlWorkbookService workbook)
|
||||
: EndpointWithoutRequest
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/imports/sessions/{sessionId:guid}/errors.xlsx");
|
||||
Permissions("imports.execute");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var id = Route<Guid>("sessionId");
|
||||
var session = await repository.GetAsync(id, ImportIdentity.TenantId(User), ImportIdentity.UserId(User), false, ct);
|
||||
if (session is null) { await Send.NotFoundAsync(ct); return; }
|
||||
var (_, bytes) = await repository.GetFileAsync(id, ct);
|
||||
await using var source = new MemoryStream(bytes, writable: false);
|
||||
var errors = await repository.GetErrorsAsync(id, ct);
|
||||
var output = workbook.CreateErrorWorkbook(source, errors);
|
||||
await Send.StreamAsync(output,
|
||||
fileName: $"{Path.GetFileNameWithoutExtension(session.FileName)}_errors.xlsx",
|
||||
fileLengthBytes: output.Length,
|
||||
contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
cancellation: ct);
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
using FastEndpoints;
|
||||
using Shared.Excel;
|
||||
|
||||
namespace Modules.Common.Imports.Endpoints;
|
||||
|
||||
public sealed class GetSessionEndpoint(ImportRepository repository)
|
||||
: EndpointWithoutRequest<ImportSessionDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/imports/sessions/{sessionId:guid}");
|
||||
Permissions("imports.execute");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var id = Route<Guid>("sessionId");
|
||||
var session = await repository.GetAsync(id, ImportIdentity.TenantId(User), ImportIdentity.UserId(User), includeErrors: true, ct);
|
||||
if (session is null) { await Send.NotFoundAsync(ct); return; }
|
||||
await Send.OkAsync(session, ct);
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
using FastEndpoints;
|
||||
using Shared.Excel;
|
||||
|
||||
namespace Modules.Common.Imports.Endpoints;
|
||||
|
||||
public sealed record SaveMappingRequest(IReadOnlyList<ImportMapping> Mappings);
|
||||
|
||||
public sealed class SaveMappingEndpoint(ImportRepository repository)
|
||||
: Endpoint<SaveMappingRequest, ImportSessionDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Put("/api/imports/sessions/{sessionId:guid}/mapping");
|
||||
Permissions("imports.execute");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(SaveMappingRequest req, CancellationToken ct)
|
||||
{
|
||||
var id = Route<Guid>("sessionId");
|
||||
var tenant = ImportIdentity.TenantId(User);
|
||||
var user = ImportIdentity.UserId(User);
|
||||
if (await repository.GetAsync(id, tenant, user, false, ct) is null) { await Send.NotFoundAsync(ct); return; }
|
||||
await repository.SaveMappingAsync(id, req.Mappings, ct);
|
||||
var session = await repository.GetAsync(id, tenant, user, false, ct);
|
||||
await Send.OkAsync(session!, ct);
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
using FastEndpoints;
|
||||
using Shared.Excel;
|
||||
|
||||
namespace Modules.Common.Imports.Endpoints;
|
||||
|
||||
public sealed record SaveNamedMappingRequest(string Name, IReadOnlyList<ImportMapping> Mappings);
|
||||
|
||||
public sealed class SaveNamedMappingEndpoint(ImportRepository repository)
|
||||
: Endpoint<SaveNamedMappingRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/imports/sessions/{sessionId:guid}/saved-mappings");
|
||||
Permissions("imports.execute");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(SaveNamedMappingRequest req, CancellationToken ct)
|
||||
{
|
||||
var id = Route<Guid>("sessionId");
|
||||
var tenant = ImportIdentity.TenantId(User);
|
||||
var user = ImportIdentity.UserId(User);
|
||||
var session = await repository.GetAsync(id, tenant, user, false, ct);
|
||||
if (session is null) { await Send.NotFoundAsync(ct); return; }
|
||||
await repository.SaveNamedMappingAsync(tenant, user, session.ImportType, req.Name, session.SourceColumns, req.Mappings, ct);
|
||||
await Send.NoContentAsync(ct);
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
using FastEndpoints;
|
||||
using Shared.Excel;
|
||||
|
||||
namespace Modules.Common.Imports.Endpoints;
|
||||
|
||||
public sealed class TemplateEndpoint(ImportDefinitionRegistry registry, ClosedXmlWorkbookService workbook)
|
||||
: EndpointWithoutRequest
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/imports/{importType}/template");
|
||||
Permissions("imports.execute");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var importType = Route<string>("importType")!;
|
||||
if (!registry.TryGet(importType, out var definition) || definition is null) { await Send.NotFoundAsync(ct); return; }
|
||||
var stream = workbook.CreateTemplate(definition.Definition);
|
||||
await Send.StreamAsync(stream,
|
||||
fileName: $"{definition.Definition.Entity}_import_template.xlsx",
|
||||
fileLengthBytes: stream.Length,
|
||||
contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
cancellation: ct);
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
using FastEndpoints;
|
||||
using Hangfire;
|
||||
using Modules.Common.Imports.Jobs;
|
||||
using Shared.Excel;
|
||||
|
||||
namespace Modules.Common.Imports.Endpoints;
|
||||
|
||||
public sealed class ValidateEndpoint(ImportRepository repository, IBackgroundJobClient jobs)
|
||||
: EndpointWithoutRequest<ImportSessionDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/imports/sessions/{sessionId:guid}/validate");
|
||||
Permissions("imports.execute");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var id = Route<Guid>("sessionId");
|
||||
var tenant = ImportIdentity.TenantId(User);
|
||||
var user = ImportIdentity.UserId(User);
|
||||
var session = await repository.GetAsync(id, tenant, user, false, ct);
|
||||
if (session is null) { await Send.NotFoundAsync(ct); return; }
|
||||
|
||||
var acquired = await repository.TryTransitionAsync(id,
|
||||
[ImportStatuses.MappingRequired, ImportStatuses.Uploaded, ImportStatuses.Failed],
|
||||
ImportStatuses.Validating, 1, ct);
|
||||
if (acquired)
|
||||
{
|
||||
try { jobs.Enqueue<ValidateImportJob>(job => job.RunAsync(id, CancellationToken.None)); }
|
||||
catch { await repository.SetStatusAsync(id, ImportStatuses.MappingRequired, 0, ct); throw; }
|
||||
}
|
||||
var current = await repository.GetAsync(id, tenant, user, false, ct);
|
||||
await Send.OkAsync(current!, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Shared.Excel;
|
||||
|
||||
namespace Modules.Common.Imports.Jobs;
|
||||
|
||||
public sealed class CommitImportJob(
|
||||
ImportRepository repository,
|
||||
ImportDefinitionRegistry registry,
|
||||
ImportProgressPublisher progress)
|
||||
{
|
||||
public async Task RunAsync(Guid sessionId, string actor, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var (importType, _) = await repository.GetWorkDefinitionAsync(sessionId, ct);
|
||||
var definition = registry.Get(importType);
|
||||
await progress.PublishAsync(new ImportProgressEvent(
|
||||
sessionId, ImportStatuses.Committing, 1, 0, 0, 0, 0, 0,
|
||||
"검증된 데이터를 반영하고 있습니다."), ct);
|
||||
|
||||
var result = await definition.CommitAsync(sessionId, actor, ct);
|
||||
await repository.CompleteAsync(sessionId, result, ct);
|
||||
await progress.PublishAsync(new ImportProgressEvent(
|
||||
sessionId,
|
||||
result.Failed > 0 ? ImportStatuses.PartiallyCompleted : ImportStatuses.Completed,
|
||||
100,
|
||||
result.Created + result.Updated + result.Failed,
|
||||
result.Created + result.Updated + result.Failed,
|
||||
result.Created + result.Updated,
|
||||
result.Failed,
|
||||
0,
|
||||
result.Failed > 0 ? "일부 데이터가 오류로 제외되었습니다." : "반영이 완료되었습니다."), ct);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
await repository.SetStatusAsync(sessionId, ImportStatuses.Failed, 100, ct);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
using Npgsql;
|
||||
using Shared.Excel;
|
||||
|
||||
namespace Modules.Common.Imports.Jobs;
|
||||
|
||||
public sealed class ValidateImportJob(
|
||||
ImportRepository repository,
|
||||
ImportDefinitionRegistry registry,
|
||||
ClosedXmlWorkbookService workbook,
|
||||
ImportProgressPublisher progress,
|
||||
NpgsqlDataSource dataSource)
|
||||
{
|
||||
public async Task RunAsync(Guid sessionId, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var (importType, mapping) = await repository.GetWorkDefinitionAsync(sessionId, ct);
|
||||
var definition = registry.Get(importType);
|
||||
|
||||
var targetToSource = mapping
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x.TargetField))
|
||||
.ToDictionary(x => x.TargetField!, x => x.SourceColumn, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var (contentType, bytes) = await repository.GetFileAsync(sessionId, ct);
|
||||
await using var stream = new MemoryStream(bytes, writable: false);
|
||||
var rows = workbook.ReadRows(stream, definition.Definition.MaxRows).ToArray();
|
||||
await progress.PublishAsync(new ImportProgressEvent(
|
||||
sessionId, ImportStatuses.Validating, 15, rows.Length, 0, 0, 0, 0,
|
||||
"참조 데이터를 확인하고 있습니다."), ct);
|
||||
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var results = await definition.ValidateAsync(rows, targetToSource, connection, ct);
|
||||
var valid = results.Count(x => x.IsValid);
|
||||
var invalid = results.Count - valid;
|
||||
var warnings = results.Count(x => x.Warnings.Count > 0);
|
||||
|
||||
await progress.PublishAsync(new ImportProgressEvent(
|
||||
sessionId, ImportStatuses.Validating, 80, rows.Length, rows.Length,
|
||||
valid, invalid, warnings, "검증 결과를 저장하고 있습니다."), ct);
|
||||
|
||||
await repository.ReplaceRowsAsync(sessionId, results, ct);
|
||||
await progress.PublishAsync(new ImportProgressEvent(
|
||||
sessionId, ImportStatuses.Validated, 100, rows.Length, rows.Length,
|
||||
valid, invalid, warnings, "검증이 완료되었습니다."), ct);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
await repository.SetStatusAsync(sessionId, ImportStatuses.Failed, 100, ct);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user