V13-FE-011: finalize search list layout slice
This commit is contained in:
+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();
|
||||
}
|
||||
Reference in New Issue
Block a user