V13-FE-011: finalize search list layout slice
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
using Dapper;
|
||||
using FastEndpoints;
|
||||
using KBX.Shared.Experience;
|
||||
using Npgsql;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace KBX.Modules.Common.Ai.Ask;
|
||||
|
||||
public sealed record Request(string Question, AiScreenContext Context);
|
||||
|
||||
public sealed class Endpoint(IKbxAiAssistantProvider assistant, NpgsqlDataSource dataSource) : Endpoint<Request, AiAnswer>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/common/ai/ask");
|
||||
Permissions("common.ai.use");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(Request req, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(req.Question) || req.Question.Length > 2000)
|
||||
{
|
||||
AddError(r => r.Question, "질문을 1~2000자로 입력하세요.");
|
||||
await Send.ErrorsAsync(cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Host application replaces this with authenticated claims resolution.
|
||||
var tenantId = Guid.Empty;
|
||||
var userId = Guid.Empty;
|
||||
var sw = Stopwatch.StartNew();
|
||||
var answer = await assistant.AskAsync(tenantId, userId, new AiAskCommand(req.Question.Trim(), req.Context), ct);
|
||||
sw.Stop();
|
||||
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await connection.ExecuteAsync(new CommandDefinition("""
|
||||
insert into kbx.ai_interactions
|
||||
(id, tenant_id, user_id, screen_id, screen_version, capability, result_kind, provider, duration_ms)
|
||||
values
|
||||
(@Id, @TenantId, @UserId, @ScreenId, @ScreenVersion, 'explain', @ResultKind, @Provider, @DurationMs)
|
||||
""", new
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
req.Context.ScreenId,
|
||||
req.Context.ScreenVersion,
|
||||
ResultKind = answer.Proposal is null ? "answer" : "proposal",
|
||||
Provider = assistant.GetType().Name,
|
||||
DurationMs = (int)sw.ElapsedMilliseconds,
|
||||
}, cancellationToken: ct));
|
||||
|
||||
await Send.OkAsync(answer, ct);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
using FastEndpoints;using Kbx.Shared.Experiments;using Kbx.Shared.Runtime;
|
||||
namespace Kbx.Modules.Common.Experiments.Assignments;
|
||||
public sealed record Request(string ScreenId);
|
||||
public sealed class Endpoint(KbxExperimentAssignmentService service):Endpoint<Request,KbxExperimentAssignmentsResponse>{public override void Configure(){Get("/api/kbx/experiments/assignments");Permissions("common.experiment.evaluate");}public override async Task HandleAsync(Request req,CancellationToken ct)=>await Send.OkAsync(await service.EvaluateAsync(RuntimeIdentity.TenantId(User),RuntimeIdentity.UserId(User),req.ScreenId,ct),ct);}
|
||||
@@ -0,0 +1,3 @@
|
||||
using FastEndpoints;using Kbx.Shared.Experiments;using Kbx.Shared.Runtime;
|
||||
namespace Kbx.Modules.Common.Experiments.Overview;
|
||||
public sealed class Endpoint(KbxExperimentOverviewQuery query):EndpointWithoutRequest<KbxExperimentOverviewResponse>{public override void Configure(){Get("/api/kbx/experiments");Permissions("common.experiment.read");}public override async Task HandleAsync(CancellationToken ct)=>await Send.OkAsync(await query.ExecuteAsync(RuntimeIdentity.TenantId(User),ct),ct);}
|
||||
@@ -0,0 +1,4 @@
|
||||
using FastEndpoints;using Kbx.Shared.Experiments;using Kbx.Shared.Runtime;
|
||||
namespace Kbx.Modules.Common.Experiments.Rollback;
|
||||
public sealed record Request(string ExperimentId,string Reason);
|
||||
public sealed class Endpoint(KbxExperimentRuntimeStore store):Endpoint<Request>{public override void Configure(){Post("/api/kbx/experiments/{experimentId}/rollback");Permissions("common.experiment.manage");}public override async Task HandleAsync(Request req,CancellationToken ct){await store.RollbackAsync(RuntimeIdentity.TenantId(User),RuntimeIdentity.UserId(User),req.ExperimentId,req.Reason,HttpContext.TraceIdentifier,ct);await Send.OkAsync(ct);}}
|
||||
@@ -0,0 +1,4 @@
|
||||
using FastEndpoints;using Kbx.Shared.Experiments;using Kbx.Shared.Runtime;
|
||||
namespace Kbx.Modules.Common.Experiments.Rollout;
|
||||
public sealed record Request(string ExperimentId,int RolloutPercent,string State,string Reason);
|
||||
public sealed class Endpoint(KbxExperimentRuntimeStore store):Endpoint<Request>{public override void Configure(){Post("/api/kbx/experiments/{experimentId}/rollout");Permissions("common.experiment.manage");}public override async Task HandleAsync(Request req,CancellationToken ct){await store.UpdateRolloutAsync(RuntimeIdentity.TenantId(User),RuntimeIdentity.UserId(User),req.ExperimentId,req.RolloutPercent,req.State,req.Reason,HttpContext.TraceIdentifier,ct);await Send.OkAsync(ct);}}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
using FastEndpoints;
|
||||
using Kbx.Shared.ExternalData;
|
||||
using Kbx.Shared.ExternalData.Generated;
|
||||
using Kbx.Shared.Runtime;
|
||||
using Shared.Problems;
|
||||
|
||||
namespace Kbx.Modules.Common.ExternalData.Refresh;
|
||||
|
||||
public sealed record Response(string DatasetId,string Status);
|
||||
public sealed class Endpoint(IKbxExternalDataRefreshScheduler scheduler):EndpointWithoutRequest<Response>
|
||||
{
|
||||
public override void Configure(){Post("/api/kbx/external-data/{datasetId}/refresh");Permissions("common.external-data.refresh");}
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var datasetId=Route<string>("datasetId")!;
|
||||
if(!KbxExternalDataCatalog.All.TryGetValue(datasetId,out var dataset))
|
||||
{ await Send.ResponseAsync(KbxNotFoundProblem.Create("EXTERNAL_DATASET_NOT_FOUND","외부 데이터셋을 찾을 수 없습니다."),404,cancellation:ct); return; }
|
||||
if(dataset.FreshnessMode=="provider-defined" && dataset.FreshForSeconds is null)
|
||||
{ await Send.ResponseAsync(KbxBusinessProblem.Create("EXTERNAL_DATA_POLICY_REQUIRED","승인된 서비스별 신선도 정책이 먼저 필요합니다.","KRX 범용 데이터셋에는 임의 TTL을 적용하지 않습니다."),422,cancellation:ct); return; }
|
||||
await scheduler.ScheduleDatasetAsync(RuntimeIdentity.TenantId(User),datasetId,ct);
|
||||
await Send.ResponseAsync(new Response(datasetId,"queued"),202,cancellation:ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using Dapper;
|
||||
using FastEndpoints;
|
||||
using Npgsql;
|
||||
using Kbx.Shared.ExternalData.Generated;
|
||||
using Kbx.Shared.Runtime;
|
||||
|
||||
namespace Kbx.Modules.Common.ExternalData.Status;
|
||||
|
||||
public sealed record Row(string DatasetId,string ProviderId,string SourceLabel,string State,long CacheEntries,DateTimeOffset? LastReceivedAt,DateTimeOffset? OldestFreshUntil,long StaleEntries,long UnavailableEntries);
|
||||
public sealed record Response(IReadOnlyList<Row> Items);
|
||||
file sealed record DbRow(string DatasetId,string ProviderId,string State,long CacheEntries,DateTimeOffset? LastReceivedAt,DateTimeOffset? OldestFreshUntil,long StaleEntries,long UnavailableEntries);
|
||||
|
||||
public sealed class Endpoint(NpgsqlDataSource dataSource):EndpointWithoutRequest<Response>
|
||||
{
|
||||
public override void Configure(){Get("/api/kbx/external-data/status");Permissions("common.external-data.read");}
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
const string sql="""
|
||||
select dataset_id as DatasetId, provider_id as ProviderId,
|
||||
case
|
||||
when bool_or(fresh_until is not null and now() <= fresh_until) then 'fresh'
|
||||
when bool_or(usable_until is not null and now() <= usable_until) then 'stale'
|
||||
when bool_or(state='unavailable') then 'unavailable'
|
||||
else 'expired'
|
||||
end as State,
|
||||
count(*) as CacheEntries,
|
||||
max(received_at) as LastReceivedAt,
|
||||
min(fresh_until) as OldestFreshUntil,
|
||||
count(*) filter(where fresh_until is not null and now()>fresh_until and usable_until is not null and now()<=usable_until) as StaleEntries,
|
||||
count(*) filter(where state='unavailable') as UnavailableEntries
|
||||
from kbx.external_data_cache
|
||||
where tenant_id=@TenantId
|
||||
group by dataset_id,provider_id
|
||||
""";
|
||||
await using var connection=await dataSource.OpenConnectionAsync(ct);
|
||||
var db=(await connection.QueryAsync<DbRow>(new CommandDefinition(sql,new{TenantId=RuntimeIdentity.TenantId(User)},cancellationToken:ct))).ToDictionary(x=>x.DatasetId,StringComparer.Ordinal);
|
||||
var rows=KbxExternalDataCatalog.All.Values.OrderBy(x=>x.Id,StringComparer.Ordinal).Select(dataset=>{
|
||||
if(db.TryGetValue(dataset.Id,out var current))return new Row(dataset.Id,dataset.ProviderId,dataset.SourceLabel,current.State,current.CacheEntries,current.LastReceivedAt,current.OldestFreshUntil,current.StaleEntries,current.UnavailableEntries);
|
||||
var state=dataset.FreshnessMode=="provider-defined" && dataset.FreshForSeconds is null ? "unavailable" : "expired";
|
||||
return new Row(dataset.Id,dataset.ProviderId,dataset.SourceLabel,state,0,null,null,0,state=="unavailable"?1:0);
|
||||
}).ToArray();
|
||||
await Send.OkAsync(new Response(rows),ct);
|
||||
}
|
||||
}
|
||||
+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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
using Dapper;using FastEndpoints;using Npgsql;using Kbx.Shared.Runtime;using Shared.Problems;
|
||||
namespace Kbx.Modules.Common.Integrations.Attempts.Get;
|
||||
public sealed record Response(Guid Id,string IntegrationId,string MessageId,string AggregateType,string AggregateId,string State,int AttemptNo,string? FailureCode,string? Detail,DateTimeOffset StartedAt,DateTimeOffset? CompletedAt,DateTimeOffset? NextRetryAt,string CorrelationId);
|
||||
public sealed class Endpoint(NpgsqlDataSource dataSource):EndpointWithoutRequest<object>
|
||||
{
|
||||
public override void Configure(){Get("/api/integrations/attempts/{attemptId:guid}");Permissions("common.integration.read");}
|
||||
public override async Task HandleAsync(CancellationToken ct){var attemptId=Route<Guid>("attemptId");await using var c=await dataSource.OpenConnectionAsync(ct);const string sql="""select id, integration_id as IntegrationId, message_id::text as MessageId, aggregate_type as AggregateType, aggregate_id as AggregateId, state, attempt_no as AttemptNo, failure_code as FailureCode, detail, started_at as StartedAt, completed_at as CompletedAt, next_retry_at as NextRetryAt, correlation_id as CorrelationId from kbx.integration_attempts where id=@Id and tenant_id=@TenantId""";var row=await c.QuerySingleOrDefaultAsync<Response>(new CommandDefinition(sql,new{Id=attemptId,TenantId=RuntimeIdentity.TenantId(User)},cancellationToken:ct));if(row is null){await Send.ResponseAsync(KbxNotFoundProblem.Create("INTEGRATION_ATTEMPT_NOT_FOUND","연계 이력을 찾을 수 없습니다."),404,cancellation:ct);return;}await Send.OkAsync(row,ct);}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using FastEndpoints;
|
||||
using Npgsql;
|
||||
using Kbx.Shared.Runtime;
|
||||
using Shared.Problems;
|
||||
|
||||
namespace Kbx.Modules.Common.Integrations.Attempts.Retry;
|
||||
|
||||
public sealed record Request(string? IdempotencyKey = null);
|
||||
public sealed record Response(Guid AttemptId,string State,string Message);
|
||||
|
||||
public sealed class Endpoint(NpgsqlDataSource dataSource):Endpoint<Request,object>
|
||||
{
|
||||
private const string OperationId = "common.integrations.retryAttempt";
|
||||
public override void Configure(){Post("/api/integrations/attempts/{attemptId:guid}/retry");Permissions("common.integration.retry");}
|
||||
|
||||
public override async Task HandleAsync(Request req,CancellationToken ct)
|
||||
{
|
||||
var attemptId=Route<Guid>("attemptId");
|
||||
var key=HttpContext.Request.Headers["Idempotency-Key"].FirstOrDefault()??req.IdempotencyKey;
|
||||
if(string.IsNullOrWhiteSpace(key)){
|
||||
await Send.ResponseAsync(KbxValidationProblem.Create(new KbxValidationError(null,null,"IDEMPOTENCY_KEY_REQUIRED","안전한 재처리를 위해 Idempotency-Key가 필요합니다.")),400,cancellation:ct);return;
|
||||
}
|
||||
await using var c=await dataSource.OpenConnectionAsync(ct);
|
||||
await using var tx=await c.BeginTransactionAsync(ct);
|
||||
var replay=await c.QuerySingleOrDefaultAsync<string?>(new CommandDefinition(
|
||||
"select response_json::text from kbx.command_receipts where operation_id=@OperationId and idempotency_key=@Key",
|
||||
new { OperationId, Key=key },tx,cancellationToken:ct));
|
||||
if(replay is not null){await tx.RollbackAsync(ct);await Send.OkAsync(JsonSerializer.Deserialize<Response>(replay)!,ct);return;}
|
||||
|
||||
const string sql="""
|
||||
update kbx.integration_attempts
|
||||
set state='retrying', next_retry_at=now(), manual_retry_key=@Key
|
||||
where id=@Id and tenant_id=@TenantId and state='failed'
|
||||
returning id;
|
||||
""";
|
||||
var id=await c.ExecuteScalarAsync<Guid?>(new CommandDefinition(sql,new{Id=attemptId,TenantId=RuntimeIdentity.TenantId(User),Key=key},tx,cancellationToken:ct));
|
||||
if(id is null){await tx.RollbackAsync(ct);await Send.ResponseAsync(KbxNotFoundProblem.Create("INTEGRATION_ATTEMPT_NOT_RETRYABLE","재처리할 수 있는 연계 실패를 찾지 못했습니다."),404,cancellation:ct);return;}
|
||||
|
||||
var response=new Response(id.Value,"retrying","재처리를 예약했습니다.");
|
||||
await c.ExecuteAsync(new CommandDefinition("insert into kbx.command_receipts(operation_id,idempotency_key,response_json) values(@OperationId,@Key,cast(@Json as jsonb))",
|
||||
new { OperationId, Key=key, Json=JsonSerializer.Serialize(response) },tx,cancellationToken:ct));
|
||||
await tx.CommitAsync(ct);
|
||||
await Send.OkAsync(response,ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Dapper;
|
||||
using FastEndpoints;
|
||||
using Npgsql;
|
||||
using Shared.Operations;
|
||||
|
||||
namespace Modules.Common.Operations.Claim;
|
||||
|
||||
public sealed record ClaimWorkItemsRequest(IReadOnlyList<Guid> Ids);
|
||||
public sealed record ClaimWorkItemsResponse(int ClaimedCount, int SkippedCount);
|
||||
|
||||
public sealed class Endpoint(NpgsqlDataSource dataSource) : Endpoint<ClaimWorkItemsRequest, ClaimWorkItemsResponse>
|
||||
{
|
||||
public override void Configure() { Post("/api/operations/work-items/claim"); Permissions("common.operations.claim"); }
|
||||
|
||||
public override async Task HandleAsync(ClaimWorkItemsRequest req, CancellationToken ct)
|
||||
{
|
||||
var ids = req.Ids.Distinct().Take(500).ToArray();
|
||||
if (ids.Length == 0) { await Send.OkAsync(new(0, 0), ct); return; }
|
||||
var tenant = OperationsIdentity.TenantId(User);
|
||||
var actorId = OperationsIdentity.UserId(User);
|
||||
var actorName = OperationsIdentity.UserName(User);
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var tx = await connection.BeginTransactionAsync(ct);
|
||||
|
||||
var changed = (await connection.QueryAsync<(Guid Id, string Status)>(new CommandDefinition("""
|
||||
update kbx.work_items
|
||||
set status='claimed', owner_id=@ActorId, owner_name=@ActorName, version=version+1, updated_at=now()
|
||||
where tenant_id=@Tenant and id=any(@Ids) and status='open'
|
||||
returning id, 'open'::text as Status;
|
||||
""", new { Tenant = tenant, Ids = ids, ActorId = actorId, ActorName = actorName }, tx, cancellationToken: ct))).AsList();
|
||||
|
||||
foreach (var row in changed)
|
||||
await connection.ExecuteAsync(new CommandDefinition("""
|
||||
insert into kbx.work_item_audit(id, work_item_id, tenant_id, action, actor_id, actor_name, before_status, after_status)
|
||||
values(@Id, @WorkItemId, @Tenant, 'claim', @ActorId, @ActorName, 'open', 'claimed');
|
||||
""", new { Id = Guid.NewGuid(), WorkItemId = row.Id, Tenant = tenant, ActorId = actorId, ActorName = actorName }, tx, cancellationToken: ct));
|
||||
|
||||
await tx.CommitAsync(ct);
|
||||
await Send.OkAsync(new(changed.Count, ids.Length - changed.Count), ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using Dapper;
|
||||
using FastEndpoints;
|
||||
using Npgsql;
|
||||
using Shared.Operations;
|
||||
|
||||
namespace Modules.Common.Operations.Resolve;
|
||||
|
||||
public sealed record ResolveWorkItemsRequest(IReadOnlyList<Guid> Ids, string Reason);
|
||||
public sealed record ResolveWorkItemsResponse(int ResolvedCount, int RejectedCount);
|
||||
|
||||
public sealed class Endpoint(NpgsqlDataSource dataSource) : Endpoint<ResolveWorkItemsRequest, ResolveWorkItemsResponse>
|
||||
{
|
||||
public override void Configure() { Post("/api/operations/work-items/resolve"); Permissions("common.operations.resolve"); }
|
||||
|
||||
public override async Task HandleAsync(ResolveWorkItemsRequest req, CancellationToken ct)
|
||||
{
|
||||
var ids = req.Ids.Distinct().Take(500).ToArray();
|
||||
var tenant = OperationsIdentity.TenantId(User);
|
||||
var actorId = OperationsIdentity.UserId(User);
|
||||
var actorName = OperationsIdentity.UserName(User);
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var tx = await connection.BeginTransactionAsync(ct);
|
||||
|
||||
// Manual resolution is opt-in. Domain-originated exceptions should normally be resolved
|
||||
// by source-module events through OperationsProjectionWriter.ResolveBySourceAsync().
|
||||
var changed = (await connection.QueryAsync<Guid>(new CommandDefinition("""
|
||||
update kbx.work_items
|
||||
set status='resolved', resolution_reason=@Reason, resolved_at=now(), version=version+1, updated_at=now()
|
||||
where tenant_id=@Tenant and id=any(@Ids) and allow_manual_resolution=true and status in ('open','claimed')
|
||||
returning id;
|
||||
""", new { Tenant = tenant, Ids = ids, Reason = req.Reason }, tx, cancellationToken: ct))).AsList();
|
||||
|
||||
foreach (var id in changed)
|
||||
await connection.ExecuteAsync(new CommandDefinition("""
|
||||
insert into kbx.work_item_audit(id, work_item_id, tenant_id, action, actor_id, actor_name, reason, after_status)
|
||||
values(@Id, @WorkItemId, @Tenant, 'manual-resolve', @ActorId, @ActorName, @Reason, 'resolved');
|
||||
""", new { Id = Guid.NewGuid(), WorkItemId = id, Tenant = tenant, ActorId = actorId, ActorName = actorName, Reason = req.Reason }, tx, cancellationToken: ct));
|
||||
|
||||
await tx.CommitAsync(ct);
|
||||
await Send.OkAsync(new(changed.Count, ids.Length - changed.Count), ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Dapper;
|
||||
using FastEndpoints;
|
||||
using Npgsql;
|
||||
using Shared.Operations;
|
||||
|
||||
namespace Modules.Common.Operations.Retry;
|
||||
|
||||
public sealed class Endpoint(NpgsqlDataSource dataSource, WorkItemActionRegistry registry) : EndpointWithoutRequest
|
||||
{
|
||||
public override void Configure() { Post("/api/operations/work-items/{id:guid}/retry"); Permissions("common.operations.retry"); }
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var id = Route<Guid>("id");
|
||||
var tenant = OperationsIdentity.TenantId(User);
|
||||
var actor = OperationsIdentity.UserId(User);
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var key = await connection.QuerySingleOrDefaultAsync<string?>(new CommandDefinition("""
|
||||
select retry_action_key from kbx.work_items
|
||||
where tenant_id=@Tenant and id=@Id and status in ('open','claimed');
|
||||
""", new { Tenant = tenant, Id = id }, cancellationToken: ct));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(key) || !registry.TryGet(key, out var handler) || handler is null)
|
||||
{
|
||||
AddError("이 예외에는 안전한 재처리 동작이 등록되어 있지 않습니다.");
|
||||
await Send.ErrorsAsync(cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
await handler.ExecuteAsync(tenant, actor, id, ct);
|
||||
await Send.OkAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using Dapper;
|
||||
using FastEndpoints;
|
||||
using Npgsql;
|
||||
using Shared.Operations;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Modules.Common.Operations.Search;
|
||||
|
||||
public sealed class SearchWorkItemsRequest
|
||||
{
|
||||
public string? Module { get; init; }
|
||||
public string? Severity { get; init; }
|
||||
public string? Status { get; init; }
|
||||
public string? Owner { get; init; }
|
||||
public string? Code { get; init; }
|
||||
public string? Keyword { get; init; }
|
||||
public int Page { get; init; } = 1;
|
||||
public int PageSize { get; init; } = 200;
|
||||
}
|
||||
|
||||
public sealed class Endpoint(NpgsqlDataSource dataSource) : Endpoint<SearchWorkItemsRequest, WorkQueueResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/operations/work-items");
|
||||
Permissions("common.operations.read");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(SearchWorkItemsRequest req, CancellationToken ct)
|
||||
{
|
||||
var tenantId = OperationsIdentity.TenantId(User);
|
||||
var userId = OperationsIdentity.UserId(User);
|
||||
var page = Math.Max(1, req.Page);
|
||||
var pageSize = Math.Clamp(req.PageSize, 1, 500);
|
||||
var offset = (page - 1) * pageSize;
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
const string rowsSql = """
|
||||
select id, source_module as SourceModule, source_type as SourceType, source_id as SourceId,
|
||||
reference_no as ReferenceNo, source_screen_id as SourceScreenId, code, title, detail, severity, status, owner_id as OwnerId, owner_name as OwnerName,
|
||||
occurred_at as OccurredAt, due_at as DueAt,
|
||||
greatest(0, floor(extract(epoch from (now() - occurred_at)) / 60))::int as AgeMinutes,
|
||||
version, context::text as ContextJson, retry_action_key as RetryActionKey,
|
||||
allow_manual_resolution as AllowManualResolution
|
||||
from kbx.work_items
|
||||
where tenant_id = @TenantId
|
||||
and (@Module is null or source_module = @Module)
|
||||
and (@Severity is null or severity = @Severity)
|
||||
and (@Status is null or status = @Status)
|
||||
and (@Code is null or code = @Code)
|
||||
and (@Keyword is null or title ilike '%' || @Keyword || '%' or detail ilike '%' || @Keyword || '%' or reference_no ilike '%' || @Keyword || '%' or source_id ilike '%' || @Keyword || '%')
|
||||
and (@Owner is null
|
||||
or (@Owner = 'mine' and owner_id = @UserId)
|
||||
or (@Owner = 'unassigned' and owner_id is null))
|
||||
order by
|
||||
case severity when 'critical' then 0 when 'warning' then 1 else 2 end,
|
||||
coalesce(due_at, occurred_at), occurred_at
|
||||
limit @PageSize offset @Offset;
|
||||
""";
|
||||
|
||||
const string countSql = """
|
||||
select count(*)::int
|
||||
from kbx.work_items
|
||||
where tenant_id = @TenantId
|
||||
and (@Module is null or source_module = @Module)
|
||||
and (@Severity is null or severity = @Severity)
|
||||
and (@Status is null or status = @Status)
|
||||
and (@Code is null or code = @Code)
|
||||
and (@Keyword is null or title ilike '%' || @Keyword || '%' or detail ilike '%' || @Keyword || '%' or reference_no ilike '%' || @Keyword || '%' or source_id ilike '%' || @Keyword || '%')
|
||||
and (@Owner is null
|
||||
or (@Owner = 'mine' and owner_id = @UserId)
|
||||
or (@Owner = 'unassigned' and owner_id is null));
|
||||
""";
|
||||
|
||||
const string countersSql = """
|
||||
select code as Key, min(title) as Label, count(*)::int as Count,
|
||||
case max(case severity when 'critical' then 3 when 'warning' then 2 else 1 end)
|
||||
when 3 then 'critical' when 2 then 'warning' else 'info' end as Severity
|
||||
from kbx.work_items
|
||||
where tenant_id = @TenantId and status in ('open','claimed')
|
||||
group by code
|
||||
order by max(case severity when 'critical' then 3 when 'warning' then 2 else 1 end) desc, count(*) desc
|
||||
limit 12;
|
||||
""";
|
||||
|
||||
var args = new {
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
Module = EmptyToNull(req.Module),
|
||||
Severity = EmptyToNull(req.Severity),
|
||||
Status = EmptyToNull(req.Status),
|
||||
Owner = EmptyToNull(req.Owner),
|
||||
Code = EmptyToNull(req.Code),
|
||||
Keyword = EmptyToNull(req.Keyword),
|
||||
PageSize = pageSize,
|
||||
Offset = offset,
|
||||
};
|
||||
|
||||
var rows = (await connection.QueryAsync<WorkItemProjection>(new CommandDefinition(rowsSql, args, cancellationToken: ct))).AsList();
|
||||
var total = await connection.ExecuteScalarAsync<int>(new CommandDefinition(countSql, args, cancellationToken: ct));
|
||||
var counters = (await connection.QueryAsync<WorkQueueCounter>(new CommandDefinition(countersSql, new { TenantId = tenantId }, cancellationToken: ct))).AsList();
|
||||
var items = rows.Select(ToDto).ToList();
|
||||
await Send.OkAsync(new WorkQueueResponse(items, total, counters), ct);
|
||||
}
|
||||
|
||||
private static string? EmptyToNull(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static WorkItemDto ToDto(WorkItemProjection row)
|
||||
{
|
||||
var actions = new List<WorkItemActionDto> {
|
||||
new("navigate", "원 업무 보기", "navigate"),
|
||||
};
|
||||
if (WorkItemActionPolicy.CanClaim(row.Status)) actions.Add(new("claim", "내가 처리", "claim", "common.operations.claim"));
|
||||
if (WorkItemActionPolicy.CanRetry(row.Status, row.RetryActionKey)) actions.Add(new("retry", "재처리", "retry", "common.operations.retry"));
|
||||
if (WorkItemActionPolicy.CanManualResolve(row.Status, row.AllowManualResolution)) actions.Add(new("resolve", "해결 처리", "resolve", "common.operations.resolve"));
|
||||
var context = JsonSerializer.Deserialize<JsonElement>(row.ContextJson);
|
||||
return new(row.Id, row.SourceModule, row.SourceType, row.SourceId, row.ReferenceNo, row.SourceScreenId, row.Code, row.Title, row.Detail,
|
||||
row.Severity, row.Status, row.OwnerId, row.OwnerName, row.OccurredAt, row.DueAt, row.AgeMinutes,
|
||||
row.Version, context, actions);
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
using Dapper;
|
||||
using FastEndpoints;
|
||||
using Npgsql;
|
||||
using Shared.Operations;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Modules.Common.Reconcile.CreateExceptions;
|
||||
|
||||
public sealed record CreateReconcileExceptionsRequest(IReadOnlyList<Guid> Ids);
|
||||
public sealed record CreateReconcileExceptionsResponse(int CreatedOrUpdatedCount, int SkippedCount);
|
||||
|
||||
public sealed class Endpoint(NpgsqlDataSource dataSource, OperationsProjectionWriter projection)
|
||||
: Endpoint<CreateReconcileExceptionsRequest, CreateReconcileExceptionsResponse>
|
||||
{
|
||||
public override void Configure() { Post("/api/reconcile/items/create-exceptions"); Permissions("common.operations.create"); }
|
||||
|
||||
public override async Task HandleAsync(CreateReconcileExceptionsRequest req, CancellationToken ct)
|
||||
{
|
||||
var tenant = OperationsIdentity.TenantId(User);
|
||||
var ids = req.Ids.Distinct().Take(500).ToArray();
|
||||
if (ids.Length == 0) { await Send.OkAsync(new(0, 0), ct); return; }
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var rows = (await connection.QueryAsync<Row>(new CommandDefinition("""
|
||||
select id, reconcile_type as ReconcileType, reference_no as ReferenceNo,
|
||||
expected_value as ExpectedValue, actual_value as ActualValue,
|
||||
difference_value as DifferenceValue, reason_code as ReasonCode, reason_text as ReasonText,
|
||||
occurred_at as OccurredAt, version
|
||||
from kbx.reconcile_items
|
||||
where tenant_id=@Tenant and id=any(@Ids) and status in ('mismatch','pending');
|
||||
""", new { Tenant = tenant, Ids = ids }, cancellationToken: ct))).AsList();
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
await projection.UpsertAsync(new UpsertWorkItem(
|
||||
tenant, "COMMON", "reconcile", row.Id.ToString(), row.ReferenceNo, "COMMON-REC-001", row.Version,
|
||||
"RECONCILE_MISMATCH", $"대사 불일치 · {row.ReferenceNo}", row.ReasonText,
|
||||
"warning", row.OccurredAt, Context: new {
|
||||
row.ReconcileType, row.ReferenceNo, row.ExpectedValue, row.ActualValue,
|
||||
row.DifferenceValue, row.ReasonCode
|
||||
}), ct);
|
||||
}
|
||||
|
||||
await Send.OkAsync(new(rows.Count, ids.Length - rows.Count), ct);
|
||||
}
|
||||
|
||||
private sealed record Row(Guid Id, string ReconcileType, string ReferenceNo, string ExpectedValue,
|
||||
string ActualValue, string? DifferenceValue, string? ReasonCode, string? ReasonText,
|
||||
DateTimeOffset OccurredAt, long Version);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using Dapper;
|
||||
using FastEndpoints;
|
||||
using Npgsql;
|
||||
using Shared.Operations;
|
||||
|
||||
namespace Modules.Common.Reconcile.Search;
|
||||
|
||||
public sealed class SearchReconcileRequest
|
||||
{
|
||||
public string? ReconcileType { get; init; }
|
||||
public string? Status { get; init; }
|
||||
public string? Keyword { get; init; }
|
||||
public int Page { get; init; } = 1;
|
||||
public int PageSize { get; init; } = 200;
|
||||
}
|
||||
|
||||
public sealed class Endpoint(NpgsqlDataSource dataSource) : Endpoint<SearchReconcileRequest, ReconcileResponse>
|
||||
{
|
||||
public override void Configure() { Get("/api/reconcile/items"); Permissions("common.reconcile.read"); }
|
||||
|
||||
public override async Task HandleAsync(SearchReconcileRequest req, CancellationToken ct)
|
||||
{
|
||||
var tenant = OperationsIdentity.TenantId(User);
|
||||
var page = Math.Max(1, req.Page);
|
||||
var pageSize = Math.Clamp(req.PageSize, 1, 500);
|
||||
var offset = (page - 1) * pageSize;
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
const string rowsSql = """
|
||||
select id, reconcile_type as ReconcileType, reference_no as ReferenceNo,
|
||||
source_label as SourceLabel, target_label as TargetLabel,
|
||||
expected_value as ExpectedValue, actual_value as ActualValue,
|
||||
difference_value as DifferenceValue, reason_code as ReasonCode, reason_text as ReasonText,
|
||||
status, occurred_at as OccurredAt, source_id as SourceId, target_id as TargetId, version
|
||||
from kbx.reconcile_items
|
||||
where tenant_id=@Tenant
|
||||
and (@ReconcileType is null or reconcile_type=@ReconcileType)
|
||||
and (@Status is null or status=@Status)
|
||||
and (@Keyword is null or reference_no ilike '%' || @Keyword || '%' or reason_text ilike '%' || @Keyword || '%')
|
||||
order by case status when 'mismatch' then 0 when 'pending' then 1 when 'resolved' then 2 else 3 end,
|
||||
occurred_at desc
|
||||
limit @PageSize offset @Offset;
|
||||
""";
|
||||
const string summarySql = """
|
||||
select count(*)::int as TotalCount,
|
||||
count(*) filter(where status='matched')::int as MatchedCount,
|
||||
count(*) filter(where status='mismatch')::int as MismatchCount,
|
||||
count(*) filter(where status='pending')::int as PendingCount,
|
||||
count(*) filter(where status='resolved')::int as ResolvedCount
|
||||
from kbx.reconcile_items
|
||||
where tenant_id=@Tenant
|
||||
and (@ReconcileType is null or reconcile_type=@ReconcileType)
|
||||
and (@Keyword is null or reference_no ilike '%' || @Keyword || '%' or reason_text ilike '%' || @Keyword || '%');
|
||||
""";
|
||||
var args = new {
|
||||
Tenant = tenant,
|
||||
ReconcileType = Empty(req.ReconcileType),
|
||||
Status = Empty(req.Status),
|
||||
Keyword = Empty(req.Keyword),
|
||||
PageSize = pageSize,
|
||||
Offset = offset,
|
||||
};
|
||||
var rows = (await connection.QueryAsync<ReconcileItemDto>(new CommandDefinition(rowsSql, args, cancellationToken: ct))).AsList();
|
||||
var summary = await connection.QuerySingleAsync<ReconcileSummary>(new CommandDefinition(summarySql, args, cancellationToken: ct));
|
||||
await Send.OkAsync(new ReconcileResponse(rows, summary), ct);
|
||||
}
|
||||
|
||||
private static string? Empty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using FastEndpoints;
|
||||
using Kbx.Shared.Runtime;
|
||||
|
||||
namespace Kbx.Modules.Common.Runtime.Health;
|
||||
|
||||
public sealed class Endpoint(KbxRuntimeNoticeRepository repository) : EndpointWithoutRequest<KbxRuntimeNotice?>
|
||||
{
|
||||
public override void Configure() { Get("/api/kbx/runtime/notice"); Permissions("common.runtime.read"); }
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
=> await Send.OkAsync(await repository.GetActiveAsync(RuntimeIdentity.TenantId(User), ct), ct);
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
using FastEndpoints;
|
||||
using Kbx.Shared.Runtime;
|
||||
|
||||
namespace Kbx.Modules.Common.Runtime.Notifications;
|
||||
|
||||
public sealed class Request { public int Limit { get; init; } = 30; }
|
||||
public sealed class Endpoint(KbxNotificationRepository repository) : Endpoint<Request, IReadOnlyList<KbxUserNotificationDto>>
|
||||
{
|
||||
public override void Configure() { Get("/api/kbx/runtime/notifications"); Permissions("common.runtime.read"); }
|
||||
public override async Task HandleAsync(Request req, CancellationToken ct)
|
||||
=> await Send.OkAsync(await repository.GetRecentAsync(RuntimeIdentity.TenantId(User), RuntimeIdentity.UserId(User), req.Limit, ct), ct);
|
||||
}
|
||||
|
||||
public sealed class MarkReadRequest { public Guid Id { get; init; } }
|
||||
public sealed class MarkReadEndpoint(KbxNotificationRepository repository) : Endpoint<MarkReadRequest>
|
||||
{
|
||||
public override void Configure() { Post("/api/kbx/runtime/notifications/{Id}/read"); Permissions("common.runtime.read"); }
|
||||
public override async Task HandleAsync(MarkReadRequest req, CancellationToken ct)
|
||||
{
|
||||
await repository.MarkReadAsync(RuntimeIdentity.TenantId(User), RuntimeIdentity.UserId(User), req.Id, ct);
|
||||
await Send.NoContentAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using FastEndpoints;
|
||||
using Kbx.Shared.Runtime;
|
||||
|
||||
namespace Kbx.Modules.Common.Runtime.Operations;
|
||||
|
||||
public sealed class Request { public int Limit { get; init; } = 20; }
|
||||
public sealed class Endpoint(KbxOperationRunRepository repository) : Endpoint<Request, IReadOnlyList<KbxOperationRunDto>>
|
||||
{
|
||||
public override void Configure() { Get("/api/kbx/runtime/operations"); Permissions("common.runtime.read"); }
|
||||
public override async Task HandleAsync(Request req, CancellationToken ct)
|
||||
=> await Send.OkAsync(await repository.GetRecentAsync(RuntimeIdentity.TenantId(User), RuntimeIdentity.UserId(User), req.Limit, ct), ct);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using Dapper;
|
||||
using FastEndpoints;
|
||||
using KBX.Shared.Experience;
|
||||
using Npgsql;
|
||||
|
||||
namespace KBX.Modules.Common.Suggestions.Submit;
|
||||
|
||||
public sealed record Request(
|
||||
string Category,
|
||||
string Message,
|
||||
bool IncludeScreenContext,
|
||||
SuggestionContext Context);
|
||||
|
||||
public sealed record Response(Guid SuggestionId, DateTimeOffset ReceivedAt);
|
||||
|
||||
public sealed class Endpoint(NpgsqlDataSource dataSource) : Endpoint<Request, Response>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/common/suggestions");
|
||||
Permissions("common.suggestion.create");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(Request req, CancellationToken ct)
|
||||
{
|
||||
if (req.Message.Trim().Length is < 3 or > 2000)
|
||||
{
|
||||
AddError(r => r.Message, "의견 내용은 3~2000자로 입력하세요.");
|
||||
await Send.ErrorsAsync(cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.Category is not ("inconvenience" or "bug" or "improvement"))
|
||||
{
|
||||
AddError(r => r.Category, "지원하지 않는 의견 유형입니다.");
|
||||
await Send.ErrorsAsync(cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Host application must replace these claims adapters with its authenticated tenant/user resolver.
|
||||
var tenantId = Guid.Empty;
|
||||
var userId = Guid.Empty;
|
||||
var id = Guid.NewGuid();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
object? context = null;
|
||||
if (req.IncludeScreenContext)
|
||||
{
|
||||
// Deliberately persist diagnostic keys only, not arbitrary screen/business values.
|
||||
context = new
|
||||
{
|
||||
req.Context.ActiveFilters,
|
||||
req.Context.GridLayoutVersion,
|
||||
};
|
||||
}
|
||||
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await connection.ExecuteAsync(new CommandDefinition("""
|
||||
insert into kbx.user_suggestions
|
||||
(id, tenant_id, user_id, category, message, screen_id, screen_version,
|
||||
route, app_version, user_role, context, status, created_at)
|
||||
values
|
||||
(@Id, @TenantId, @UserId, @Category, @Message, @ScreenId, @ScreenVersion,
|
||||
@Route, @AppVersion, @UserRole, cast(@Context as jsonb), 'NEW', @CreatedAt)
|
||||
""", new
|
||||
{
|
||||
Id = id,
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
req.Category,
|
||||
Message = req.Message.Trim(),
|
||||
req.Context.ScreenId,
|
||||
req.Context.ScreenVersion,
|
||||
req.Context.Route,
|
||||
req.Context.AppVersion,
|
||||
req.Context.UserRole,
|
||||
Context = context is null ? null : System.Text.Json.JsonSerializer.Serialize(context),
|
||||
CreatedAt = now,
|
||||
}, cancellationToken: ct));
|
||||
|
||||
await Send.OkAsync(new Response(id, now), ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using FastEndpoints;
|
||||
using Kbx.Shared.Telemetry;
|
||||
namespace Kbx.Modules.Common.UxTelemetry.Ingest;
|
||||
public sealed class Endpoint(KbxUxTelemetryRepository repository) : Endpoint<KbxUxEventBatchRequest>
|
||||
{
|
||||
public override void Configure(){Post("/api/kbx/ux/events");Permissions("common.telemetry.write");}
|
||||
public override async Task HandleAsync(KbxUxEventBatchRequest req,CancellationToken ct)
|
||||
{
|
||||
// Replace with the deployment's authenticated tenant accessor.
|
||||
var tenantId=Kbx.Shared.Runtime.RuntimeIdentity.TenantId(User);
|
||||
await repository.AppendAsync(tenantId,Kbx.Shared.Runtime.RuntimeIdentity.UserId(User),req.Events,ct);await Send.NoContentAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using FastEndpoints;using Kbx.Shared.Telemetry;
|
||||
namespace Kbx.Modules.Common.UxTelemetry.Metrics;
|
||||
public sealed record Request(DateOnly From,DateOnly To,string? ScreenId);
|
||||
public sealed class Endpoint(KbxUxMetricsQuery query):Endpoint<Request,KbxUxMetricsResponse>
|
||||
{
|
||||
public override void Configure(){Get("/api/kbx/ux/metrics");Permissions("common.ux.read");}
|
||||
public override async Task HandleAsync(Request req,CancellationToken ct)
|
||||
{
|
||||
var tenantId=Kbx.Shared.Runtime.RuntimeIdentity.TenantId(User);
|
||||
await Send.OkAsync(await query.ExecuteAsync(tenantId,req.From,req.To,req.ScreenId,ct),ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using Dapper;
|
||||
using FastEndpoints;
|
||||
using Npgsql;
|
||||
|
||||
namespace Modules.ERP.Inventory.Search;
|
||||
|
||||
public sealed record SearchInventoryRequest(string? Keyword, int Page = 1, int PageSize = 200);
|
||||
public sealed record InventoryItemRow(Guid ItemId, string ItemCode, string ItemName, string Specification, decimal TotalQty, decimal AvailableQty, decimal AllocatedQty, decimal HoldQty);
|
||||
public sealed record SearchInventoryResponse(IReadOnlyList<InventoryItemRow> Items, int TotalCount);
|
||||
|
||||
public sealed class Endpoint(NpgsqlDataSource dataSource) : Endpoint<SearchInventoryRequest, SearchInventoryResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/erp/inventory");
|
||||
Permissions("erp.inventory.read");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(SearchInventoryRequest req, CancellationToken ct)
|
||||
{
|
||||
var page = Math.Max(req.Page, 1);
|
||||
var pageSize = Math.Clamp(req.PageSize, 1, 500);
|
||||
var keyword = string.IsNullOrWhiteSpace(req.Keyword) ? null : req.Keyword.Trim();
|
||||
var offset = (page - 1) * pageSize;
|
||||
const string sql = """
|
||||
select item_id as ItemId, item_code as ItemCode, item_name as ItemName, specification,
|
||||
sum(on_hand_qty) as TotalQty,
|
||||
sum(available_qty) as AvailableQty,
|
||||
sum(allocated_qty) as AllocatedQty,
|
||||
sum(hold_qty) as HoldQty
|
||||
from erp_inventory_snapshot_projection
|
||||
where (@Keyword is null or search_text ilike '%' || @Keyword || '%')
|
||||
group by item_id, item_code, item_name, specification
|
||||
order by item_code
|
||||
limit @PageSize offset @Offset;
|
||||
|
||||
select count(distinct item_id)::int
|
||||
from erp_inventory_snapshot_projection
|
||||
where (@Keyword is null or search_text ilike '%' || @Keyword || '%');
|
||||
""";
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
using var multi = await connection.QueryMultipleAsync(new CommandDefinition(sql, new { Keyword = keyword, PageSize = pageSize, Offset = offset }, cancellationToken: ct));
|
||||
var items = (await multi.ReadAsync<InventoryItemRow>()).AsList();
|
||||
var totalCount = await multi.ReadSingleAsync<int>();
|
||||
await Send.OkAsync(new SearchInventoryResponse(items, totalCount), ct);
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
using Dapper;
|
||||
using FastEndpoints;
|
||||
using Npgsql;
|
||||
|
||||
namespace Modules.ERP.Inventory.Search;
|
||||
|
||||
public sealed class InventoryHistoryRequest { public Guid ItemId { get; init; } public int PageSize { get; init; } = 100; }
|
||||
public sealed record InventoryHistoryRow(Guid EntryId, DateTimeOffset OccurredAt, string BusinessType, string ReferenceNo, string WarehouseName, string LocationCode, decimal InboundQty, decimal OutboundQty, decimal BalanceQty, string Actor);
|
||||
public sealed record InventoryHistoryResponse(IReadOnlyList<InventoryHistoryRow> Items);
|
||||
|
||||
public sealed class HistoryEndpoint(NpgsqlDataSource dataSource) : Endpoint<InventoryHistoryRequest, InventoryHistoryResponse>
|
||||
{
|
||||
public override void Configure() { Get("/api/erp/inventory/{itemId}/history"); Permissions("erp.inventory.read"); }
|
||||
|
||||
public override async Task HandleAsync(InventoryHistoryRequest req, CancellationToken ct)
|
||||
{
|
||||
var pageSize=Math.Clamp(req.PageSize,1,500);
|
||||
const string sql="""
|
||||
select entry_id as EntryId, occurred_at as OccurredAt, business_type as BusinessType,
|
||||
reference_no as ReferenceNo, warehouse_name as WarehouseName,
|
||||
coalesce(location_code,'-') as LocationCode, inbound_qty as InboundQty,
|
||||
outbound_qty as OutboundQty, balance_qty as BalanceQty, actor as Actor
|
||||
from erp_inventory_ledger_projection
|
||||
where item_id=@ItemId
|
||||
order by occurred_at desc, entry_id
|
||||
limit @PageSize;
|
||||
""";
|
||||
await using var connection=await dataSource.OpenConnectionAsync(ct);
|
||||
var items=(await connection.QueryAsync<InventoryHistoryRow>(new CommandDefinition(sql,new{req.ItemId,PageSize=pageSize},cancellationToken:ct))).AsList();
|
||||
await Send.OkAsync(new InventoryHistoryResponse(items),ct);
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
using Dapper;
|
||||
using FastEndpoints;
|
||||
using Npgsql;
|
||||
|
||||
namespace Modules.ERP.Inventory.Search;
|
||||
|
||||
public sealed class LocationsRequest { public Guid ItemId { get; init; } }
|
||||
public sealed record InventoryLocationRow(string Key, string WarehouseName, string LocationCode, decimal OnHandQty, decimal AllocatedQty, decimal AvailableQty, decimal HoldQty);
|
||||
public sealed record InventoryLocationsResponse(IReadOnlyList<InventoryLocationRow> Items);
|
||||
|
||||
public sealed class LocationsEndpoint(NpgsqlDataSource dataSource) : Endpoint<LocationsRequest, InventoryLocationsResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/erp/inventory/{itemId}/locations");
|
||||
Permissions("erp.inventory.read");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(LocationsRequest req, CancellationToken ct)
|
||||
{
|
||||
const string sql = """
|
||||
select snapshot_key as Key,
|
||||
warehouse_name as WarehouseName,
|
||||
coalesce(location_code, '-') as LocationCode,
|
||||
on_hand_qty as OnHandQty,
|
||||
allocated_qty as AllocatedQty,
|
||||
available_qty as AvailableQty,
|
||||
hold_qty as HoldQty
|
||||
from erp_inventory_snapshot_projection
|
||||
where item_id = @ItemId
|
||||
order by warehouse_name, location_code nulls first;
|
||||
""";
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var items = (await connection.QueryAsync<InventoryLocationRow>(new CommandDefinition(sql, new { req.ItemId }, cancellationToken: ct))).AsList();
|
||||
await Send.OkAsync(new InventoryLocationsResponse(items), ct);
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
|
||||
using KBX.Shared.Workflow;
|
||||
namespace KBX.Modules.ERP.InventoryMove.Workflow;
|
||||
public static class InventoryMoveWorkflow
|
||||
{
|
||||
public static readonly WorkflowTransition[] Transitions=[
|
||||
new("confirm",new HashSet<string>{"DRAFT"},"CONFIRMED","erp.inventory.move.confirm"),
|
||||
new("ship",new HashSet<string>{"CONFIRMED"},"IN_TRANSIT","erp.inventory.move.ship"),
|
||||
new("receive",new HashSet<string>{"IN_TRANSIT"},"RECEIVED","erp.inventory.move.receive")
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using FastEndpoints;
|
||||
using Npgsql;
|
||||
using Shared.Problems;
|
||||
|
||||
namespace Modules.ERP.ItemPrices.BulkSave;
|
||||
|
||||
public sealed record SaveItemPriceRow(string ClientId, Guid ItemId, DateOnly EffectiveDate, decimal UnitPrice, string? Remark);
|
||||
public sealed record SaveItemPricesRequest(IReadOnlyList<SaveItemPriceRow> Rows);
|
||||
public sealed record SaveItemPricesResponse(int Requested, int Saved, int Created, int Updated);
|
||||
|
||||
public sealed class Endpoint(NpgsqlDataSource dataSource) : Endpoint<SaveItemPricesRequest, SaveItemPricesResponse>
|
||||
{
|
||||
public override void Configure() { Post("/api/erp/item-prices/bulk"); Permissions("erp.item.price.write"); }
|
||||
|
||||
public override async Task HandleAsync(SaveItemPricesRequest req, CancellationToken ct)
|
||||
{
|
||||
var key=HttpContext.Request.Headers["Idempotency-Key"].FirstOrDefault();
|
||||
if(string.IsNullOrWhiteSpace(key)){await Send.ResponseAsync(KbxValidationProblem.Create(new KbxValidationError(null,null,"IDEMPOTENCY_KEY_REQUIRED","안전한 재처리를 위해 Idempotency-Key가 필요합니다.")),400,cancellation:ct);return;}
|
||||
if(req.Rows is null||req.Rows.Count==0){await Send.ResponseAsync(KbxValidationProblem.Create(new KbxValidationError(null,null,"PRICE_ROWS_REQUIRED","저장할 단가를 한 건 이상 입력하세요.")),400,cancellation:ct);return;}
|
||||
if(req.Rows.Count>5000){await Send.ResponseAsync(KbxValidationProblem.Create(new KbxValidationError(null,null,"PRICE_ROWS_LIMIT","한 번에 최대 5,000건까지 저장할 수 있습니다.")),400,cancellation:ct);return;}
|
||||
|
||||
var errors=new List<KbxValidationError>();
|
||||
foreach(var row in req.Rows){if(row.UnitPrice<0)errors.Add(new("unitPrice",row.ClientId,"PRICE_NONNEGATIVE","단가는 0 이상이어야 합니다."));}
|
||||
foreach(var group in req.Rows.GroupBy(x=>new{x.ItemId,x.EffectiveDate}).Where(x=>x.Count()>1)) foreach(var row in group) errors.Add(new("effectiveDate",row.ClientId,"DUPLICATE_ITEM_DATE","같은 품목과 적용일이 중복되었습니다."));
|
||||
|
||||
if(errors.Count>0){await Send.ResponseAsync(KbxValidationProblem.Create(errors.ToArray()),400,cancellation:ct);return;}
|
||||
|
||||
await using var connection=await dataSource.OpenConnectionAsync(ct);
|
||||
await using var tx=await connection.BeginTransactionAsync(ct);
|
||||
var replay=await connection.QuerySingleOrDefaultAsync<string?>(new CommandDefinition("select response_json::text from kbx.command_receipts where operation_id=@OperationId and idempotency_key=@Key",new{OperationId="erp.itemPrices.bulkSave",Key=key},tx,cancellationToken:ct));
|
||||
if(replay is not null){await tx.RollbackAsync(ct);await Send.OkAsync(JsonSerializer.Deserialize<SaveItemPricesResponse>(replay)!,ct);return;}
|
||||
|
||||
var ids=req.Rows.Select(x=>x.ItemId).Distinct().ToArray();
|
||||
var active=(await connection.QueryAsync<Guid>(new CommandDefinition("select id from catalog.items where id=any(@Ids) and is_active=true",new{Ids=ids},tx,cancellationToken:ct))).ToHashSet();
|
||||
foreach(var row in req.Rows.Where(x=>!active.Contains(x.ItemId)))errors.Add(new("itemCode",row.ClientId,"ITEM_NOT_FOUND","사용 가능한 품목이 아닙니다."));
|
||||
if(errors.Count>0){await tx.RollbackAsync(ct);await Send.ResponseAsync(KbxValidationProblem.Create(errors.ToArray()),400,cancellation:ct);return;}
|
||||
|
||||
var actor=User.Identity?.Name??"unknown";
|
||||
var rowsJson=JsonSerializer.Serialize(req.Rows.Select(x=>new{clientId=x.ClientId,itemId=x.ItemId,effectiveDate=x.EffectiveDate.ToString("yyyy-MM-dd"),unitPrice=x.UnitPrice,remark=x.Remark}));
|
||||
var result=await connection.QuerySingleAsync<MutationResult>(new CommandDefinition("""
|
||||
with input as materialized (
|
||||
select x.client_id, x.item_id, x.effective_date, x.unit_price, nullif(trim(x.remark),'') as remark
|
||||
from jsonb_to_recordset(cast(@RowsJson as jsonb)) as x(client_id text,item_id uuid,effective_date date,unit_price numeric,remark text)
|
||||
), before_state as materialized (
|
||||
select i.*, p.unit_price as old_unit_price, p.id as existing_id
|
||||
from input i left join erp.item_prices p on p.item_id=i.item_id and p.effective_date=i.effective_date
|
||||
), upserted as materialized (
|
||||
insert into erp.item_prices(id,item_id,effective_date,unit_price,remark,version,created_at,created_by,updated_at,updated_by)
|
||||
select coalesce(existing_id,gen_random_uuid()),item_id,effective_date,unit_price,remark,case when existing_id is null then 1 else 2 end,now(),@Actor,case when existing_id is null then null else now() end,case when existing_id is null then null else @Actor end
|
||||
from before_state
|
||||
on conflict(item_id,effective_date) do update set unit_price=excluded.unit_price,remark=excluded.remark,version=erp.item_prices.version+1,updated_at=now(),updated_by=@Actor
|
||||
returning item_id,effective_date
|
||||
), audit_insert as (
|
||||
insert into audit.entries(id,aggregate_type,aggregate_id,action,actor,occurred_at,data)
|
||||
select gen_random_uuid(),'Item',b.item_id,'ITEM_PRICE_SAVED',@Actor,now(),jsonb_build_object('effectiveDate',b.effective_date,'before',b.old_unit_price,'after',b.unit_price)
|
||||
from before_state b returning 1
|
||||
), outbox_insert as (
|
||||
insert into integration.outbox(id,event_type,aggregate_id,payload,occurred_at,status)
|
||||
select gen_random_uuid(),'ErpItemPriceChanged',b.item_id,jsonb_build_object('itemId',b.item_id,'effectiveDate',b.effective_date,'unitPrice',b.unit_price),now(),'PENDING'
|
||||
from before_state b returning 1
|
||||
)
|
||||
select count(*)::int as Saved,
|
||||
count(*) filter(where existing_id is null)::int as Created,
|
||||
count(*) filter(where existing_id is not null)::int as Updated
|
||||
from before_state;
|
||||
""",new{RowsJson=rowsJson,Actor=actor},tx,cancellationToken:ct));
|
||||
|
||||
var response=new SaveItemPricesResponse(req.Rows.Count,result.Saved,result.Created,result.Updated);
|
||||
await connection.ExecuteAsync(new CommandDefinition("insert into kbx.command_receipts(operation_id,idempotency_key,response_json) values(@OperationId,@Key,cast(@Response as jsonb))",new{OperationId="erp.itemPrices.bulkSave",Key=key,Response=JsonSerializer.Serialize(response)},tx,cancellationToken:ct));
|
||||
await tx.CommitAsync(ct); await Send.OkAsync(response,ct);
|
||||
}
|
||||
private sealed record MutationResult(int Saved,int Created,int Updated);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
using System.Text.Json;using Dapper;using FastEndpoints;using Npgsql;
|
||||
namespace Modules.ERP.Items.Audit;
|
||||
public sealed record AuditActor(string Type,string DisplayName);public sealed record AuditChange(string Field,string Label,object? Before,object? After);public sealed record AuditRow(Guid Id,DateTimeOffset OccurredAt,string Actor,string Action,string Data);public sealed record AuditEntry(Guid Id,DateTimeOffset OccurredAt,AuditActor Actor,string Action,IReadOnlyList<AuditChange>? Changes,string? Reason);
|
||||
public sealed class Endpoint(NpgsqlDataSource dataSource):EndpointWithoutRequest<IReadOnlyList<AuditEntry>>{public override void Configure(){Get("/api/erp/items/{id}/audit");Permissions("erp.item.read");}public override async Task HandleAsync(CancellationToken ct){var id=Route<Guid>("id");await using var c=await dataSource.OpenConnectionAsync(ct);var rows=await c.QueryAsync<AuditRow>(new CommandDefinition("select id,occurred_at as OccurredAt,actor,action,data::text as Data from audit.entries where aggregate_type='Item' and aggregate_id=@Id order by occurred_at desc limit 100",new{Id=id},cancellationToken:ct));var result=rows.Select(r=>{using var d=JsonDocument.Parse(r.Data);List<AuditChange>? changes=null;if(d.RootElement.TryGetProperty("changes",out var arr)){changes=new();foreach(var x in arr.EnumerateArray())changes.Add(new(x.GetProperty("field").GetString()??"",x.GetProperty("label").GetString()??"",x.TryGetProperty("before",out var b)?b.ToString():null,x.TryGetProperty("after",out var a)?a.ToString():null));}return new AuditEntry(r.Id,r.OccurredAt,new("user",r.Actor),r.Action,changes,null);}).ToList();await Send.OkAsync(result,ct);}}
|
||||
@@ -0,0 +1,5 @@
|
||||
using Dapper;using FastEndpoints;using Npgsql;using Shared.Problems;
|
||||
namespace Modules.ERP.Items.Deactivate;
|
||||
public sealed record Request(long Version);
|
||||
public sealed record Response(Guid Id,long Version,string Status);
|
||||
public sealed class Endpoint(NpgsqlDataSource dataSource):Endpoint<Request,Response>{public override void Configure(){Post("/api/erp/items/{id}/deactivate");Permissions("erp.item.write");}public override async Task HandleAsync(Request req,CancellationToken ct){var id=Route<Guid>("id");await using var c=await dataSource.OpenConnectionAsync(ct);await using var tx=await c.BeginTransactionAsync(ct);var current=await c.QuerySingleOrDefaultAsync<(long Version,bool Active)>(new CommandDefinition("select d.version as Version,i.is_active as Active from catalog.items i join catalog.item_details d on d.item_id=i.id where i.id=@Id for update",new{Id=id},tx,cancellationToken:ct));if(current.Version==0){await Send.ResponseAsync(KbxNotFoundProblem.Create("ITEM_NOT_FOUND","품목을 찾을 수 없습니다."),404,cancellation:ct);return;}if(current.Version!=req.Version){await Send.ResponseAsync(KbxConflictProblem.Version(current.Version),409,cancellation:ct);return;}var next=current.Version+1;var actor=User.Identity?.Name??"unknown";await c.ExecuteAsync(new CommandDefinition(@"update catalog.items set is_active=false where id=@Id;update catalog.item_details set version=@Version,updated_at=now(),updated_by=@Actor where item_id=@Id;update erp_item_search_projection set active=false,version=@Version where id=@Id;insert into audit.entries(id,aggregate_type,aggregate_id,action,actor,occurred_at,data) values(@AuditId,'Item',@Id,'ITEM_DEACTIVATED',@Actor,now(),jsonb_build_object('version',@Version));insert into integration.outbox(id,event_type,aggregate_id,payload,occurred_at,status) values(@EventId,'ItemDeactivated',@Id,jsonb_build_object('itemId',@Id,'version',@Version),now(),'PENDING');",new{Id=id,Version=next,Actor=actor,AuditId=Guid.NewGuid(),EventId=Guid.NewGuid()},tx,cancellationToken:ct));await tx.CommitAsync(ct);await Send.OkAsync(new(id,next,"사용중지"),ct);}}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Dapper;
|
||||
using FastEndpoints;
|
||||
using Npgsql;
|
||||
using Modules.ERP.Items.Search;
|
||||
|
||||
namespace Modules.ERP.Items.Get;
|
||||
|
||||
public sealed class Request { public Guid Id { get; init; } }
|
||||
|
||||
public sealed class Endpoint(NpgsqlDataSource dataSource) : Endpoint<Request, ItemMasterRow>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/erp/items/{id}");
|
||||
Permissions("erp.item.read");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(Request req, CancellationToken ct)
|
||||
{
|
||||
const string sql = """
|
||||
select id, code, name, category_name as CategoryName, specification, unit, barcode,
|
||||
default_warehouse_id as DefaultWarehouseId, default_warehouse_name as DefaultWarehouseName,
|
||||
lot_managed as LotManaged, expiry_managed as ExpiryManaged, active, version
|
||||
from erp_item_search_projection
|
||||
where id = @Id;
|
||||
""";
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var row = await connection.QuerySingleOrDefaultAsync<ItemMasterRow>(new CommandDefinition(sql, new { req.Id }, cancellationToken: ct));
|
||||
if (row is null) { await Send.NotFoundAsync(ct); return; }
|
||||
await Send.OkAsync(row, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
using FastEndpoints;
|
||||
using Shared.Problems;
|
||||
namespace Modules.ERP.Items.Save;
|
||||
public sealed class CreateEndpoint(Handler handler):Endpoint<ItemSaveRequest,ItemSaveResponse>{public override void Configure(){Post("/api/erp/items");Permissions("erp.item.create");}public override async Task HandleAsync(ItemSaveRequest req,CancellationToken ct){var r=await handler.HandleAsync(req with { Id=null, Version=null },User,ct);if(r.Problem is KbxValidationProblem v){await Send.ResponseAsync(v,400,cancellation:ct);return;}if(r.Problem is KbxBusinessProblem b){await Send.ResponseAsync(b,409,cancellation:ct);return;}await Send.OkAsync(r.Response!,ct);}}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Data;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
using Shared.Problems;
|
||||
|
||||
namespace Modules.ERP.Items.Save;
|
||||
|
||||
public sealed record ItemSaveResult(ItemSaveResponse? Response, object? Problem)
|
||||
{
|
||||
public static ItemSaveResult Ok(ItemSaveResponse response) => new(response, null);
|
||||
public static ItemSaveResult Fail(object problem) => new(null, problem);
|
||||
}
|
||||
|
||||
public sealed class Handler(NpgsqlDataSource dataSource)
|
||||
{
|
||||
public async Task<ItemSaveResult> HandleAsync(ItemSaveRequest request, ClaimsPrincipal user, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var tx = await connection.BeginTransactionAsync(IsolationLevel.ReadCommitted, ct);
|
||||
var actor = user.Identity?.Name ?? "unknown";
|
||||
var id = request.Id ?? Guid.NewGuid();
|
||||
var isNew = request.Id is null;
|
||||
long version = 1;
|
||||
dynamic? before = null;
|
||||
|
||||
if (request.DefaultWarehouseId is not null)
|
||||
{
|
||||
var warehouseOk = await connection.ExecuteScalarAsync<bool>(new CommandDefinition(
|
||||
"select exists(select 1 from inventory.warehouses where id=@Id and is_active=true)",
|
||||
new { Id=request.DefaultWarehouseId }, tx, cancellationToken:ct));
|
||||
if (!warehouseOk) return ItemSaveResult.Fail(KbxValidationProblem.Create(new("defaultWarehouseId", null, "WAREHOUSE_NOT_FOUND", "사용 가능한 기본창고가 아닙니다.")));
|
||||
}
|
||||
|
||||
var duplicateCode = await connection.ExecuteScalarAsync<bool>(new CommandDefinition(
|
||||
"select exists(select 1 from catalog.items where code=@Code and id<>@Id)", new { request.Code, Id=id }, tx, cancellationToken:ct));
|
||||
if (duplicateCode) return ItemSaveResult.Fail(KbxValidationProblem.Create(new("code", null, "ITEM_CODE_DUPLICATE", "이미 사용 중인 품목코드입니다.")));
|
||||
if (!string.IsNullOrWhiteSpace(request.Barcode))
|
||||
{
|
||||
var duplicateBarcode = await connection.ExecuteScalarAsync<bool>(new CommandDefinition(
|
||||
"select exists(select 1 from catalog.item_details where barcode=@Barcode and item_id<>@Id)", new { Barcode=request.Barcode, Id=id }, tx, cancellationToken:ct));
|
||||
if (duplicateBarcode) return ItemSaveResult.Fail(KbxValidationProblem.Create(new("barcode", null, "BARCODE_DUPLICATE", "이미 다른 품목에서 사용 중인 바코드입니다.")));
|
||||
}
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
await connection.ExecuteAsync(new CommandDefinition("insert into catalog.items(id,code,name,is_active) values(@Id,@Code,@Name,true)", new { Id=id, request.Code, request.Name }, tx, cancellationToken:ct));
|
||||
await connection.ExecuteAsync(new CommandDefinition(@"insert into catalog.item_details(item_id,category_name,specification,unit,barcode,default_warehouse_id,lot_managed,expiry_managed,version,created_by)
|
||||
values(@Id,@CategoryName,@Specification,@Unit,@Barcode,@DefaultWarehouseId,@LotManaged,@ExpiryManaged,1,@Actor)", new { Id=id, CategoryName=request.CategoryName??"", Specification=request.Specification??"", request.Unit, Barcode=request.Barcode??"", request.DefaultWarehouseId, request.LotManaged, request.ExpiryManaged, Actor=actor }, tx, cancellationToken:ct));
|
||||
}
|
||||
else
|
||||
{
|
||||
before = await connection.QuerySingleOrDefaultAsync(new CommandDefinition(@"select i.code,i.name,i.is_active as active,d.category_name,d.specification,d.unit,d.barcode,d.default_warehouse_id,d.lot_managed,d.expiry_managed,d.version
|
||||
from catalog.items i join catalog.item_details d on d.item_id=i.id where i.id=@Id for update", new { Id=id }, tx, cancellationToken:ct));
|
||||
if (before is null) return ItemSaveResult.Fail(KbxBusinessProblem.Create("ITEM_NOT_FOUND", "품목을 찾을 수 없습니다."));
|
||||
if ((long)before.version != request.Version) return ItemSaveResult.Fail(KbxConflictProblem.Version((long)before.version));
|
||||
if (!(bool)before.active) return ItemSaveResult.Fail(KbxBusinessProblem.Create("ITEM_INACTIVE", "사용중지된 품목은 수정할 수 없습니다."));
|
||||
version=(long)before.version+1;
|
||||
await connection.ExecuteAsync(new CommandDefinition("update catalog.items set code=@Code,name=@Name where id=@Id", new { Id=id, request.Code, request.Name }, tx, cancellationToken:ct));
|
||||
await connection.ExecuteAsync(new CommandDefinition(@"update catalog.item_details set category_name=@CategoryName,specification=@Specification,unit=@Unit,barcode=@Barcode,default_warehouse_id=@DefaultWarehouseId,lot_managed=@LotManaged,expiry_managed=@ExpiryManaged,version=@Version,updated_at=now(),updated_by=@Actor where item_id=@Id", new { Id=id, CategoryName=request.CategoryName??"", Specification=request.Specification??"", request.Unit, Barcode=request.Barcode??"", request.DefaultWarehouseId, request.LotManaged, request.ExpiryManaged, Version=version, Actor=actor }, tx, cancellationToken:ct));
|
||||
}
|
||||
|
||||
var warehouseName = request.DefaultWarehouseId is null ? "" : await connection.ExecuteScalarAsync<string?>(new CommandDefinition("select name from inventory.warehouses where id=@Id", new { Id=request.DefaultWarehouseId }, tx, cancellationToken:ct)) ?? "";
|
||||
await connection.ExecuteAsync(new CommandDefinition(@"insert into erp_item_search_projection(id,code,name,category_name,specification,unit,barcode,default_warehouse_id,default_warehouse_name,lot_managed,expiry_managed,active,version,search_text)
|
||||
values(@Id,@Code,@Name,@CategoryName,@Specification,@Unit,@Barcode,@DefaultWarehouseId,@DefaultWarehouseName,@LotManaged,@ExpiryManaged,true,@Version,lower(concat_ws(' ',@Code,@Name,@CategoryName,@Barcode)))
|
||||
on conflict(id) do update set code=excluded.code,name=excluded.name,category_name=excluded.category_name,specification=excluded.specification,unit=excluded.unit,barcode=excluded.barcode,default_warehouse_id=excluded.default_warehouse_id,default_warehouse_name=excluded.default_warehouse_name,lot_managed=excluded.lot_managed,expiry_managed=excluded.expiry_managed,active=true,version=excluded.version,search_text=excluded.search_text", new { Id=id, request.Code, request.Name, CategoryName=request.CategoryName??"", Specification=request.Specification??"", request.Unit, Barcode=request.Barcode??"", request.DefaultWarehouseId, DefaultWarehouseName=warehouseName, request.LotManaged, request.ExpiryManaged, Version=version }, tx, cancellationToken:ct));
|
||||
|
||||
var changes = new[] { new { field="code", label="품목코드", before=isNew?null:(object?)before!.code, after=request.Code }, new { field="name", label="품목명", before=isNew?null:(object?)before!.name, after=request.Name }, new { field="unit", label="단위", before=isNew?null:(object?)before!.unit, after=request.Unit } };
|
||||
await connection.ExecuteAsync(new CommandDefinition(@"insert into audit.entries(id,aggregate_type,aggregate_id,action,actor,occurred_at,data) values(@AuditId,'Item',@Id,@Action,@Actor,now(),cast(@Data as jsonb));
|
||||
insert into integration.outbox(id,event_type,aggregate_id,payload,occurred_at,status) values(@EventId,'ItemChanged',@Id,jsonb_build_object('itemId',@Id,'version',@Version),now(),'PENDING');", new { AuditId=Guid.NewGuid(), EventId=Guid.NewGuid(), Id=id, Action=isNew?"ITEM_CREATED":"ITEM_SAVED", Actor=actor, Data=JsonSerializer.Serialize(new { version, changes }), Version=version }, tx, cancellationToken:ct));
|
||||
await tx.CommitAsync(ct);
|
||||
return ItemSaveResult.Ok(new(id,version,"사용"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Modules.ERP.Items.Save;
|
||||
|
||||
public sealed record ItemSaveRequest(
|
||||
Guid? Id,
|
||||
long? Version,
|
||||
string Code,
|
||||
string Name,
|
||||
string? CategoryName,
|
||||
string? Specification,
|
||||
string Unit,
|
||||
string? Barcode,
|
||||
Guid? DefaultWarehouseId,
|
||||
bool LotManaged,
|
||||
bool ExpiryManaged);
|
||||
|
||||
public sealed record ItemSaveResponse(Guid Id, long Version, string Status);
|
||||
@@ -0,0 +1,4 @@
|
||||
using FastEndpoints;
|
||||
using Shared.Problems;
|
||||
namespace Modules.ERP.Items.Save;
|
||||
public sealed class UpdateEndpoint(Handler handler):Endpoint<ItemSaveRequest,ItemSaveResponse>{public override void Configure(){Put("/api/erp/items/{id}");Permissions("erp.item.write");}public override async Task HandleAsync(ItemSaveRequest req,CancellationToken ct){var routeId=Route<Guid>("id");var r=await handler.HandleAsync(req with { Id=routeId },User,ct);if(r.Problem is KbxValidationProblem v){await Send.ResponseAsync(v,400,cancellation:ct);return;}if(r.Problem is KbxBusinessProblem b){await Send.ResponseAsync(b,409,cancellation:ct);return;}if(r.Problem is KbxConflictProblem c){await Send.ResponseAsync(c,409,cancellation:ct);return;}await Send.OkAsync(r.Response!,ct);}}
|
||||
@@ -0,0 +1,15 @@
|
||||
using FastEndpoints;
|
||||
|
||||
namespace Modules.ERP.Items.Search;
|
||||
|
||||
public sealed class Endpoint(SearchItemsHandler handler) : Endpoint<SearchItemsRequest, SearchItemsResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/erp/items");
|
||||
Permissions("erp.item.read");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(SearchItemsRequest req, CancellationToken ct)
|
||||
=> await Send.OkAsync(await handler.HandleAsync(req, ct), ct);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
|
||||
namespace Modules.ERP.Items.Search;
|
||||
|
||||
public sealed class SearchItemsHandler(NpgsqlDataSource dataSource)
|
||||
{
|
||||
public async Task<SearchItemsResponse> HandleAsync(SearchItemsRequest request, CancellationToken ct)
|
||||
{
|
||||
var page = Math.Max(request.Page, 1);
|
||||
var pageSize = Math.Clamp(request.PageSize, 1, 500);
|
||||
var offset = (page - 1) * pageSize;
|
||||
var keyword = string.IsNullOrWhiteSpace(request.Keyword) ? null : request.Keyword.Trim();
|
||||
|
||||
const string sql = """
|
||||
select
|
||||
p.id,
|
||||
p.code,
|
||||
p.name,
|
||||
p.category_name as CategoryName,
|
||||
p.specification,
|
||||
p.unit,
|
||||
p.barcode,
|
||||
p.default_warehouse_id as DefaultWarehouseId,
|
||||
p.default_warehouse_name as DefaultWarehouseName,
|
||||
p.lot_managed as LotManaged,
|
||||
p.expiry_managed as ExpiryManaged,
|
||||
p.active,
|
||||
p.version
|
||||
from erp_item_search_projection p
|
||||
where (@Keyword is null or p.search_text ilike '%' || @Keyword || '%')
|
||||
order by p.code
|
||||
limit @PageSize offset @Offset;
|
||||
|
||||
select count(*)::int
|
||||
from erp_item_search_projection p
|
||||
where (@Keyword is null or p.search_text ilike '%' || @Keyword || '%');
|
||||
""";
|
||||
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
using var multi = await connection.QueryMultipleAsync(new CommandDefinition(sql, new { Keyword = keyword, PageSize = pageSize, Offset = offset }, cancellationToken: ct));
|
||||
var items = (await multi.ReadAsync<ItemMasterRow>()).AsList();
|
||||
var count = await multi.ReadSingleAsync<int>();
|
||||
return new(items, count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Modules.ERP.Items.Search;
|
||||
|
||||
public sealed record SearchItemsRequest(
|
||||
string? Keyword,
|
||||
int Page = 1,
|
||||
int PageSize = 200);
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Modules.ERP.Items.Search;
|
||||
|
||||
public sealed record ItemMasterRow(
|
||||
Guid Id,
|
||||
string Code,
|
||||
string Name,
|
||||
string CategoryName,
|
||||
string Specification,
|
||||
string Unit,
|
||||
string Barcode,
|
||||
Guid? DefaultWarehouseId,
|
||||
string DefaultWarehouseName,
|
||||
bool LotManaged,
|
||||
bool ExpiryManaged,
|
||||
bool Active,
|
||||
long Version);
|
||||
|
||||
public sealed record SearchItemsResponse(
|
||||
IReadOnlyList<ItemMasterRow> Items,
|
||||
int TotalCount);
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
|
||||
using KBX.Shared.Workflow;
|
||||
namespace KBX.Modules.ERP.Purchases.Workflow;
|
||||
public static class PurchaseWorkflow
|
||||
{
|
||||
public static readonly WorkflowTransition[] Transitions=[
|
||||
new("confirm",new HashSet<string>{"DRAFT"},"CONFIRMED","erp.purchase.confirm"),
|
||||
new("cancel",new HashSet<string>{"DRAFT","CONFIRMED"},"CANCELLED","erp.purchase.cancel",true)
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
|
||||
using Dapper;
|
||||
using FastEndpoints;
|
||||
using Npgsql;
|
||||
namespace KBX.Modules.OMS.Claims.Search;
|
||||
public sealed record Request(DateOnly? From, DateOnly? To, string? Type, string? Status, string? Keyword, int Page = 1, int PageSize = 100);
|
||||
public sealed record Row(Guid Id,string ClaimNo,string OrderNo,string ChannelName,string Type,string Reason,decimal RequestedQty,string Status,string? OwnerName,DateTimeOffset RequestedAt);
|
||||
public sealed record Response(IReadOnlyList<Row> Items,long TotalCount);
|
||||
public sealed class Endpoint(NpgsqlDataSource db) : Endpoint<Request,Response>
|
||||
{
|
||||
public override void Configure(){Get("/api/oms/claims");Permissions("oms.claim.read");}
|
||||
public override async Task HandleAsync(Request req,CancellationToken ct){await using var c=await db.OpenConnectionAsync(ct); const string where=""" from oms.claim_search_projection where (@From is null or requested_at>=@From) and (@To is null or requested_at<@To+1) and (@Type is null or type=@Type) and (@Status is null or status=@Status) and (@Keyword is null or claim_no ilike '%'||@Keyword||'%' or order_no ilike '%'||@Keyword||'%') """; var items=(await c.QueryAsync<Row>(new CommandDefinition("select id,claim_no ClaimNo,order_no OrderNo,channel_name ChannelName,type,reason,requested_qty RequestedQty,status,owner_name OwnerName,requested_at RequestedAt"+where+" order by requested_at desc limit @PageSize offset @Offset",new {req.From,req.To,req.Type,req.Status,req.Keyword,req.PageSize,Offset=(req.Page-1)*req.PageSize},cancellationToken:ct))).AsList(); var total=await c.ExecuteScalarAsync<long>(new CommandDefinition("select count(*)"+where,req,cancellationToken:ct)); await SendOkAsync(new(items,total),ct); }
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using Dapper;
|
||||
using FastEndpoints;
|
||||
using Npgsql;
|
||||
using Shared.Problems;
|
||||
|
||||
namespace Modules.OMS.Claims.Workflow;
|
||||
|
||||
public sealed record ClaimTransitionRequest(Guid Id);
|
||||
public sealed record ClaimTransitionResponse(Guid Id, string Status, long Version);
|
||||
|
||||
public sealed class ClaimTransitionHandler(NpgsqlDataSource dataSource)
|
||||
{
|
||||
public async Task<ClaimTransitionResponse?> HandleAsync(
|
||||
Guid id,
|
||||
string fromStatus,
|
||||
string toStatus,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
return await connection.QuerySingleOrDefaultAsync<ClaimTransitionResponse>(new CommandDefinition("""
|
||||
update oms.claims
|
||||
set status=@ToStatus, version=version+1
|
||||
where id=@Id and status=@FromStatus
|
||||
returning id, status, version;
|
||||
""", new { Id = id, FromStatus = fromStatus, ToStatus = toStatus }, cancellationToken: ct));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ApproveEndpoint(ClaimTransitionHandler handler)
|
||||
: Endpoint<ClaimTransitionRequest, ClaimTransitionResponse>
|
||||
{
|
||||
public override void Configure() { Post("/api/oms/claims/{id:guid}/approve"); Permissions("oms.claim.approve"); }
|
||||
public override async Task HandleAsync(ClaimTransitionRequest req, CancellationToken ct)
|
||||
=> await RespondAsync(await handler.HandleAsync(req.Id, "REQUESTED", "APPROVED", ct), "approve", "REQUESTED", ct);
|
||||
|
||||
private async Task RespondAsync(ClaimTransitionResponse? result, string transition, string required, CancellationToken ct)
|
||||
{
|
||||
if (result is not null) { await Send.OkAsync(result, ct); return; }
|
||||
await Send.ResponseAsync(KbxBusinessProblem.Create("CLAIM_TRANSITION_NOT_ALLOWED", "현재 상태에서는 승인할 수 없습니다.", $"Transition '{transition}' requires state '{required}'."), 409, cancellation: ct);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class HoldEndpoint(ClaimTransitionHandler handler)
|
||||
: Endpoint<ClaimTransitionRequest, ClaimTransitionResponse>
|
||||
{
|
||||
public override void Configure() { Post("/api/oms/claims/{id:guid}/hold"); Permissions("oms.claim.hold"); }
|
||||
public override async Task HandleAsync(ClaimTransitionRequest req, CancellationToken ct)
|
||||
{
|
||||
var result = await handler.HandleAsync(req.Id, "REQUESTED", "HOLD", ct);
|
||||
if (result is not null) { await Send.OkAsync(result, ct); return; }
|
||||
await Send.ResponseAsync(KbxBusinessProblem.Create("CLAIM_TRANSITION_NOT_ALLOWED", "현재 상태에서는 보류할 수 없습니다."), 409, cancellation: ct);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class StartEndpoint(ClaimTransitionHandler handler)
|
||||
: Endpoint<ClaimTransitionRequest, ClaimTransitionResponse>
|
||||
{
|
||||
public override void Configure() { Post("/api/oms/claims/{id:guid}/start"); Permissions("oms.claim.process"); }
|
||||
public override async Task HandleAsync(ClaimTransitionRequest req, CancellationToken ct)
|
||||
{
|
||||
var result = await handler.HandleAsync(req.Id, "APPROVED", "IN_PROGRESS", ct);
|
||||
if (result is not null) { await Send.OkAsync(result, ct); return; }
|
||||
await Send.ResponseAsync(KbxBusinessProblem.Create("CLAIM_TRANSITION_NOT_ALLOWED", "승인된 클레임만 처리를 시작할 수 있습니다."), 409, cancellation: ct);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class CompleteEndpoint(ClaimTransitionHandler handler)
|
||||
: Endpoint<ClaimTransitionRequest, ClaimTransitionResponse>
|
||||
{
|
||||
public override void Configure() { Post("/api/oms/claims/{id:guid}/complete"); Permissions("oms.claim.process"); }
|
||||
public override async Task HandleAsync(ClaimTransitionRequest req, CancellationToken ct)
|
||||
{
|
||||
var result = await handler.HandleAsync(req.Id, "IN_PROGRESS", "COMPLETED", ct);
|
||||
if (result is not null) { await Send.OkAsync(result, ct); return; }
|
||||
await Send.ResponseAsync(KbxBusinessProblem.Create("CLAIM_TRANSITION_NOT_ALLOWED", "처리 중인 클레임만 완료할 수 있습니다."), 409, cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Dapper;
|
||||
using FastEndpoints;
|
||||
using Npgsql;
|
||||
|
||||
namespace Modules.OMS.Lookups.Customers;
|
||||
|
||||
public sealed class Endpoint(NpgsqlDataSource dataSource) : EndpointWithoutRequest
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/lookups/customers");
|
||||
Permissions("oms.order.read");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var query = Query<string>("query", false) ?? string.Empty;
|
||||
var page = Math.Max(1, Query<int>("page", false));
|
||||
var pageSize = Math.Clamp(Query<int>("pageSize", false), 1, 100);
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var items = (await connection.QueryAsync(new CommandDefinition(@"
|
||||
select id, code, name as DisplayName, case when is_active then '사용' else '중지' end as Status
|
||||
from oms.customers
|
||||
where is_active = true and (@Query = '' or code ilike '%' || @Query || '%' or name ilike '%' || @Query || '%')
|
||||
order by code
|
||||
offset @Offset limit @PageSize;", new { Query = query, Offset = (page - 1) * pageSize, PageSize = pageSize }, cancellationToken: ct))).ToArray();
|
||||
var count = await connection.ExecuteScalarAsync<int>(new CommandDefinition(@"
|
||||
select count(*) from oms.customers
|
||||
where is_active = true and (@Query = '' or code ilike '%' || @Query || '%' or name ilike '%' || @Query || '%');", new { Query = query }, cancellationToken: ct));
|
||||
await Send.OkAsync(new { items, totalCount = count }, ct);
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
using Dapper;
|
||||
using FastEndpoints;
|
||||
using Npgsql;
|
||||
|
||||
namespace Modules.OMS.Lookups.Customers;
|
||||
|
||||
public sealed class ResolveByCodeEndpoint(NpgsqlDataSource dataSource) : EndpointWithoutRequest
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/lookups/customers/by-code/{code}");
|
||||
Permissions("oms.order.read");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var code = Route<string>("code");
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var item = await connection.QuerySingleOrDefaultAsync(new CommandDefinition(@"
|
||||
select id, code, name as DisplayName, case when is_active then '사용' else '중지' end as Status
|
||||
from oms.customers where code=@Code and is_active=true;", new { Code = code }, cancellationToken: ct));
|
||||
if (item is null) { await Send.NotFoundAsync(ct); return; }
|
||||
await Send.OkAsync(item, ct);
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
using Dapper;
|
||||
using FastEndpoints;
|
||||
using Npgsql;
|
||||
|
||||
namespace Modules.OMS.Lookups.Customers;
|
||||
|
||||
public sealed class ResolveEndpoint(NpgsqlDataSource dataSource) : EndpointWithoutRequest
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/lookups/customers/{id}");
|
||||
Permissions("oms.order.read");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var id = Route<Guid>("id");
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var item = await connection.QuerySingleOrDefaultAsync(new CommandDefinition(@"
|
||||
select id, code, name as DisplayName, case when is_active then '사용' else '중지' end as Status
|
||||
from oms.customers where id=@Id;", new { Id = id }, cancellationToken: ct));
|
||||
if (item is null) { await Send.NotFoundAsync(ct); return; }
|
||||
await Send.OkAsync(item, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Dapper;
|
||||
using FastEndpoints;
|
||||
using Npgsql;
|
||||
|
||||
namespace Modules.OMS.Lookups.Items;
|
||||
|
||||
public sealed class Endpoint(NpgsqlDataSource dataSource) : EndpointWithoutRequest
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/lookups/items");
|
||||
Permissions("erp.item.read");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var query = Query<string>("query", false) ?? string.Empty;
|
||||
var page = Math.Max(1, Query<int>("page", false));
|
||||
var requestedPageSize = Query<int>("pageSize", false);
|
||||
var pageSize = Math.Clamp(requestedPageSize == 0 ? 30 : requestedPageSize, 1, 100);
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var items = (await connection.QueryAsync(new CommandDefinition(@"
|
||||
select id, code, name as DisplayName, case when is_active then '사용' else '중지' end as Status
|
||||
from catalog.items
|
||||
where is_active = true and (@Query = '' or code ilike '%' || @Query || '%' or name ilike '%' || @Query || '%')
|
||||
order by code
|
||||
offset @Offset limit @PageSize;", new { Query = query, Offset = (page - 1) * pageSize, PageSize = pageSize }, cancellationToken: ct))).ToArray();
|
||||
var count = await connection.ExecuteScalarAsync<int>(new CommandDefinition(@"
|
||||
select count(*) from catalog.items
|
||||
where is_active = true and (@Query = '' or code ilike '%' || @Query || '%' or name ilike '%' || @Query || '%');", new { Query = query }, cancellationToken: ct));
|
||||
await Send.OkAsync(new { items, totalCount = count }, ct);
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
using Dapper;
|
||||
using FastEndpoints;
|
||||
using Npgsql;
|
||||
|
||||
namespace Modules.OMS.Lookups.Items;
|
||||
|
||||
public sealed class ResolveByCodeEndpoint(NpgsqlDataSource dataSource) : EndpointWithoutRequest
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/lookups/items/by-code/{code}");
|
||||
Permissions("erp.item.read");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var code = Route<string>("code");
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var item = await connection.QuerySingleOrDefaultAsync(new CommandDefinition(@"
|
||||
select id, code, name as DisplayName, case when is_active then '사용' else '중지' end as Status
|
||||
from catalog.items where code=@Code and is_active=true;", new { Code = code }, cancellationToken: ct));
|
||||
if (item is null) { await Send.NotFoundAsync(ct); return; }
|
||||
await Send.OkAsync(item, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using Dapper;
|
||||
using FastEndpoints;
|
||||
using Npgsql;
|
||||
|
||||
namespace Modules.OMS.Lookups.Items;
|
||||
|
||||
public sealed class ResolveEndpoint(NpgsqlDataSource dataSource) : EndpointWithoutRequest
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/lookups/items/{id}");
|
||||
Permissions("erp.item.read");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var id = Route<Guid>("id");
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var item = await connection.QuerySingleOrDefaultAsync(new CommandDefinition(@"
|
||||
select id, code, name as DisplayName, case when is_active then '사용' else '중지' end as Status
|
||||
from catalog.items where id=@Id;", new { Id = id }, cancellationToken: ct));
|
||||
if (item is null) { await Send.NotFoundAsync(ct); return; }
|
||||
await Send.OkAsync(item, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Dapper;
|
||||
using FastEndpoints;
|
||||
using Npgsql;
|
||||
|
||||
namespace Modules.OMS.Lookups.Warehouses;
|
||||
|
||||
public sealed class Endpoint(NpgsqlDataSource dataSource) : EndpointWithoutRequest
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/lookups/warehouses");
|
||||
Permissions("oms.order.read");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var query = Query<string>("query", false) ?? string.Empty;
|
||||
var page = Math.Max(1, Query<int>("page", false));
|
||||
var requestedPageSize = Query<int>("pageSize", false);
|
||||
var pageSize = Math.Clamp(requestedPageSize == 0 ? 30 : requestedPageSize, 1, 100);
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var items = (await connection.QueryAsync(new CommandDefinition(@"
|
||||
select id, code, name as DisplayName, case when is_active then '사용' else '중지' end as Status
|
||||
from inventory.warehouses
|
||||
where is_active = true and (@Query = '' or code ilike '%' || @Query || '%' or name ilike '%' || @Query || '%')
|
||||
order by code
|
||||
offset @Offset limit @PageSize;", new { Query = query, Offset = (page - 1) * pageSize, PageSize = pageSize }, cancellationToken: ct))).ToArray();
|
||||
var count = await connection.ExecuteScalarAsync<int>(new CommandDefinition(@"
|
||||
select count(*) from inventory.warehouses
|
||||
where is_active = true and (@Query = '' or code ilike '%' || @Query || '%' or name ilike '%' || @Query || '%');", new { Query = query }, cancellationToken: ct));
|
||||
await Send.OkAsync(new { items, totalCount = count }, ct);
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
using Dapper;
|
||||
using FastEndpoints;
|
||||
using Npgsql;
|
||||
|
||||
namespace Modules.OMS.Lookups.Warehouses;
|
||||
|
||||
public sealed class ResolveByCodeEndpoint(NpgsqlDataSource dataSource) : EndpointWithoutRequest
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/lookups/warehouses/by-code/{code}");
|
||||
Permissions("oms.order.read");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var code = Route<string>("code");
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var item = await connection.QuerySingleOrDefaultAsync(new CommandDefinition(@"
|
||||
select id, code, name as DisplayName, case when is_active then '사용' else '중지' end as Status
|
||||
from inventory.warehouses where code=@Code and is_active=true;", new { Code = code }, cancellationToken: ct));
|
||||
if (item is null) { await Send.NotFoundAsync(ct); return; }
|
||||
await Send.OkAsync(item, ct);
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
using Dapper;
|
||||
using FastEndpoints;
|
||||
using Npgsql;
|
||||
|
||||
namespace Modules.OMS.Lookups.Warehouses;
|
||||
|
||||
public sealed class ResolveEndpoint(NpgsqlDataSource dataSource) : EndpointWithoutRequest
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/lookups/warehouses/{id}");
|
||||
Permissions("oms.order.read");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var id = Route<Guid>("id");
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var item = await connection.QuerySingleOrDefaultAsync(new CommandDefinition(@"
|
||||
select id, code, name as DisplayName, case when is_active then '사용' else '중지' end as Status
|
||||
from inventory.warehouses where id=@Id;", new { Id = id }, cancellationToken: ct));
|
||||
if (item is null) { await Send.NotFoundAsync(ct); return; }
|
||||
await Send.OkAsync(item, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
using System.Text.Json;using Dapper;using FastEndpoints;using Npgsql;
|
||||
namespace Modules.OMS.Orders.Audit;
|
||||
public sealed record AuditActor(string Type,string DisplayName);public sealed record AuditChange(string Field,string Label,object? Before,object? After);public sealed record AuditRow(Guid Id,DateTimeOffset OccurredAt,string Actor,string Action,string Data);public sealed record AuditEntry(Guid Id,DateTimeOffset OccurredAt,AuditActor Actor,string Action,IReadOnlyList<AuditChange>? Changes,string? Reason);
|
||||
public sealed class Endpoint(NpgsqlDataSource dataSource):EndpointWithoutRequest<IReadOnlyList<AuditEntry>>{public override void Configure(){Get("/api/oms/orders/{id}/audit");Permissions("oms.order.read");}public override async Task HandleAsync(CancellationToken ct){var id=Route<Guid>("id");await using var c=await dataSource.OpenConnectionAsync(ct);var rows=await c.QueryAsync<AuditRow>(new CommandDefinition("select id,occurred_at as OccurredAt,actor,action,data::text as Data from audit.entries where aggregate_type='Order' and aggregate_id=@Id order by occurred_at desc limit 100",new{Id=id},cancellationToken:ct));var result=rows.Select(r=>{using var d=JsonDocument.Parse(r.Data);List<AuditChange>? changes=null;if(d.RootElement.TryGetProperty("changes",out var arr)){changes=new();foreach(var x in arr.EnumerateArray())changes.Add(new(x.GetProperty("field").GetString()??"",x.GetProperty("label").GetString()??"",x.TryGetProperty("before",out var b)?b.ToString():null,x.TryGetProperty("after",out var a)?a.ToString():null));}return new AuditEntry(r.Id,r.OccurredAt,new("user",r.Actor),r.Action,changes,null);}).ToList();await Send.OkAsync(result,ct);}}
|
||||
@@ -0,0 +1,4 @@
|
||||
using System.Text.Json;using Dapper;using FastEndpoints;using Npgsql;using Shared.Problems;
|
||||
namespace Modules.OMS.Orders.Confirm;
|
||||
public sealed record Request(long Version);public sealed record Response(Guid OrderId,long Version,string Status);
|
||||
public sealed class Endpoint(NpgsqlDataSource dataSource):Endpoint<Request,Response>{public override void Configure(){Post("/api/oms/orders/{id}/confirm");Permissions("oms.order.confirm");}public override async Task HandleAsync(Request req,CancellationToken ct){var id=Route<Guid>("id");var key=HttpContext.Request.Headers["Idempotency-Key"].FirstOrDefault();if(string.IsNullOrWhiteSpace(key)){await Send.ResponseAsync(KbxValidationProblem.Create(new(null,null,"IDEMPOTENCY_KEY_REQUIRED","안전한 재처리를 위해 Idempotency-Key가 필요합니다.")),400,cancellation:ct);return;}await using var c=await dataSource.OpenConnectionAsync(ct);await using var tx=await c.BeginTransactionAsync(ct);var replay=await c.QuerySingleOrDefaultAsync<string?>(new CommandDefinition("select response_json::text from kbx.command_receipts where operation_id=@OperationId and idempotency_key=@Key",new{OperationId="oms.orders.confirm",Key=key},tx,cancellationToken:ct));if(replay is not null){await tx.RollbackAsync(ct);await Send.OkAsync(JsonSerializer.Deserialize<Response>(replay)!,ct);return;}var current=await c.QuerySingleOrDefaultAsync<(long Version,string Status)>(new CommandDefinition("select version as Version,status as Status from oms.orders where id=@Id for update",new{Id=id},tx,cancellationToken:ct));if(current.Status is null){await Send.ResponseAsync(KbxNotFoundProblem.Create("ORDER_NOT_FOUND","주문을 찾을 수 없습니다."),404,cancellation:ct);return;}if(current.Version!=req.Version){await Send.ResponseAsync(KbxConflictProblem.Version(current.Version),409,cancellation:ct);return;}if(current.Status!="DRAFT"){await Send.ResponseAsync(KbxBusinessProblem.Create("ORDER_NOT_CONFIRMABLE","주문을 확정할 수 없습니다.","작성 상태의 주문만 확정할 수 있습니다."),409,cancellation:ct);return;}var next=current.Version+1;var actor=User.Identity?.Name??"unknown";await c.ExecuteAsync(new CommandDefinition(@"update oms.orders set status='CONFIRMED',version=@Version,updated_at=now(),updated_by=@Actor where id=@Id;insert into audit.entries(id,aggregate_type,aggregate_id,action,actor,occurred_at,data) values(@AuditId,'Order',@Id,'ORDER_CONFIRMED',@Actor,now(),jsonb_build_object('version',@Version,'changes',jsonb_build_array(jsonb_build_object('field','status','label','상태','before','작성','after','확정'))));insert into integration.outbox(id,event_type,aggregate_id,payload,occurred_at,status) values(@EventId,'OrderConfirmed',@Id,jsonb_build_object('orderId',@Id,'version',@Version),now(),'PENDING');",new{Id=id,Version=next,Actor=actor,AuditId=Guid.NewGuid(),EventId=Guid.NewGuid()},tx,cancellationToken:ct));var response=new Response(id,next,"확정");await c.ExecuteAsync(new CommandDefinition("insert into kbx.command_receipts(operation_id,idempotency_key,response_json) values(@OperationId,@Key,cast(@ResponseJson as jsonb))",new{OperationId="oms.orders.confirm",Key=key,ResponseJson=JsonSerializer.Serialize(response)},tx,cancellationToken:ct));await tx.CommitAsync(ct);await Send.OkAsync(response,ct);}}
|
||||
@@ -0,0 +1,5 @@
|
||||
using Dapper;using FastEndpoints;using Npgsql;using Shared.Problems;
|
||||
namespace Modules.OMS.Orders.Get;
|
||||
public sealed record OrderLine(Guid Id,string ClientId,Guid ItemId,string ItemCode,string ItemName,decimal OrderQty,decimal UnitPrice,decimal Amount,string Remark);
|
||||
public sealed record Response(Guid OrderId,string OrderNo,long Version,string Status,DateOnly OrderDate,Guid CustomerId,Guid WarehouseId,string ReceiverName,string Phone,string? PostalCode,string Address1,string? Address2,IReadOnlyList<OrderLine> Lines);
|
||||
public sealed class Endpoint(NpgsqlDataSource dataSource):EndpointWithoutRequest<Response>{public override void Configure(){Get("/api/oms/orders/{id}");Permissions("oms.order.read");}public override async Task HandleAsync(CancellationToken ct){var id=Route<Guid>("id");await using var c=await dataSource.OpenConnectionAsync(ct);var h=await c.QuerySingleOrDefaultAsync(new CommandDefinition("select id as OrderId,order_no as OrderNo,version,status,order_date as OrderDate,customer_id as CustomerId,warehouse_id as WarehouseId,receiver_name as ReceiverName,phone,postal_code as PostalCode,address1,address2 from oms.orders where id=@Id",new{Id=id},cancellationToken:ct));if(h is null){await Send.ResponseAsync(KbxNotFoundProblem.Create("ORDER_NOT_FOUND","주문을 찾을 수 없습니다."),404,cancellation:ct);return;}var lines=(await c.QueryAsync<OrderLine>(new CommandDefinition(@"select l.id,l.id::text as ClientId,l.item_id as ItemId,i.code as ItemCode,i.name as ItemName,l.quantity as OrderQty,l.unit_price as UnitPrice,l.amount,coalesce(l.remark,'') as Remark from oms.order_lines l join catalog.items i on i.id=l.item_id where l.order_id=@Id order by l.line_no",new{Id=id},cancellationToken:ct))).AsList();string display=(string)h.status switch{"DRAFT"=>"작성","CONFIRMED"=>"확정","ALLOCATED"=>"할당","PICKING"=>"피킹","CHECKED"=>"검수","SHIPPED"=>"출고완료",_=>(string)h.status};await Send.OkAsync(new((Guid)h.orderid,(string)h.orderno,(long)h.version,display,(DateOnly)h.orderdate,(Guid)h.customerid,(Guid)h.warehouseid,(string)h.receivername,(string)h.phone,(string?)h.postalcode,(string)h.address1,(string?)h.address2,lines),ct);}}
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
using Shared.Excel;
|
||||
using Kbx.Contracts.Generated;
|
||||
|
||||
namespace Modules.OMS.Orders.Import;
|
||||
|
||||
public sealed class OrderImportDefinition(NpgsqlDataSource dataSource) : IImportDefinition
|
||||
{
|
||||
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public ImportDefinition Definition { get; } = new(
|
||||
Id: "oms.orders.v1",
|
||||
ScreenId: "OMS-ORD-003",
|
||||
Entity: "order",
|
||||
Title: "주문 Excel 업로드",
|
||||
Fields: [
|
||||
I(KbxFieldKeys.OrderNo),
|
||||
I(KbxFieldKeys.OrderDate),
|
||||
I(KbxFieldKeys.CustomerCode),
|
||||
I(KbxFieldKeys.WarehouseCode),
|
||||
I(KbxFieldKeys.ReceiverName),
|
||||
I(KbxFieldKeys.Phone),
|
||||
I(KbxFieldKeys.PostalCode),
|
||||
I(KbxFieldKeys.Address1),
|
||||
I(KbxFieldKeys.Address2),
|
||||
I(KbxFieldKeys.ItemCode),
|
||||
I(KbxFieldKeys.OrderQty),
|
||||
I(KbxFieldKeys.UnitPrice),
|
||||
I(KbxFieldKeys.Remark),
|
||||
],
|
||||
AllowCreate: true,
|
||||
AllowUpdate: true,
|
||||
MaxFileSizeBytes: 20 * 1024 * 1024,
|
||||
MaxRows: 100_000);
|
||||
|
||||
public async Task<IReadOnlyList<ImportRowValidation>> ValidateAsync(
|
||||
IReadOnlyList<ImportRawRow> rows,
|
||||
IReadOnlyDictionary<string, string> targetToSource,
|
||||
NpgsqlConnection connection,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var customerCodes = Values(rows, targetToSource, KbxFieldKeys.CustomerCode);
|
||||
var warehouseCodes = Values(rows, targetToSource, KbxFieldKeys.WarehouseCode);
|
||||
var itemCodes = Values(rows, targetToSource, KbxFieldKeys.ItemCode);
|
||||
var orderNos = Values(rows, targetToSource, KbxFieldKeys.OrderNo);
|
||||
|
||||
var customers = (await connection.QueryAsync<RefRow>(new CommandDefinition(
|
||||
"select id,code from oms.customers where is_active and code = any(@Codes)",
|
||||
new { Codes = customerCodes }, cancellationToken: ct))).ToDictionary(x => x.Code, StringComparer.OrdinalIgnoreCase);
|
||||
var warehouses = (await connection.QueryAsync<RefRow>(new CommandDefinition(
|
||||
"select id,code from inventory.warehouses where is_active and code = any(@Codes)",
|
||||
new { Codes = warehouseCodes }, cancellationToken: ct))).ToDictionary(x => x.Code, StringComparer.OrdinalIgnoreCase);
|
||||
var items = (await connection.QueryAsync<RefRow>(new CommandDefinition(
|
||||
"select id,code from catalog.items where is_active and code = any(@Codes)",
|
||||
new { Codes = itemCodes }, cancellationToken: ct))).ToDictionary(x => x.Code, StringComparer.OrdinalIgnoreCase);
|
||||
var existing = (await connection.QueryAsync<ExistingOrder>(new CommandDefinition(
|
||||
"""
|
||||
select id, order_no as "OrderNo", status
|
||||
from oms.orders
|
||||
where order_no = any(@OrderNos)
|
||||
""",
|
||||
new { OrderNos = orderNos }, cancellationToken: ct))).ToDictionary(x => x.OrderNo, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var results = new List<ImportRowValidation>(rows.Count);
|
||||
foreach (var row in rows)
|
||||
results.Add(ValidateRow(row, targetToSource, customers, warehouses, items, existing));
|
||||
|
||||
// One order can span multiple Excel rows. Header values must be identical across its lines.
|
||||
foreach (var group in results.Where(x => x.NormalizedData is not null && x.DomainKey is not null).GroupBy(x => x.DomainKey!, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var signatures = group.Select(x => HeaderSignature(x.NormalizedData!.RootElement)).Distinct(StringComparer.Ordinal).ToArray();
|
||||
if (signatures.Length <= 1) continue;
|
||||
foreach (var row in group)
|
||||
{
|
||||
var issue = new ImportIssue(row.RowNumber, null, null, "ORDER_HEADER_CONFLICT", "같은 주문번호의 주문일·거래처·창고·배송정보가 서로 다릅니다.", "error");
|
||||
var index = results.IndexOf(row);
|
||||
results[index] = row with { Errors = row.Errors.Append(issue).ToArray() };
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<ImportCommitResult> CommitAsync(Guid sessionId, string actor, CancellationToken ct)
|
||||
{
|
||||
await using var readConnection = await dataSource.OpenConnectionAsync(ct);
|
||||
var staged = (await readConnection.QueryAsync<StagedRow>(new CommandDefinition("""
|
||||
select row_number as "RowNumber", domain_key as "DomainKey", normalized_data::text as "NormalizedJson"
|
||||
from kbx.import_rows
|
||||
where session_id=@SessionId and status='VALID'
|
||||
order by domain_key,row_number;
|
||||
""", new { SessionId = sessionId }, cancellationToken: ct))).ToArray();
|
||||
|
||||
var created = 0; var updated = 0; var failed = 0;
|
||||
foreach (var group in staged.GroupBy(x => x.DomainKey, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var lines = group.Select(x => JsonSerializer.Deserialize<NormalizedOrderRow>(x.NormalizedJson, Json)!).ToArray();
|
||||
try
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var tx = await connection.BeginTransactionAsync(ct);
|
||||
var existing = await connection.QuerySingleOrDefaultAsync<ExistingOrder>(new CommandDefinition(
|
||||
"""
|
||||
select id, order_no as "OrderNo", status
|
||||
from oms.orders
|
||||
where order_no=@OrderNo
|
||||
for update
|
||||
""",
|
||||
new { OrderNo = group.Key }, tx, cancellationToken: ct));
|
||||
|
||||
Guid orderId;
|
||||
var header = lines[0];
|
||||
if (existing is null)
|
||||
{
|
||||
orderId = Guid.NewGuid();
|
||||
await connection.ExecuteAsync(new CommandDefinition("""
|
||||
insert into oms.orders(id,order_no,order_date,customer_id,warehouse_id,receiver_name,phone,postal_code,address1,address2,status,version,created_at,created_by)
|
||||
values(@Id,@OrderNo,@OrderDate,@CustomerId,@WarehouseId,@ReceiverName,@Phone,@PostalCode,@Address1,@Address2,'NEW',1,now(),@Actor);
|
||||
""", new { Id=orderId, header.OrderNo, header.OrderDate, header.CustomerId, header.WarehouseId, header.ReceiverName, header.Phone, header.PostalCode, header.Address1, header.Address2, Actor=actor }, tx, cancellationToken: ct));
|
||||
created++;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (existing.Status is not ("NEW" or "DRAFT")) throw new InvalidOperationException($"{existing.OrderNo} 주문은 현재 상태에서 수정할 수 없습니다.");
|
||||
orderId = existing.Id;
|
||||
await connection.ExecuteAsync(new CommandDefinition("""
|
||||
update oms.orders set order_date=@OrderDate,customer_id=@CustomerId,warehouse_id=@WarehouseId,
|
||||
receiver_name=@ReceiverName,phone=@Phone,postal_code=@PostalCode,address1=@Address1,address2=@Address2,
|
||||
version=version+1,updated_at=now(),updated_by=@Actor where id=@Id;
|
||||
delete from oms.order_lines where order_id=@Id;
|
||||
""", new { Id=orderId, header.OrderDate, header.CustomerId, header.WarehouseId, header.ReceiverName, header.Phone, header.PostalCode, header.Address1, header.Address2, Actor=actor }, tx, cancellationToken: ct));
|
||||
updated++;
|
||||
}
|
||||
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i];
|
||||
await connection.ExecuteAsync(new CommandDefinition("""
|
||||
insert into oms.order_lines(id,order_id,line_no,item_id,quantity,unit_price,amount,remark)
|
||||
values(@Id,@OrderId,@LineNo,@ItemId,@Quantity,@UnitPrice,@Amount,@Remark);
|
||||
""", new { Id=Guid.NewGuid(), OrderId=orderId, LineNo=i+1, line.ItemId, Quantity=line.OrderQty, line.UnitPrice, Amount=line.OrderQty*line.UnitPrice, line.Remark }, tx, cancellationToken: ct));
|
||||
}
|
||||
|
||||
await connection.ExecuteAsync(new CommandDefinition("""
|
||||
update kbx.import_rows set status='COMMITTED'
|
||||
where session_id=@SessionId and domain_key=@OrderNo and status='VALID';
|
||||
insert into audit.entries(id,aggregate_type,aggregate_id,action,actor,occurred_at,data)
|
||||
values(@AuditId,'Order',@OrderId,'ExcelImport',@Actor,now(),jsonb_build_object('sessionId',@SessionText,'orderNo',@OrderNo));
|
||||
insert into integration.outbox(id,event_type,aggregate_id,payload,occurred_at,status)
|
||||
values(@EventId,'OrderImported',@OrderId,jsonb_build_object('sessionId',@SessionText,'orderId',@OrderText,'orderNo',@OrderNo),now(),'PENDING');
|
||||
""", new { SessionId=sessionId, SessionText=sessionId.ToString(), OrderNo=group.Key, AuditId=Guid.NewGuid(), EventId=Guid.NewGuid(), OrderId=orderId, OrderText=orderId.ToString(), Actor=actor }, tx, cancellationToken: ct));
|
||||
await tx.CommitAsync(ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failed += group.Count();
|
||||
await readConnection.ExecuteAsync(new CommandDefinition("""
|
||||
update kbx.import_rows set status='COMMIT_FAILED', errors = errors || jsonb_build_array(jsonb_build_object(
|
||||
'rowNumber',row_number,'code','COMMIT_FAILED','message',@Message,'severity','error'))
|
||||
where session_id=@SessionId and domain_key=@OrderNo and status='VALID';
|
||||
""", new { SessionId=sessionId, OrderNo=group.Key, Message=ex.Message }, cancellationToken: ct));
|
||||
}
|
||||
}
|
||||
return new ImportCommitResult(created, updated, failed);
|
||||
}
|
||||
|
||||
private static ImportRowValidation ValidateRow(
|
||||
ImportRawRow row,
|
||||
IReadOnlyDictionary<string,string> map,
|
||||
IReadOnlyDictionary<string,RefRow> customers,
|
||||
IReadOnlyDictionary<string,RefRow> warehouses,
|
||||
IReadOnlyDictionary<string,RefRow> items,
|
||||
IReadOnlyDictionary<string,ExistingOrder> existing)
|
||||
{
|
||||
var errors = new List<ImportIssue>();
|
||||
string? V(string key) => map.TryGetValue(key, out var column) && row.Values.TryGetValue(column, out var value) ? value?.Trim() : null;
|
||||
void Required(string key, string label, string? value) { if (string.IsNullOrWhiteSpace(value)) errors.Add(E(row.RowNumber,key,map,label + "을(를) 입력하세요.")); }
|
||||
|
||||
var orderNo=V(KbxFieldKeys.OrderNo); var customerCode=V(KbxFieldKeys.CustomerCode); var warehouseCode=V(KbxFieldKeys.WarehouseCode); var itemCode=V(KbxFieldKeys.ItemCode);
|
||||
var receiver=V(KbxFieldKeys.ReceiverName); var phone=V(KbxFieldKeys.Phone); var address1=V(KbxFieldKeys.Address1);
|
||||
Required("orderNo","주문번호",orderNo); Required("customerCode","거래처코드",customerCode); Required("warehouseCode","출고창고",warehouseCode);
|
||||
Required("itemCode","품목코드",itemCode); Required("receiverName","수취인",receiver); Required("phone","연락처",phone); Required("address1","주소",address1);
|
||||
|
||||
if (!TryDate(V(KbxFieldKeys.OrderDate), out var orderDate)) errors.Add(E(row.RowNumber,KbxFieldKeys.OrderDate,map,"주문일 형식을 확인하세요. (yyyy-MM-dd 또는 yyyyMMdd)"));
|
||||
if (!TryDecimal(V(KbxFieldKeys.OrderQty), out var orderQty) || orderQty <= 0) errors.Add(E(row.RowNumber,KbxFieldKeys.OrderQty,map,"수량은 0보다 커야 합니다."));
|
||||
if (!TryDecimal(V(KbxFieldKeys.UnitPrice), out var unitPrice) || unitPrice < 0) errors.Add(E(row.RowNumber,KbxFieldKeys.UnitPrice,map,"단가는 0 이상이어야 합니다."));
|
||||
|
||||
customers.TryGetValue(customerCode ?? "", out var customer);
|
||||
warehouses.TryGetValue(warehouseCode ?? "", out var warehouse);
|
||||
items.TryGetValue(itemCode ?? "", out var item);
|
||||
if (!string.IsNullOrWhiteSpace(customerCode) && customer is null) errors.Add(E(row.RowNumber,"customerCode",map,$"존재하지 않거나 사용중지된 거래처코드입니다: {customerCode}"));
|
||||
if (!string.IsNullOrWhiteSpace(warehouseCode) && warehouse is null) errors.Add(E(row.RowNumber,"warehouseCode",map,$"존재하지 않거나 사용중지된 창고코드입니다: {warehouseCode}"));
|
||||
if (!string.IsNullOrWhiteSpace(itemCode) && item is null) errors.Add(E(row.RowNumber,"itemCode",map,$"존재하지 않거나 사용중지된 품목코드입니다: {itemCode}"));
|
||||
if (!string.IsNullOrWhiteSpace(orderNo) && existing.TryGetValue(orderNo, out var old) && old.Status is not ("NEW" or "DRAFT"))
|
||||
errors.Add(E(row.RowNumber,"orderNo",map,$"{old.Status} 상태의 기존 주문은 Excel로 수정할 수 없습니다."));
|
||||
|
||||
JsonDocument? normalized = null;
|
||||
if (errors.Count == 0)
|
||||
{
|
||||
normalized = JsonSerializer.SerializeToDocument(new NormalizedOrderRow(
|
||||
orderNo!, orderDate, customer!.Id, customerCode!, warehouse!.Id, warehouseCode!, receiver!, phone!, V(KbxFieldKeys.PostalCode), address1!, V(KbxFieldKeys.Address2), item!.Id, itemCode!, orderQty, unitPrice, V(KbxFieldKeys.Remark)), Json);
|
||||
}
|
||||
return new(row.RowNumber, row.Values, orderNo, normalized, errors, []);
|
||||
}
|
||||
|
||||
private static ImportIssue E(int row,string field,IReadOnlyDictionary<string,string> map,string message) =>
|
||||
new(row,field,map.TryGetValue(field,out var source)?source:null,"INVALID_VALUE",message,"error");
|
||||
|
||||
private static bool TryDate(string? value, out DateOnly result) =>
|
||||
DateOnly.TryParseExact(value, ["yyyy-MM-dd","yyyyMMdd","yyyy/M/d"], CultureInfo.InvariantCulture, DateTimeStyles.None, out result);
|
||||
private static bool TryDecimal(string? value, out decimal result) =>
|
||||
decimal.TryParse(value?.Replace(",", ""), NumberStyles.Number, CultureInfo.InvariantCulture, out result);
|
||||
private static string[] Values(IReadOnlyList<ImportRawRow> rows,IReadOnlyDictionary<string,string> map,string field) =>
|
||||
map.TryGetValue(field,out var source) ? rows.Select(x=>x.Values.TryGetValue(source,out var v)?v?.Trim():null).Where(x=>!string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).Cast<string>().ToArray() : [];
|
||||
private static string HeaderSignature(JsonElement e) => string.Join("|", ["orderDate","customerId","warehouseId","receiverName","phone","postalCode","address1","address2"].Select(x=>e.TryGetProperty(x,out var v)?v.ToString():""));
|
||||
private static ImportFieldDefinition I(string key) => ImportFieldDefinitionFactory.FromKbxField(key);
|
||||
|
||||
private sealed record RefRow(Guid Id,string Code);
|
||||
private sealed record ExistingOrder(Guid Id,string OrderNo,string Status);
|
||||
private sealed record StagedRow(int RowNumber,string DomainKey,string NormalizedJson);
|
||||
private sealed record NormalizedOrderRow(string OrderNo,DateOnly OrderDate,Guid CustomerId,string CustomerCode,Guid WarehouseId,string WarehouseCode,string ReceiverName,string Phone,string? PostalCode,string Address1,string? Address2,Guid ItemId,string ItemCode,decimal OrderQty,decimal UnitPrice,string? Remark);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using FastEndpoints;
|
||||
using Shared.Problems;
|
||||
|
||||
namespace Modules.OMS.Orders.Register;
|
||||
|
||||
public sealed class Endpoint(RegisterOrderHandler handler)
|
||||
: Endpoint<RegisterOrderRequest, RegisterOrderResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/oms/orders/register");
|
||||
Permissions("oms.order.create");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(RegisterOrderRequest req, CancellationToken ct)
|
||||
{
|
||||
if (req.OrderId is not null)
|
||||
{
|
||||
await Send.ResponseAsync(KbxBusinessProblem.Create("ORDER_CREATE_ENDPOINT_ONLY", "신규 주문 등록 요청이 아닙니다.", "기존 주문 수정은 전용 수정 API를 사용하세요."), 409, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
var result = await handler.HandleAsync(req with { OrderId = null, Version = null }, User, ct);
|
||||
|
||||
if (result.Problem is KbxValidationProblem validation)
|
||||
{
|
||||
await Send.ResponseAsync(validation, 400, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
if (result.Problem is KbxBusinessProblem business)
|
||||
{
|
||||
await Send.ResponseAsync(business, 409, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
if (result.Problem is KbxConflictProblem conflict)
|
||||
{
|
||||
await Send.ResponseAsync(conflict, 409, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
await Send.OkAsync(result.Response!, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using System.Data;
|
||||
using System.Security.Claims;
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
using Shared.Problems;
|
||||
|
||||
namespace Modules.OMS.Orders.Register;
|
||||
|
||||
public sealed record RegisterOrderResult(RegisterOrderResponse? Response, object? Problem)
|
||||
{
|
||||
public static RegisterOrderResult Ok(RegisterOrderResponse response) => new(response, null);
|
||||
public static RegisterOrderResult Fail(object problem) => new(null, problem);
|
||||
}
|
||||
|
||||
public sealed class RegisterOrderHandler(NpgsqlDataSource dataSource)
|
||||
{
|
||||
public async Task<RegisterOrderResult> HandleAsync(
|
||||
RegisterOrderRequest request,
|
||||
ClaimsPrincipal user,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var tx = await connection.BeginTransactionAsync(IsolationLevel.ReadCommitted, ct);
|
||||
|
||||
// Lookup/Domain facts are re-read on the server. UI/Zod values are never trusted as business truth.
|
||||
var customerExists = await connection.ExecuteScalarAsync<bool>(new CommandDefinition(
|
||||
"select exists(select 1 from oms.customers where id = @Id and is_active = true)",
|
||||
new { Id = request.CustomerId }, tx, cancellationToken: ct));
|
||||
if (!customerExists)
|
||||
return RegisterOrderResult.Fail(KbxValidationProblem.Create(
|
||||
new("customerId", null, "CUSTOMER_NOT_FOUND", "사용 가능한 거래처가 아닙니다.")));
|
||||
|
||||
var warehouseExists = await connection.ExecuteScalarAsync<bool>(new CommandDefinition(
|
||||
"select exists(select 1 from inventory.warehouses where id = @Id and is_active = true)",
|
||||
new { Id = request.WarehouseId }, tx, cancellationToken: ct));
|
||||
if (!warehouseExists)
|
||||
return RegisterOrderResult.Fail(KbxValidationProblem.Create(
|
||||
new("warehouseId", null, "WAREHOUSE_NOT_FOUND", "사용 가능한 출고창고가 아닙니다.")));
|
||||
|
||||
var lineErrors = new List<KbxValidationError>();
|
||||
foreach (var line in request.Lines)
|
||||
{
|
||||
if (line.EffectiveOrderQty <= 0)
|
||||
lineErrors.Add(new("orderQty", line.ClientId, "QUANTITY_POSITIVE", "수량은 0보다 커야 합니다."));
|
||||
if (line.UnitPrice < 0)
|
||||
lineErrors.Add(new("unitPrice", line.ClientId, "UNIT_PRICE_NONNEGATIVE", "단가는 0 이상이어야 합니다."));
|
||||
}
|
||||
|
||||
var requestedItemIds = request.Lines.Select(x => x.ItemId).Distinct().ToArray();
|
||||
var activeItemIds = (await connection.QueryAsync<Guid>(new CommandDefinition(
|
||||
"select id from catalog.items where id = any(@Ids) and is_active = true",
|
||||
new { Ids = requestedItemIds }, tx, cancellationToken: ct))).ToHashSet();
|
||||
|
||||
foreach (var line in request.Lines.Where(x => !activeItemIds.Contains(x.ItemId)))
|
||||
lineErrors.Add(new("itemCode", line.ClientId, "ITEM_NOT_FOUND", "사용 가능한 품목이 아닙니다."));
|
||||
|
||||
if (lineErrors.Count > 0)
|
||||
return RegisterOrderResult.Fail(KbxValidationProblem.Create(lineErrors.ToArray()));
|
||||
|
||||
var actor = user.Identity?.Name ?? "unknown";
|
||||
var orderId = request.OrderId ?? Guid.NewGuid();
|
||||
var nextVersion = 1L;
|
||||
string orderNo;
|
||||
|
||||
if (request.OrderId is null)
|
||||
{
|
||||
orderNo = $"ORD-{DateTime.UtcNow:yyyyMMdd}-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
|
||||
await connection.ExecuteAsync(new CommandDefinition(@"
|
||||
insert into oms.orders(id, order_no, order_date, customer_id, warehouse_id, receiver_name, phone, postal_code, address1, address2, status, version, created_at, created_by)
|
||||
values (@Id, @OrderNo, @OrderDate, @CustomerId, @WarehouseId, @ReceiverName, @Phone, @PostalCode, @Address1, @Address2, 'DRAFT', 1, now(), @Actor);",
|
||||
new { Id = orderId, OrderNo = orderNo, request.OrderDate, request.CustomerId, request.WarehouseId, request.ReceiverName, request.Phone, request.PostalCode, request.Address1, request.Address2, Actor = actor }, tx, cancellationToken: ct));
|
||||
}
|
||||
else
|
||||
{
|
||||
var current = await connection.QuerySingleOrDefaultAsync<(string OrderNo, long Version, string Status)>(new CommandDefinition(
|
||||
"select order_no as OrderNo, version as Version, status as Status from oms.orders where id = @Id for update",
|
||||
new { Id = orderId }, tx, cancellationToken: ct));
|
||||
if (current.OrderNo is null)
|
||||
return RegisterOrderResult.Fail(KbxBusinessProblem.Create("ORDER_NOT_FOUND", "주문을 찾을 수 없습니다."));
|
||||
if (current.Version != request.Version)
|
||||
return RegisterOrderResult.Fail(KbxConflictProblem.Version(current.Version));
|
||||
if (current.Status != "DRAFT")
|
||||
return RegisterOrderResult.Fail(KbxBusinessProblem.Create("ORDER_NOT_EDITABLE", "주문을 수정할 수 없습니다.", "작성 상태의 주문만 수정할 수 있습니다."));
|
||||
|
||||
orderNo = current.OrderNo;
|
||||
nextVersion = current.Version + 1;
|
||||
await connection.ExecuteAsync(new CommandDefinition(@"
|
||||
update oms.orders set order_date=@OrderDate, customer_id=@CustomerId, warehouse_id=@WarehouseId,
|
||||
receiver_name=@ReceiverName, phone=@Phone, postal_code=@PostalCode, address1=@Address1, address2=@Address2,
|
||||
version=@Version, updated_at=now(), updated_by=@Actor where id=@Id;",
|
||||
new { Id = orderId, request.OrderDate, request.CustomerId, request.WarehouseId, request.ReceiverName, request.Phone, request.PostalCode, request.Address1, request.Address2, Version = nextVersion, Actor = actor }, tx, cancellationToken: ct));
|
||||
await connection.ExecuteAsync(new CommandDefinition("delete from oms.order_lines where order_id=@OrderId", new { OrderId = orderId }, tx, cancellationToken: ct));
|
||||
}
|
||||
|
||||
var lineNo = 0;
|
||||
foreach (var line in request.Lines)
|
||||
{
|
||||
lineNo++;
|
||||
await connection.ExecuteAsync(new CommandDefinition(@"
|
||||
insert into oms.order_lines(id, order_id, line_no, item_id, quantity, unit_price, amount, remark)
|
||||
values (@Id, @OrderId, @LineNo, @ItemId, @Quantity, @UnitPrice, @Amount, @Remark);",
|
||||
new { Id = Guid.NewGuid(), OrderId = orderId, LineNo = lineNo, line.ItemId, Quantity = line.EffectiveOrderQty, line.UnitPrice, Amount = line.EffectiveOrderQty * line.UnitPrice, line.Remark }, tx, cancellationToken: ct));
|
||||
}
|
||||
|
||||
await connection.ExecuteAsync(new CommandDefinition(@"
|
||||
insert into audit.entries(id, aggregate_type, aggregate_id, action, actor, occurred_at, data)
|
||||
values (@Id, 'Order', @OrderId, 'ORDER_SAVED', @Actor, now(), jsonb_build_object('version', @Version));",
|
||||
new { Id = Guid.NewGuid(), OrderId = orderId, Actor = actor, Version = nextVersion }, tx, cancellationToken: ct));
|
||||
|
||||
// Integration event is recorded atomically; dispatch is handled outside this transaction.
|
||||
await connection.ExecuteAsync(new CommandDefinition(@"
|
||||
insert into integration.outbox(id, event_type, aggregate_id, payload, occurred_at, status)
|
||||
values (@Id, 'OrderSaved', @OrderId, jsonb_build_object('orderId', @OrderId, 'version', @Version), now(), 'PENDING');",
|
||||
new { Id = Guid.NewGuid(), OrderId = orderId, Version = nextVersion }, tx, cancellationToken: ct));
|
||||
|
||||
await tx.CommitAsync(ct);
|
||||
return RegisterOrderResult.Ok(new(orderId, orderNo, nextVersion, "작성"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Kbx.Contracts;
|
||||
using Kbx.Contracts.Generated;
|
||||
|
||||
namespace Modules.OMS.Orders.Register;
|
||||
|
||||
public sealed record RegisterOrderRequest(
|
||||
Guid? OrderId,
|
||||
long? Version,
|
||||
[property: KbxFieldKey(KbxFieldKeys.OrderDate)] DateOnly OrderDate,
|
||||
[property: KbxFieldKey(KbxFieldKeys.CustomerId)] Guid CustomerId,
|
||||
[property: KbxFieldKey(KbxFieldKeys.WarehouseId)] Guid WarehouseId,
|
||||
[property: KbxFieldKey(KbxFieldKeys.ReceiverName)] string ReceiverName,
|
||||
[property: KbxFieldKey(KbxFieldKeys.Phone)] string Phone,
|
||||
[property: KbxFieldKey(KbxFieldKeys.PostalCode)] string? PostalCode,
|
||||
[property: KbxFieldKey(KbxFieldKeys.Address1)] string Address1,
|
||||
[property: KbxFieldKey(KbxFieldKeys.Address2)] string? Address2,
|
||||
IReadOnlyList<RegisterOrderLineRequest> Lines);
|
||||
|
||||
public sealed record RegisterOrderLineRequest(
|
||||
string ClientId,
|
||||
[property: KbxFieldKey(KbxFieldKeys.ItemId)] Guid ItemId,
|
||||
[property: KbxFieldKey(KbxFieldKeys.OrderQty)] decimal? OrderQty,
|
||||
// Legacy wire compatibility for v12 clients. Remove only in a declared breaking API migration.
|
||||
decimal? Quantity,
|
||||
[property: KbxFieldKey(KbxFieldKeys.UnitPrice)] decimal UnitPrice,
|
||||
[property: KbxFieldKey(KbxFieldKeys.Remark)] string? Remark)
|
||||
{
|
||||
public decimal EffectiveOrderQty => OrderQty ?? Quantity ?? 0m;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Modules.OMS.Orders.Register;
|
||||
|
||||
public sealed record RegisterOrderResponse(
|
||||
Guid OrderId,
|
||||
string OrderNo,
|
||||
long Version,
|
||||
string Status);
|
||||
@@ -0,0 +1,3 @@
|
||||
using FastEndpoints;using Shared.Problems;
|
||||
namespace Modules.OMS.Orders.Register;
|
||||
public sealed class UpdateEndpoint(RegisterOrderHandler handler):Endpoint<RegisterOrderRequest,RegisterOrderResponse>{public override void Configure(){Put("/api/oms/orders/{id}");Permissions("oms.order.write");}public override async Task HandleAsync(RegisterOrderRequest req,CancellationToken ct){var id=Route<Guid>("id");var result=await handler.HandleAsync(req with{OrderId=id},User,ct);if(result.Problem is KbxValidationProblem v){await Send.ResponseAsync(v,400,cancellation:ct);return;}if(result.Problem is KbxBusinessProblem b){await Send.ResponseAsync(b,409,cancellation:ct);return;}if(result.Problem is KbxConflictProblem c){await Send.ResponseAsync(c,409,cancellation:ct);return;}await Send.OkAsync(result.Response!,ct);}}
|
||||
@@ -0,0 +1,21 @@
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
|
||||
namespace Modules.OMS.Orders.Register;
|
||||
|
||||
public sealed class RegisterOrderValidator : Validator<RegisterOrderRequest>
|
||||
{
|
||||
public RegisterOrderValidator()
|
||||
{
|
||||
RuleFor(x => x.OrderDate).NotEmpty().WithErrorCode("ORDER_DATE_REQUIRED");
|
||||
RuleFor(x => x.CustomerId).NotEmpty().WithErrorCode("CUSTOMER_REQUIRED");
|
||||
RuleFor(x => x.WarehouseId).NotEmpty().WithErrorCode("WAREHOUSE_REQUIRED");
|
||||
RuleFor(x => x.ReceiverName).NotEmpty().MaximumLength(100).WithErrorCode("RECEIVER_REQUIRED");
|
||||
RuleFor(x => x.Phone).NotEmpty().MaximumLength(50).WithErrorCode("PHONE_REQUIRED");
|
||||
RuleFor(x => x.Address1).NotEmpty().MaximumLength(500).WithErrorCode("ADDRESS_REQUIRED");
|
||||
RuleFor(x => x.Lines).NotEmpty().WithErrorCode("ORDER_LINE_REQUIRED");
|
||||
|
||||
// Row-aware line validation is deliberately performed in the handler so the
|
||||
// KBX response can return the stable ClientId as rowKey instead of Lines[7].OrderQty.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using FastEndpoints;
|
||||
|
||||
namespace Modules.OMS.Orders.Search;
|
||||
|
||||
public sealed class Endpoint(SearchOrdersHandler handler)
|
||||
: Endpoint<SearchOrdersRequest, SearchOrdersResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/oms/orders");
|
||||
Permissions("oms.order.read");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(SearchOrdersRequest req, CancellationToken ct)
|
||||
{
|
||||
var response = await handler.HandleAsync(req, ct);
|
||||
await Send.OkAsync(response, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
|
||||
namespace Modules.OMS.Orders.Search;
|
||||
|
||||
public sealed class SearchOrdersHandler(NpgsqlDataSource dataSource)
|
||||
{
|
||||
public async Task<SearchOrdersResponse> HandleAsync(SearchOrdersRequest request, CancellationToken ct)
|
||||
{
|
||||
var page = Math.Max(request.Page, 1);
|
||||
var pageSize = Math.Clamp(request.PageSize, 1, 500);
|
||||
var offset = (page - 1) * pageSize;
|
||||
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
// Read model query: UI-required projection in one server-side query boundary.
|
||||
const string rowsSql = """
|
||||
select
|
||||
o.id,
|
||||
o.order_no as OrderNo,
|
||||
ch.name as ChannelName,
|
||||
o.ordered_at as OrderedAt,
|
||||
o.customer_name as CustomerName,
|
||||
o.item_summary as ItemSummary,
|
||||
o.total_qty as TotalQty,
|
||||
o.amount as Amount,
|
||||
o.allocation_status as AllocationStatus,
|
||||
o.shipment_status as ShipmentStatus,
|
||||
o.exception_count as ExceptionCount
|
||||
from oms_order_search_projection o
|
||||
join sales_channel ch on ch.id = o.channel_id
|
||||
where o.ordered_at >= @From
|
||||
and o.ordered_at < @ToExclusive
|
||||
and (@ChannelId is null or o.channel_id = @ChannelId)
|
||||
and (@Status is null or o.shipment_status = @Status)
|
||||
and (@Keyword is null or o.search_text ilike '%' || @Keyword || '%')
|
||||
and (@ExceptionOnly = false or o.exception_count > 0)
|
||||
order by o.ordered_at desc, o.id desc
|
||||
limit @PageSize offset @Offset;
|
||||
""";
|
||||
|
||||
const string summarySql = """
|
||||
select
|
||||
count(*)::int as TotalCount,
|
||||
coalesce(sum(o.total_qty), 0) as TotalQty,
|
||||
coalesce(sum(o.amount), 0) as TotalAmount,
|
||||
count(*) filter (where o.order_status = 'NEW')::int as NewCount,
|
||||
count(*) filter (where o.shipment_status = 'READY')::int as ReadyToShipCount,
|
||||
count(*) filter (where o.exception_count > 0)::int as ExceptionCount
|
||||
from oms_order_search_projection o
|
||||
where o.ordered_at >= @From
|
||||
and o.ordered_at < @ToExclusive
|
||||
and (@ChannelId is null or o.channel_id = @ChannelId)
|
||||
and (@Status is null or o.shipment_status = @Status)
|
||||
and (@Keyword is null or o.search_text ilike '%' || @Keyword || '%')
|
||||
and (@ExceptionOnly = false or o.exception_count > 0);
|
||||
""";
|
||||
|
||||
var args = new
|
||||
{
|
||||
From = request.From.ToDateTime(TimeOnly.MinValue),
|
||||
ToExclusive = request.To.AddDays(1).ToDateTime(TimeOnly.MinValue),
|
||||
request.ChannelId,
|
||||
Status = string.IsNullOrWhiteSpace(request.Status) ? null : request.Status,
|
||||
Keyword = string.IsNullOrWhiteSpace(request.Keyword) ? null : request.Keyword.Trim(),
|
||||
request.ExceptionOnly,
|
||||
PageSize = pageSize,
|
||||
Offset = offset,
|
||||
};
|
||||
|
||||
var rows = (await connection.QueryAsync<OrderSearchRow>(
|
||||
new CommandDefinition(rowsSql, args, cancellationToken: ct))).AsList();
|
||||
|
||||
var summary = await connection.QuerySingleAsync<SummaryRow>(
|
||||
new CommandDefinition(summarySql, args, cancellationToken: ct));
|
||||
|
||||
return new SearchOrdersResponse(
|
||||
rows,
|
||||
summary.TotalCount,
|
||||
summary.TotalQty,
|
||||
summary.TotalAmount,
|
||||
new OrderSearchCounters(
|
||||
summary.TotalCount,
|
||||
summary.NewCount,
|
||||
summary.ReadyToShipCount,
|
||||
summary.ExceptionCount));
|
||||
}
|
||||
|
||||
private sealed record SummaryRow(
|
||||
int TotalCount,
|
||||
decimal TotalQty,
|
||||
decimal TotalAmount,
|
||||
int NewCount,
|
||||
int ReadyToShipCount,
|
||||
int ExceptionCount);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Modules.OMS.Orders.Search;
|
||||
|
||||
public sealed record SearchOrdersRequest(
|
||||
DateOnly From,
|
||||
DateOnly To,
|
||||
Guid? ChannelId,
|
||||
string? Status,
|
||||
string? Keyword,
|
||||
bool ExceptionOnly = false,
|
||||
int Page = 1,
|
||||
int PageSize = 100);
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace Modules.OMS.Orders.Search;
|
||||
|
||||
public sealed record OrderSearchRow(
|
||||
Guid Id,
|
||||
string OrderNo,
|
||||
string ChannelName,
|
||||
DateTimeOffset OrderedAt,
|
||||
string CustomerName,
|
||||
string ItemSummary,
|
||||
decimal TotalQty,
|
||||
decimal Amount,
|
||||
string AllocationStatus,
|
||||
string ShipmentStatus,
|
||||
int ExceptionCount);
|
||||
|
||||
public sealed record OrderSearchCounters(
|
||||
int All,
|
||||
int New,
|
||||
int ReadyToShip,
|
||||
int Exceptions);
|
||||
|
||||
public sealed record SearchOrdersResponse(
|
||||
IReadOnlyList<OrderSearchRow> Items,
|
||||
int TotalCount,
|
||||
decimal TotalQty,
|
||||
decimal TotalAmount,
|
||||
OrderSearchCounters Counters);
|
||||
@@ -0,0 +1,138 @@
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using FastEndpoints;
|
||||
using Npgsql;
|
||||
using Shared.Problems;
|
||||
|
||||
namespace Modules.OMS.Orders.Ship;
|
||||
|
||||
public sealed record ShipOrdersFilter(
|
||||
DateOnly From,
|
||||
DateOnly To,
|
||||
Guid? ChannelId,
|
||||
string? Status,
|
||||
string? Keyword,
|
||||
bool ExceptionOnly = false);
|
||||
|
||||
public sealed record ShipOrdersRequest(
|
||||
string Mode,
|
||||
IReadOnlyList<Guid>? Ids,
|
||||
ShipOrdersFilter? Filter,
|
||||
IReadOnlyList<Guid>? ExcludedIds);
|
||||
|
||||
public sealed record ShipOrdersResponse(int Requested, int Accepted, int Rejected);
|
||||
|
||||
public sealed class Endpoint(NpgsqlDataSource dataSource)
|
||||
: Endpoint<ShipOrdersRequest, ShipOrdersResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/oms/orders/ship");
|
||||
Permissions("oms.order.ship");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(ShipOrdersRequest req, CancellationToken ct)
|
||||
{
|
||||
var key = HttpContext.Request.Headers["Idempotency-Key"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
await Send.ResponseAsync(KbxValidationProblem.Create(
|
||||
new KbxValidationError(null, null, "IDEMPOTENCY_KEY_REQUIRED", "안전한 재처리를 위해 Idempotency-Key가 필요합니다.")), 400, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var mode = req.Mode?.Trim().ToLowerInvariant();
|
||||
if (mode is not ("ids" or "filter") || (mode == "ids" && (req.Ids is null || req.Ids.Count == 0)) || (mode == "filter" && req.Filter is null))
|
||||
{
|
||||
await Send.ResponseAsync(KbxValidationProblem.Create(
|
||||
new KbxValidationError("selection", null, "ORDER_SELECTION_REQUIRED", "출고지시할 주문을 선택하세요.")), 400, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var tx = await connection.BeginTransactionAsync(ct);
|
||||
|
||||
var replay = await connection.QuerySingleOrDefaultAsync<string?>(new CommandDefinition(
|
||||
"select response_json::text from kbx.command_receipts where operation_id=@OperationId and idempotency_key=@Key",
|
||||
new { OperationId = "oms.orders.ship", Key = key }, tx, cancellationToken: ct));
|
||||
if (replay is not null)
|
||||
{
|
||||
await tx.RollbackAsync(ct);
|
||||
await Send.OkAsync(JsonSerializer.Deserialize<ShipOrdersResponse>(replay)!, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
const string idsTarget = "select distinct unnest(@Ids::uuid[]) as id";
|
||||
const string filterTarget = """
|
||||
select o.id
|
||||
from oms_order_search_projection o
|
||||
where o.ordered_at >= @From
|
||||
and o.ordered_at < @ToExclusive
|
||||
and (@ChannelId is null or o.channel_id = @ChannelId)
|
||||
and (@Status is null or o.shipment_status = @Status)
|
||||
and (@Keyword is null or o.search_text ilike '%' || @Keyword || '%')
|
||||
and (@ExceptionOnly = false or o.exception_count > 0)
|
||||
and not (o.id = any(@ExcludedIds))
|
||||
""";
|
||||
var targetSql = mode == "ids" ? idsTarget : filterTarget;
|
||||
|
||||
var filter = req.Filter;
|
||||
var args = new
|
||||
{
|
||||
Ids = (req.Ids ?? Array.Empty<Guid>()).ToArray(),
|
||||
From = filter is null ? (DateTime?)null : filter.From.ToDateTime(TimeOnly.MinValue),
|
||||
ToExclusive = filter is null ? (DateTime?)null : filter.To.AddDays(1).ToDateTime(TimeOnly.MinValue),
|
||||
ChannelId = filter?.ChannelId,
|
||||
Status = string.IsNullOrWhiteSpace(filter?.Status) ? null : filter!.Status,
|
||||
Keyword = string.IsNullOrWhiteSpace(filter?.Keyword) ? null : filter!.Keyword!.Trim(),
|
||||
ExceptionOnly = filter?.ExceptionOnly ?? false,
|
||||
ExcludedIds = (req.ExcludedIds ?? Array.Empty<Guid>()).ToArray(),
|
||||
};
|
||||
|
||||
var result = await connection.QuerySingleAsync<ShipMutationResult>(new CommandDefinition($"""
|
||||
with target as materialized (
|
||||
{targetSql}
|
||||
),
|
||||
accepted as materialized (
|
||||
update oms.orders
|
||||
set status='CONFIRMED', version=version+1, updated_at=now(), updated_by='web'
|
||||
where id in (select id from target)
|
||||
and status in ('NEW','DRAFT','READY')
|
||||
returning id
|
||||
),
|
||||
audit_insert as (
|
||||
insert into audit.entries(id,aggregate_type,aggregate_id,action,actor,occurred_at,data)
|
||||
select gen_random_uuid(),'Order',id,'ShipRequested','web',now(),'{}'::jsonb
|
||||
from accepted
|
||||
returning 1
|
||||
),
|
||||
outbox_insert as (
|
||||
insert into integration.outbox(id,event_type,aggregate_id,payload,occurred_at,status)
|
||||
select gen_random_uuid(),'OmsOrderShipRequested',id,jsonb_build_object('orderId',id),now(),'PENDING'
|
||||
from accepted
|
||||
returning 1
|
||||
)
|
||||
select (select count(*)::int from target) as Requested,
|
||||
(select count(*)::int from accepted) as Accepted;
|
||||
""", args, tx, cancellationToken: ct));
|
||||
|
||||
if (result.Requested == 0)
|
||||
{
|
||||
await tx.RollbackAsync(ct);
|
||||
await Send.ResponseAsync(KbxValidationProblem.Create(
|
||||
new KbxValidationError("selection", null, "ORDER_SELECTION_EMPTY", "현재 검색조건에서 처리할 주문이 없습니다.")), 400, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var response = new ShipOrdersResponse(result.Requested, result.Accepted, result.Requested - result.Accepted);
|
||||
await connection.ExecuteAsync(new CommandDefinition("""
|
||||
insert into kbx.command_receipts(operation_id,idempotency_key,response_json)
|
||||
values(@OperationId,@Key,cast(@Response as jsonb));
|
||||
""", new { OperationId = "oms.orders.ship", Key = key, Response = JsonSerializer.Serialize(response) }, tx, cancellationToken: ct));
|
||||
|
||||
await tx.CommitAsync(ct);
|
||||
await Send.OkAsync(response, ct);
|
||||
}
|
||||
|
||||
private sealed record ShipMutationResult(int Requested, int Accepted);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using Dapper;
|
||||
using FastEndpoints;
|
||||
using Modules.WMS.Picking.Shared;
|
||||
using Npgsql;
|
||||
using Shared.Problems;
|
||||
|
||||
namespace Modules.WMS.Picking.Exceptions;
|
||||
|
||||
public sealed record Request(
|
||||
Guid? LineId,
|
||||
string Type,
|
||||
string? Memo,
|
||||
string IdempotencyKey,
|
||||
long ExpectedVersion);
|
||||
|
||||
public sealed class Endpoint(NpgsqlDataSource dataSource)
|
||||
: Endpoint<Request, PickingTaskDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/wms/picking/tasks/{taskId:guid}/exceptions");
|
||||
Permissions("wms.picking.execute");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(Request req, CancellationToken ct)
|
||||
{
|
||||
var taskId = Route<Guid>("taskId");
|
||||
var allowed = new[] { "no-stock", "short-quantity", "wrong-location", "damaged-item", "barcode-issue", "other" };
|
||||
if (!allowed.Contains(req.Type))
|
||||
{
|
||||
await Send.ResponseAsync(KbxValidationProblem.Create(
|
||||
new KbxValidationError("type", null, "EXCEPTION_TYPE_INVALID", "문제 유형을 확인하세요.")), 400, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var tx = await connection.BeginTransactionAsync(ct);
|
||||
|
||||
var currentVersion = await connection.QuerySingleOrDefaultAsync<long?>(new CommandDefinition(
|
||||
"select version from wms.picking_tasks where id=@TaskId for update",
|
||||
new { TaskId = taskId }, tx, cancellationToken: ct));
|
||||
|
||||
if (currentVersion is null)
|
||||
{
|
||||
await tx.RollbackAsync(ct);
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var duplicate = await connection.ExecuteScalarAsync<bool>(new CommandDefinition(
|
||||
"select exists(select 1 from wms.picking_exceptions where task_id=@TaskId and idempotency_key=@Key)",
|
||||
new { TaskId = taskId, Key = req.IdempotencyKey }, tx, cancellationToken: ct));
|
||||
if (duplicate)
|
||||
{
|
||||
await tx.RollbackAsync(ct);
|
||||
var existing = await PickingTaskQueries.GetAsync(connection, null, taskId, ct);
|
||||
await Send.OkAsync(existing!, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentVersion != req.ExpectedVersion)
|
||||
{
|
||||
await tx.RollbackAsync(ct);
|
||||
await Send.ResponseAsync(KbxConflictProblem.Version(currentVersion), 409, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
await connection.ExecuteAsync(new CommandDefinition("""
|
||||
insert into wms.picking_exceptions(
|
||||
id, task_id, line_id, exception_type, memo, status, reported_at, reported_by, idempotency_key)
|
||||
values (@Id, @TaskId, @LineId, @Type, @Memo, 'OPEN', now(), @Actor, @Key)
|
||||
on conflict (task_id, idempotency_key) do nothing;
|
||||
|
||||
update wms.picking_tasks
|
||||
set status='BLOCKED', version=version+1, updated_at=now()
|
||||
where id=@TaskId;
|
||||
|
||||
insert into audit.entries(id, aggregate_type, aggregate_id, action, actor, occurred_at, data)
|
||||
values (@AuditId, 'WmsPickingTask', @TaskId, 'PickingExceptionReported', @Actor, now(),
|
||||
jsonb_build_object('type', @Type, 'lineId', @LineId, 'memo', @Memo));
|
||||
|
||||
insert into integration.outbox(id,event_type,aggregate_id,payload,occurred_at,status)
|
||||
values (@EventId,'WmsPickingExceptionReported',@TaskId,
|
||||
jsonb_build_object('taskId',@TaskId,'lineId',@LineId,'type',@Type),now(),'PENDING');
|
||||
""", new {
|
||||
Id = Guid.NewGuid(),
|
||||
AuditId = Guid.NewGuid(),
|
||||
EventId = Guid.NewGuid(),
|
||||
TaskId = taskId,
|
||||
req.LineId,
|
||||
req.Type,
|
||||
req.Memo,
|
||||
Key = req.IdempotencyKey,
|
||||
Actor = User.Identity?.Name ?? "unknown",
|
||||
}, tx, cancellationToken: ct));
|
||||
|
||||
await tx.CommitAsync(ct);
|
||||
var task = await PickingTaskQueries.GetAsync(connection, null, taskId, ct);
|
||||
await SendOkAsync(task! with { Message = "문제를 등록했습니다. 관리자 확인이 필요합니다." }, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Dapper;
|
||||
using FastEndpoints;
|
||||
using Modules.WMS.Picking.Shared;
|
||||
using Npgsql;
|
||||
|
||||
namespace Modules.WMS.Picking.GetTask;
|
||||
|
||||
public sealed class Endpoint(NpgsqlDataSource dataSource)
|
||||
: EndpointWithoutRequest<PickingTaskDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/wms/picking/tasks/{taskId:guid}");
|
||||
Permissions("wms.picking.execute");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var taskId = Route<Guid>("taskId");
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var task = await PickingTaskQueries.GetAsync(connection, null, taskId, ct);
|
||||
if (task is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
await SendOkAsync(task, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using FastEndpoints;
|
||||
using Npgsql;
|
||||
|
||||
namespace Modules.WMS.Picking.Scan;
|
||||
|
||||
public sealed class Endpoint(NpgsqlDataSource dataSource)
|
||||
: Endpoint<Request, Response>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/wms/picking/tasks/{taskId:guid}/scan");
|
||||
Permissions("wms.picking.execute");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(Request req, CancellationToken ct)
|
||||
{
|
||||
var taskId = Route<Guid>("taskId");
|
||||
var handler = new Handler(dataSource);
|
||||
var result = await handler.HandleAsync(taskId, req, User.Identity?.Name ?? "unknown", ct);
|
||||
await Send.ResponseAsync(result.Body, result.StatusCode, cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using Modules.WMS.Picking.Shared;
|
||||
using Npgsql;
|
||||
using Shared.Problems;
|
||||
|
||||
namespace Modules.WMS.Picking.Scan;
|
||||
|
||||
public sealed class Handler(NpgsqlDataSource dataSource)
|
||||
{
|
||||
public sealed record HandlerResult(int StatusCode, object Body);
|
||||
|
||||
public async Task<HandlerResult> HandleAsync(
|
||||
Guid taskId,
|
||||
Request request,
|
||||
string actor,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.IdempotencyKey))
|
||||
return new(400, KbxValidationProblem.Create(
|
||||
new KbxValidationError("idempotencyKey", null, "IDEMPOTENCY_REQUIRED", "Idempotency key가 필요합니다.")));
|
||||
|
||||
var barcode = request.Barcode.Trim();
|
||||
if (barcode.Length < 3)
|
||||
return new(400, KbxValidationProblem.Create(
|
||||
new KbxValidationError("barcode", null, "BARCODE_INVALID", "바코드를 다시 스캔하세요.")));
|
||||
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var tx = await connection.BeginTransactionAsync(ct);
|
||||
|
||||
// One picking task is a sequence. Serialize scanner commands for this task.
|
||||
var taskRow = await connection.QuerySingleOrDefaultAsync<TaskRow>(new CommandDefinition("""
|
||||
select id, task_no as TaskNo, status, version
|
||||
from wms.picking_tasks
|
||||
where id=@TaskId
|
||||
for update;
|
||||
""", new { TaskId = taskId }, tx, cancellationToken: ct));
|
||||
|
||||
if (taskRow is null)
|
||||
{
|
||||
await tx.RollbackAsync(ct);
|
||||
return new(404, new { type = "not-found", title = "피킹 작업을 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
// Idempotency is checked before version. A response-lost retry must replay the committed result.
|
||||
var existingJson = await connection.QuerySingleOrDefaultAsync<string>(new CommandDefinition("""
|
||||
select response_payload::text
|
||||
from wms.scan_receipts
|
||||
where task_id=@TaskId and idempotency_key=@Key;
|
||||
""", new { TaskId = taskId, Key = request.IdempotencyKey }, tx, cancellationToken: ct));
|
||||
if (existingJson is not null)
|
||||
{
|
||||
var existing = JsonSerializer.Deserialize<Response>(existingJson, JsonOptions)!;
|
||||
await tx.RollbackAsync(ct);
|
||||
return new(200, existing with { Duplicate = true });
|
||||
}
|
||||
|
||||
if (taskRow.Version != request.ExpectedVersion)
|
||||
{
|
||||
await tx.RollbackAsync(ct);
|
||||
return new(409, KbxConflictProblem.Version(taskRow.Version));
|
||||
}
|
||||
|
||||
if (taskRow.Status != "IN_PROGRESS")
|
||||
{
|
||||
await tx.RollbackAsync(ct);
|
||||
return new(409, KbxBusinessProblem.Create(
|
||||
"PICKING_NOT_ACTIVE",
|
||||
"현재 피킹할 수 없는 작업입니다.",
|
||||
$"현재 상태: {taskRow.Status}"));
|
||||
}
|
||||
|
||||
var line = await connection.QuerySingleOrDefaultAsync<LineRow>(new CommandDefinition("""
|
||||
select l.id as LineId,
|
||||
l.line_no as LineNo,
|
||||
l.location_code as LocationCode,
|
||||
l.location_barcode as LocationBarcode,
|
||||
l.location_confirmed as LocationConfirmed,
|
||||
l.item_id as ItemId,
|
||||
l.barcode as Barcode,
|
||||
l.required_qty as RequiredQty,
|
||||
l.picked_qty as PickedQty
|
||||
from wms.picking_lines l
|
||||
where l.task_id=@TaskId and l.status <> 'COMPLETED'
|
||||
order by l.line_no
|
||||
limit 1
|
||||
for update;
|
||||
""", new { TaskId = taskId }, tx, cancellationToken: ct));
|
||||
|
||||
if (line is null)
|
||||
{
|
||||
await CompleteTaskAsync(connection, tx, taskId, ct);
|
||||
var completed = await BuildAndPersistResponse(
|
||||
connection, tx, taskId, request, actor, true, "success", "피킹을 완료했습니다.", "TaskRecoveredAsCompleted", ct);
|
||||
await tx.CommitAsync(ct);
|
||||
return new(200, completed);
|
||||
}
|
||||
|
||||
var decision = PickingScanStateMachine.Decide(new PickingScanState(
|
||||
line.LocationConfirmed,
|
||||
line.LocationCode,
|
||||
line.LocationBarcode,
|
||||
line.Barcode,
|
||||
line.RequiredQty,
|
||||
line.PickedQty), barcode);
|
||||
|
||||
switch (decision.Kind)
|
||||
{
|
||||
case PickingScanDecisionKind.RejectLocation:
|
||||
case PickingScanDecisionKind.RejectItem:
|
||||
{
|
||||
var rejected = await BuildAndPersistResponse(
|
||||
connection, tx, taskId, request, actor,
|
||||
decision.Accepted, decision.Feedback, decision.Message, decision.AuditAction, ct);
|
||||
await tx.CommitAsync(ct);
|
||||
return new(422, rejected);
|
||||
}
|
||||
|
||||
case PickingScanDecisionKind.AcceptLocation:
|
||||
{
|
||||
await connection.ExecuteAsync(new CommandDefinition("""
|
||||
update wms.picking_lines
|
||||
set location_confirmed=true, updated_at=now()
|
||||
where id=@LineId;
|
||||
update wms.picking_tasks
|
||||
set version=version+1, updated_at=now()
|
||||
where id=@TaskId;
|
||||
""", new { line.LineId, TaskId = taskId }, tx, cancellationToken: ct));
|
||||
|
||||
var accepted = await BuildAndPersistResponse(
|
||||
connection, tx, taskId, request, actor,
|
||||
true, decision.Feedback, decision.Message, decision.AuditAction, ct);
|
||||
await tx.CommitAsync(ct);
|
||||
return new(200, accepted);
|
||||
}
|
||||
|
||||
case PickingScanDecisionKind.AcceptItem:
|
||||
{
|
||||
await ApplyItemScanAsync(connection, tx, taskId, line, decision, ct);
|
||||
|
||||
var nextExists = await HasIncompleteLineAsync(connection, tx, taskId, ct);
|
||||
if (!nextExists)
|
||||
await CompleteTaskAsync(connection, tx, taskId, ct);
|
||||
|
||||
var message = nextExists ? decision.Message : "피킹을 완료했습니다.";
|
||||
var accepted = await BuildAndPersistResponse(
|
||||
connection, tx, taskId, request, actor,
|
||||
true, decision.Feedback, message, decision.AuditAction, ct);
|
||||
await tx.CommitAsync(ct);
|
||||
return new(200, accepted);
|
||||
}
|
||||
|
||||
default:
|
||||
throw new InvalidOperationException($"Unsupported picking scan decision: {decision.Kind}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ApplyItemScanAsync(
|
||||
NpgsqlConnection connection,
|
||||
NpgsqlTransaction tx,
|
||||
Guid taskId,
|
||||
LineRow line,
|
||||
PickingScanDecision decision,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await connection.ExecuteAsync(new CommandDefinition("""
|
||||
update wms.picking_lines
|
||||
set picked_qty=@PickedQty,
|
||||
status=case when @Completed then 'COMPLETED' else 'IN_PROGRESS' end,
|
||||
completed_at=case when @Completed then now() else completed_at end,
|
||||
updated_at=now()
|
||||
where id=@LineId;
|
||||
|
||||
update wms.picking_tasks set version=version+1, updated_at=now() where id=@TaskId;
|
||||
""", new {
|
||||
PickedQty = decision.NewPickedQty!.Value,
|
||||
Completed = decision.LineCompleted,
|
||||
line.LineId,
|
||||
TaskId = taskId,
|
||||
}, tx, cancellationToken: ct));
|
||||
|
||||
if (decision.LineCompleted)
|
||||
await PickingTransitions.AutoConfirmNextSameLocationAsync(connection, tx, taskId, line.LocationCode, ct);
|
||||
}
|
||||
|
||||
private static Task<bool> HasIncompleteLineAsync(
|
||||
NpgsqlConnection connection,
|
||||
NpgsqlTransaction tx,
|
||||
Guid taskId,
|
||||
CancellationToken ct) => connection.ExecuteScalarAsync<bool>(new CommandDefinition("""
|
||||
select exists(select 1 from wms.picking_lines where task_id=@TaskId and status <> 'COMPLETED');
|
||||
""", new { TaskId = taskId }, tx, cancellationToken: ct));
|
||||
|
||||
private static async Task CompleteTaskAsync(
|
||||
NpgsqlConnection connection,
|
||||
NpgsqlTransaction tx,
|
||||
Guid taskId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await connection.ExecuteAsync(new CommandDefinition("""
|
||||
update wms.picking_tasks
|
||||
set status='COMPLETED', version=version+1, completed_at=coalesce(completed_at,now()), updated_at=now()
|
||||
where id=@TaskId and status <> 'COMPLETED';
|
||||
|
||||
insert into integration.outbox(id,event_type,aggregate_id,payload,occurred_at,status)
|
||||
select @EventId,'WmsPickingCompleted',@TaskId,jsonb_build_object('taskId',@TaskId),now(),'PENDING'
|
||||
where not exists (
|
||||
select 1 from integration.outbox
|
||||
where aggregate_id=@TaskId and event_type='WmsPickingCompleted'
|
||||
);
|
||||
""", new { TaskId = taskId, EventId = Guid.NewGuid() }, tx, cancellationToken: ct));
|
||||
}
|
||||
|
||||
private static async Task<Response> BuildAndPersistResponse(
|
||||
NpgsqlConnection connection,
|
||||
NpgsqlTransaction tx,
|
||||
Guid taskId,
|
||||
Request request,
|
||||
string actor,
|
||||
bool accepted,
|
||||
string feedback,
|
||||
string message,
|
||||
string auditAction,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var current = await PickingTaskQueries.GetAsync(connection, tx, taskId, ct)
|
||||
?? throw new InvalidOperationException("Picking task disappeared during transaction.");
|
||||
var response = new Response(accepted, false, current, feedback, message);
|
||||
var json = JsonSerializer.Serialize(response, JsonOptions);
|
||||
|
||||
await connection.ExecuteAsync(new CommandDefinition("""
|
||||
insert into wms.scan_receipts(
|
||||
id, task_id, idempotency_key, barcode, source, occurred_at, actor, response_payload)
|
||||
values (@Id,@TaskId,@Key,@Barcode,@Source,@OccurredAt,@Actor,cast(@Response as jsonb));
|
||||
|
||||
insert into audit.entries(id,aggregate_type,aggregate_id,action,actor,occurred_at,data)
|
||||
values (@AuditId,'WmsPickingTask',@TaskId,@Action,@Actor,now(),
|
||||
jsonb_build_object('barcode',@Barcode,'source',@Source,'idempotencyKey',@Key));
|
||||
""", new {
|
||||
Id = Guid.NewGuid(),
|
||||
AuditId = Guid.NewGuid(),
|
||||
TaskId = taskId,
|
||||
Key = request.IdempotencyKey,
|
||||
request.Barcode,
|
||||
request.Source,
|
||||
request.OccurredAt,
|
||||
Actor = actor,
|
||||
Response = json,
|
||||
Action = auditAction,
|
||||
}, tx, cancellationToken: ct));
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private sealed record TaskRow(Guid Id, string TaskNo, string Status, long Version);
|
||||
private sealed record LineRow(
|
||||
Guid LineId,
|
||||
int LineNo,
|
||||
string LocationCode,
|
||||
string LocationBarcode,
|
||||
bool LocationConfirmed,
|
||||
Guid ItemId,
|
||||
string Barcode,
|
||||
decimal RequiredQty,
|
||||
decimal PickedQty);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Modules.WMS.Picking.Scan;
|
||||
|
||||
public sealed record Request(
|
||||
string Barcode,
|
||||
string Source,
|
||||
string IdempotencyKey,
|
||||
long ExpectedVersion,
|
||||
DateTimeOffset OccurredAt);
|
||||
|
||||
public sealed record Response(
|
||||
bool Accepted,
|
||||
bool Duplicate,
|
||||
Modules.WMS.Picking.Shared.PickingTaskDto Task,
|
||||
string Feedback,
|
||||
string Message);
|
||||
@@ -0,0 +1,144 @@
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using FastEndpoints;
|
||||
using Modules.WMS.Picking.Scan;
|
||||
using Modules.WMS.Picking.Shared;
|
||||
using Npgsql;
|
||||
using Shared.Problems;
|
||||
|
||||
namespace Modules.WMS.Picking.SetQuantity;
|
||||
|
||||
public sealed record Request(
|
||||
Guid LineId,
|
||||
decimal PickedQty,
|
||||
string IdempotencyKey,
|
||||
long ExpectedVersion);
|
||||
|
||||
public sealed class Endpoint(NpgsqlDataSource dataSource)
|
||||
: Endpoint<Request, Modules.WMS.Picking.Scan.Response>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/wms/picking/tasks/{taskId:guid}/quantity");
|
||||
Permissions("wms.picking.execute");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(Request req, CancellationToken ct)
|
||||
{
|
||||
var taskId = Route<Guid>("taskId");
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var tx = await connection.BeginTransactionAsync(ct);
|
||||
|
||||
var task = await connection.QuerySingleOrDefaultAsync<TaskRow>(new CommandDefinition(
|
||||
"select id, status, version from wms.picking_tasks where id=@TaskId for update",
|
||||
new { TaskId = taskId }, tx, cancellationToken: ct));
|
||||
if (task is null)
|
||||
{
|
||||
await tx.RollbackAsync(ct);
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var prior = await connection.QuerySingleOrDefaultAsync<string>(new CommandDefinition("""
|
||||
select response_payload::text from wms.quantity_receipts
|
||||
where task_id=@TaskId and idempotency_key=@Key;
|
||||
""", new { TaskId = taskId, Key = req.IdempotencyKey }, tx, cancellationToken: ct));
|
||||
if (prior is not null)
|
||||
{
|
||||
var replay = JsonSerializer.Deserialize<Modules.WMS.Picking.Scan.Response>(prior, JsonOptions)!;
|
||||
await tx.RollbackAsync(ct);
|
||||
await Send.OkAsync(replay with { Duplicate = true }, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (task.Version != req.ExpectedVersion)
|
||||
{
|
||||
await tx.RollbackAsync(ct);
|
||||
await Send.ResponseAsync(KbxConflictProblem.Version(task.Version), 409, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var line = await connection.QuerySingleOrDefaultAsync<LineRow>(new CommandDefinition("""
|
||||
select id, line_no as LineNo, location_code as LocationCode, location_confirmed as LocationConfirmed,
|
||||
required_qty as RequiredQty, picked_qty as PickedQty, status
|
||||
from wms.picking_lines
|
||||
where id=@LineId and task_id=@TaskId
|
||||
for update;
|
||||
""", new { req.LineId, TaskId = taskId }, tx, cancellationToken: ct));
|
||||
|
||||
if (line is null || line.Status == "COMPLETED")
|
||||
{
|
||||
await tx.RollbackAsync(ct);
|
||||
await Send.ResponseAsync(KbxBusinessProblem.Create("PICK_LINE_NOT_EDITABLE", "현재 피킹 수량을 변경할 수 없습니다."), 409, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!line.LocationConfirmed || line.PickedQty <= 0)
|
||||
{
|
||||
await tx.RollbackAsync(ct);
|
||||
await Send.ResponseAsync(KbxBusinessProblem.Create(
|
||||
"ITEM_NOT_VERIFIED",
|
||||
"먼저 상품 바코드를 스캔하세요.",
|
||||
"수량 직접입력은 상품을 최소 1회 확인한 뒤 사용할 수 있습니다."), 409, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.PickedQty < line.PickedQty || req.PickedQty > line.RequiredQty)
|
||||
{
|
||||
await tx.RollbackAsync(ct);
|
||||
await Send.ResponseAsync(KbxValidationProblem.Create(
|
||||
new KbxValidationError("pickedQty", null, "PICK_QTY_RANGE", $"수량은 현재 피킹수량 {line.PickedQty}부터 필요수량 {line.RequiredQty}까지 입력할 수 있습니다.")), 400, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var completed = req.PickedQty >= line.RequiredQty;
|
||||
await connection.ExecuteAsync(new CommandDefinition("""
|
||||
update wms.picking_lines
|
||||
set picked_qty=@PickedQty,
|
||||
status=case when @Completed then 'COMPLETED' else 'IN_PROGRESS' end,
|
||||
completed_at=case when @Completed then now() else null end,
|
||||
updated_at=now()
|
||||
where id=@LineId;
|
||||
|
||||
update wms.picking_tasks set version=version+1, updated_at=now() where id=@TaskId;
|
||||
""", new { req.PickedQty, Completed = completed, req.LineId, TaskId = taskId }, tx, cancellationToken: ct));
|
||||
|
||||
if (completed)
|
||||
await PickingTransitions.AutoConfirmNextSameLocationAsync(connection, tx, taskId, line.LocationCode, ct);
|
||||
|
||||
var hasNext = await connection.ExecuteScalarAsync<bool>(new CommandDefinition(
|
||||
"select exists(select 1 from wms.picking_lines where task_id=@TaskId and status <> 'COMPLETED')",
|
||||
new { TaskId = taskId }, tx, cancellationToken: ct));
|
||||
if (!hasNext)
|
||||
{
|
||||
await connection.ExecuteAsync(new CommandDefinition("""
|
||||
update wms.picking_tasks set status='COMPLETED', version=version+1, completed_at=now(), updated_at=now() where id=@TaskId;
|
||||
insert into integration.outbox(id,event_type,aggregate_id,payload,occurred_at,status)
|
||||
values (@EventId,'WmsPickingCompleted',@TaskId,jsonb_build_object('taskId',@TaskId),now(),'PENDING');
|
||||
""", new { TaskId = taskId, EventId = Guid.NewGuid() }, tx, cancellationToken: ct));
|
||||
}
|
||||
|
||||
var current = await PickingTaskQueries.GetAsync(connection, tx, taskId, ct) ?? throw new InvalidOperationException();
|
||||
var response = new Modules.WMS.Picking.Scan.Response(true, false, current, "success",
|
||||
hasNext ? "피킹 수량을 반영했습니다." : "피킹을 완료했습니다.");
|
||||
var json = JsonSerializer.Serialize(response, JsonOptions);
|
||||
|
||||
await connection.ExecuteAsync(new CommandDefinition("""
|
||||
insert into wms.quantity_receipts(id,task_id,idempotency_key,line_id,picked_qty,actor,response_payload)
|
||||
values (@Id,@TaskId,@Key,@LineId,@PickedQty,@Actor,cast(@Response as jsonb));
|
||||
insert into audit.entries(id,aggregate_type,aggregate_id,action,actor,occurred_at,data)
|
||||
values (@AuditId,'WmsPickingTask',@TaskId,'PickingQuantitySet',@Actor,now(),
|
||||
jsonb_build_object('lineId',@LineId,'pickedQty',@PickedQty,'idempotencyKey',@Key));
|
||||
""", new {
|
||||
Id = Guid.NewGuid(), AuditId = Guid.NewGuid(), TaskId = taskId, Key = req.IdempotencyKey,
|
||||
req.LineId, req.PickedQty, Actor = User.Identity?.Name ?? "unknown", Response = json,
|
||||
}, tx, cancellationToken: ct));
|
||||
|
||||
await tx.CommitAsync(ct);
|
||||
await Send.OkAsync(response, ct);
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private sealed record TaskRow(Guid Id, string Status, long Version);
|
||||
private sealed record LineRow(Guid Id, int LineNo, string LocationCode, bool LocationConfirmed, decimal RequiredQty, decimal PickedQty, string Status);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace Modules.WMS.Picking.Shared;
|
||||
|
||||
public sealed record PickingLineDto(
|
||||
Guid LineId,
|
||||
int LineNo,
|
||||
string LocationCode,
|
||||
Guid ItemId,
|
||||
string ItemCode,
|
||||
string ItemName,
|
||||
string? ItemOption,
|
||||
string Barcode,
|
||||
decimal RequiredQty,
|
||||
decimal PickedQty,
|
||||
decimal RemainingQty);
|
||||
|
||||
public sealed record PickingTaskDto(
|
||||
Guid TaskId,
|
||||
string TaskNo,
|
||||
string Stage,
|
||||
string Status,
|
||||
int CompletedLines,
|
||||
int TotalLines,
|
||||
decimal CompletedQty,
|
||||
decimal TotalQty,
|
||||
long Version,
|
||||
PickingLineDto? CurrentLine,
|
||||
string? Message = null);
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
namespace Modules.WMS.Picking.Shared;
|
||||
|
||||
public enum PickingScanDecisionKind
|
||||
{
|
||||
RejectLocation,
|
||||
AcceptLocation,
|
||||
RejectItem,
|
||||
AcceptItem
|
||||
}
|
||||
|
||||
public sealed record PickingScanState(
|
||||
bool LocationConfirmed,
|
||||
string LocationCode,
|
||||
string LocationBarcode,
|
||||
string ItemBarcode,
|
||||
decimal RequiredQty,
|
||||
decimal PickedQty);
|
||||
|
||||
public sealed record PickingScanDecision(
|
||||
PickingScanDecisionKind Kind,
|
||||
bool Accepted,
|
||||
string Feedback,
|
||||
string Message,
|
||||
decimal? NewPickedQty = null,
|
||||
bool LineCompleted = false)
|
||||
{
|
||||
public string AuditAction => Kind switch
|
||||
{
|
||||
PickingScanDecisionKind.RejectLocation => "LocationRejected",
|
||||
PickingScanDecisionKind.AcceptLocation => "LocationAccepted",
|
||||
PickingScanDecisionKind.RejectItem => "ItemRejected",
|
||||
PickingScanDecisionKind.AcceptItem => "ItemAccepted",
|
||||
_ => "BarcodeScanned"
|
||||
};
|
||||
}
|
||||
|
||||
public static class PickingScanStateMachine
|
||||
{
|
||||
public static PickingScanDecision Decide(PickingScanState state, string scannedBarcode)
|
||||
{
|
||||
var barcode = scannedBarcode.Trim();
|
||||
|
||||
if (!state.LocationConfirmed)
|
||||
{
|
||||
if (!string.Equals(barcode, state.LocationBarcode, StringComparison.OrdinalIgnoreCase))
|
||||
return new(
|
||||
PickingScanDecisionKind.RejectLocation,
|
||||
false,
|
||||
"error",
|
||||
$"잘못된 위치입니다. {state.LocationCode} 위치로 이동하세요.");
|
||||
|
||||
return new(
|
||||
PickingScanDecisionKind.AcceptLocation,
|
||||
true,
|
||||
"success",
|
||||
"위치를 확인했습니다. 상품을 스캔하세요.");
|
||||
}
|
||||
|
||||
if (!string.Equals(barcode, state.ItemBarcode, StringComparison.OrdinalIgnoreCase))
|
||||
return new(
|
||||
PickingScanDecisionKind.RejectItem,
|
||||
false,
|
||||
"error",
|
||||
"다른 상품입니다. 화면의 품목과 바코드를 확인하세요.");
|
||||
|
||||
var nextQty = Math.Min(state.PickedQty + 1m, state.RequiredQty);
|
||||
var completed = nextQty >= state.RequiredQty;
|
||||
return new(
|
||||
PickingScanDecisionKind.AcceptItem,
|
||||
true,
|
||||
"success",
|
||||
completed ? "현재 품목을 완료했습니다. 다음 작업을 진행하세요." : "1개 피킹했습니다.",
|
||||
nextQty,
|
||||
completed);
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
|
||||
namespace Modules.WMS.Picking.Shared;
|
||||
|
||||
public static class PickingTaskQueries
|
||||
{
|
||||
public static async Task<PickingTaskDto?> GetAsync(
|
||||
NpgsqlConnection connection,
|
||||
NpgsqlTransaction? transaction,
|
||||
Guid taskId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
const string taskSql = """
|
||||
select id as TaskId,
|
||||
task_no as TaskNo,
|
||||
status as Status,
|
||||
version as Version
|
||||
from wms.picking_tasks
|
||||
where id = @TaskId;
|
||||
""";
|
||||
|
||||
var task = await connection.QuerySingleOrDefaultAsync<TaskRow>(
|
||||
new CommandDefinition(taskSql, new { TaskId = taskId }, transaction, cancellationToken: ct));
|
||||
if (task is null) return null;
|
||||
|
||||
const string linesSql = """
|
||||
select l.id as LineId,
|
||||
l.line_no as LineNo,
|
||||
l.location_code as LocationCode,
|
||||
l.item_id as ItemId,
|
||||
i.code as ItemCode,
|
||||
i.name as ItemName,
|
||||
l.item_option as ItemOption,
|
||||
l.barcode as Barcode,
|
||||
l.required_qty as RequiredQty,
|
||||
l.picked_qty as PickedQty,
|
||||
greatest(l.required_qty - l.picked_qty, 0) as RemainingQty,
|
||||
l.status as Status,
|
||||
l.location_confirmed as LocationConfirmed
|
||||
from wms.picking_lines l
|
||||
join catalog.items i on i.id = l.item_id
|
||||
where l.task_id = @TaskId
|
||||
order by l.line_no;
|
||||
""";
|
||||
|
||||
var lines = (await connection.QueryAsync<LineRow>(
|
||||
new CommandDefinition(linesSql, new { TaskId = taskId }, transaction, cancellationToken: ct))).AsList();
|
||||
|
||||
var current = lines.FirstOrDefault(x => x.Status != "COMPLETED");
|
||||
var completedLines = lines.Count(x => x.Status == "COMPLETED");
|
||||
var totalQty = lines.Sum(x => x.RequiredQty);
|
||||
var completedQty = lines.Sum(x => x.PickedQty);
|
||||
|
||||
var stage = task.Status switch
|
||||
{
|
||||
"READY" => "ready",
|
||||
"COMPLETED" => "completed",
|
||||
"BLOCKED" => "blocked",
|
||||
_ when current is null => "completed",
|
||||
_ when current.LocationConfirmed => "await-item",
|
||||
_ => "await-location"
|
||||
};
|
||||
|
||||
return new PickingTaskDto(
|
||||
task.TaskId,
|
||||
task.TaskNo,
|
||||
stage,
|
||||
task.Status,
|
||||
completedLines,
|
||||
lines.Count,
|
||||
completedQty,
|
||||
totalQty,
|
||||
task.Version,
|
||||
current is null ? null : new PickingLineDto(
|
||||
current.LineId,
|
||||
current.LineNo,
|
||||
current.LocationCode,
|
||||
current.ItemId,
|
||||
current.ItemCode,
|
||||
current.ItemName,
|
||||
current.ItemOption,
|
||||
current.Barcode,
|
||||
current.RequiredQty,
|
||||
current.PickedQty,
|
||||
current.RemainingQty));
|
||||
}
|
||||
|
||||
private sealed record TaskRow(Guid TaskId, string TaskNo, string Status, long Version);
|
||||
private sealed record LineRow(
|
||||
Guid LineId,
|
||||
int LineNo,
|
||||
string LocationCode,
|
||||
Guid ItemId,
|
||||
string ItemCode,
|
||||
string ItemName,
|
||||
string? ItemOption,
|
||||
string Barcode,
|
||||
decimal RequiredQty,
|
||||
decimal PickedQty,
|
||||
decimal RemainingQty,
|
||||
string Status,
|
||||
bool LocationConfirmed = false);
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
|
||||
namespace Modules.WMS.Picking.Shared;
|
||||
|
||||
public static class PickingTransitions
|
||||
{
|
||||
public static async Task AutoConfirmNextSameLocationAsync(
|
||||
NpgsqlConnection connection,
|
||||
NpgsqlTransaction transaction,
|
||||
Guid taskId,
|
||||
string completedLocation,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await connection.ExecuteAsync(new CommandDefinition("""
|
||||
update wms.picking_lines
|
||||
set location_confirmed=true, updated_at=now()
|
||||
where id = (
|
||||
select id
|
||||
from wms.picking_lines
|
||||
where task_id=@TaskId
|
||||
and status <> 'COMPLETED'
|
||||
order by line_no
|
||||
limit 1
|
||||
)
|
||||
and location_code=@LocationCode;
|
||||
""", new { TaskId = taskId, LocationCode = completedLocation }, transaction, cancellationToken: ct));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using Dapper;
|
||||
using FastEndpoints;
|
||||
using Modules.WMS.Picking.Shared;
|
||||
using Npgsql;
|
||||
using Shared.Problems;
|
||||
|
||||
namespace Modules.WMS.Picking.Start;
|
||||
|
||||
public sealed record StartPickingRequest(long ExpectedVersion);
|
||||
|
||||
public sealed class Endpoint(NpgsqlDataSource dataSource)
|
||||
: Endpoint<StartPickingRequest, PickingTaskDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/wms/picking/tasks/{taskId:guid}/start");
|
||||
Permissions("wms.picking.execute");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(StartPickingRequest req, CancellationToken ct)
|
||||
{
|
||||
var taskId = Route<Guid>("taskId");
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var tx = await connection.BeginTransactionAsync(ct);
|
||||
|
||||
const string sql = """
|
||||
update wms.picking_tasks
|
||||
set status = 'IN_PROGRESS',
|
||||
started_at = coalesce(started_at, now()),
|
||||
version = version + 1,
|
||||
updated_at = now()
|
||||
where id = @TaskId
|
||||
and status = 'READY'
|
||||
and version = @ExpectedVersion
|
||||
returning version;
|
||||
""";
|
||||
|
||||
var version = await connection.QuerySingleOrDefaultAsync<long?>(
|
||||
new CommandDefinition(sql, new { TaskId = taskId, req.ExpectedVersion }, tx, cancellationToken: ct));
|
||||
|
||||
if (version is null)
|
||||
{
|
||||
var current = await connection.QuerySingleOrDefaultAsync<long?>(
|
||||
new CommandDefinition("select version from wms.picking_tasks where id=@TaskId", new { TaskId = taskId }, tx, cancellationToken: ct));
|
||||
await tx.RollbackAsync(ct);
|
||||
await Send.ResponseAsync(KbxConflictProblem.Version(current), 409, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
await connection.ExecuteAsync(new CommandDefinition("""
|
||||
insert into audit.entries(id, aggregate_type, aggregate_id, action, actor, occurred_at, data)
|
||||
values (@Id, 'WmsPickingTask', @TaskId, 'PickingStarted', @Actor, now(), '{}'::jsonb);
|
||||
""", new { Id = Guid.NewGuid(), TaskId = taskId, Actor = User.Identity?.Name ?? "unknown" }, tx, cancellationToken: ct));
|
||||
|
||||
await tx.CommitAsync(ct);
|
||||
var task = await PickingTaskQueries.GetAsync(connection, null, taskId, ct);
|
||||
await SendOkAsync(task!, ct);
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
using Modules.WMS.Picking.Shared;
|
||||
using Xunit;
|
||||
|
||||
namespace Modules.WMS.Picking.Tests;
|
||||
|
||||
public sealed class PickingScanStateMachineTests
|
||||
{
|
||||
[Fact]
|
||||
public void LocationMustBeConfirmedBeforeItem()
|
||||
{
|
||||
var state = new PickingScanState(false, "A-03-02", "LOC-A-03-02", "880123", 2, 0);
|
||||
var result = PickingScanStateMachine.Decide(state, "880123");
|
||||
|
||||
Assert.Equal(PickingScanDecisionKind.RejectLocation, result.Kind);
|
||||
Assert.False(result.Accepted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CorrectLocationMovesToItemStageWithoutChangingQuantity()
|
||||
{
|
||||
var state = new PickingScanState(false, "A-03-02", "LOC-A-03-02", "880123", 2, 0);
|
||||
var result = PickingScanStateMachine.Decide(state, "LOC-A-03-02");
|
||||
|
||||
Assert.Equal(PickingScanDecisionKind.AcceptLocation, result.Kind);
|
||||
Assert.Null(result.NewPickedQty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CorrectItemIncrementsExactlyOneUnit()
|
||||
{
|
||||
var state = new PickingScanState(true, "A-03-02", "LOC-A-03-02", "880123", 2, 0);
|
||||
var result = PickingScanStateMachine.Decide(state, "880123");
|
||||
|
||||
Assert.Equal(PickingScanDecisionKind.AcceptItem, result.Kind);
|
||||
Assert.Equal(1m, result.NewPickedQty);
|
||||
Assert.False(result.LineCompleted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequiredQuantityCompletesLine()
|
||||
{
|
||||
var state = new PickingScanState(true, "A-03-02", "LOC-A-03-02", "880123", 2, 1);
|
||||
var result = PickingScanStateMachine.Decide(state, "880123");
|
||||
|
||||
Assert.Equal(2m, result.NewPickedQty);
|
||||
Assert.True(result.LineCompleted);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user