V13-FE-011: finalize search list layout slice

This commit is contained in:
2026-08-09 02:57:26 +09:00
parent 9efd202e76
commit 6422cb2b13
984 changed files with 120811 additions and 1498 deletions
@@ -0,0 +1,14 @@
using Microsoft.Extensions.DependencyInjection;
namespace Shared.Operations;
public static class KbxOperationsRegistration
{
public static IServiceCollection AddKbxOperations(this IServiceCollection services)
{
services.AddSingleton<OperationsProjectionWriter>();
services.AddSingleton<ReconcileProjectionWriter>();
services.AddSingleton<WorkItemActionRegistry>();
return services;
}
}
@@ -0,0 +1,38 @@
namespace Shared.Operations;
public sealed record WorkItemProjection(
Guid Id,
string SourceModule,
string SourceType,
string SourceId,
string ReferenceNo,
string? SourceScreenId,
string Code,
string Title,
string? Detail,
string Severity,
string Status,
string? OwnerId,
string? OwnerName,
DateTimeOffset OccurredAt,
DateTimeOffset? DueAt,
int AgeMinutes,
long Version,
string ContextJson,
string? RetryActionKey,
bool AllowManualResolution);
public sealed record WorkQueueCounter(string Key, string Label, int Count, string Severity);
public sealed record WorkItemActionDto(string Id, string Label, string Kind, string? Permission = null, bool Danger = false);
public sealed record WorkItemDto(
Guid Id, string SourceModule, string SourceType, string SourceId, string ReferenceNo, string? SourceScreenId, string Code, string Title, string? Detail,
string Severity, string Status, string? OwnerId, string? OwnerName, DateTimeOffset OccurredAt,
DateTimeOffset? DueAt, int AgeMinutes, long Version, object Context, IReadOnlyList<WorkItemActionDto> Actions);
public sealed record WorkQueueResponse(IReadOnlyList<WorkItemDto> Items, int TotalCount, IReadOnlyList<WorkQueueCounter> Counters);
public sealed record ReconcileItemDto(
Guid Id, string ReconcileType, string ReferenceNo, string SourceLabel, string TargetLabel,
string ExpectedValue, string ActualValue, string? DifferenceValue, string? ReasonCode, string? ReasonText,
string Status, DateTimeOffset OccurredAt, string? SourceId, string? TargetId, long Version);
public sealed record ReconcileSummary(int TotalCount, int MatchedCount, int MismatchCount, int PendingCount, int ResolvedCount);
public sealed record ReconcileResponse(IReadOnlyList<ReconcileItemDto> Items, ReconcileSummary Summary);
@@ -0,0 +1,14 @@
using System.Security.Claims;
namespace Shared.Operations;
public static class OperationsIdentity
{
public static string TenantId(ClaimsPrincipal user) =>
user.FindFirst("tenant_id")?.Value ?? user.FindFirst("tenant")?.Value ?? "default";
public static string UserId(ClaimsPrincipal user) =>
user.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? user.Identity?.Name ?? "unknown";
public static string UserName(ClaimsPrincipal user) => user.Identity?.Name ?? UserId(user);
}
@@ -0,0 +1,91 @@
using Dapper;
using Npgsql;
using System.Text.Json;
namespace Shared.Operations;
public sealed record UpsertWorkItem(
string TenantId,
string SourceModule,
string SourceType,
string SourceId,
string ReferenceNo,
string? SourceScreenId,
long? SourceVersion,
string Code,
string Title,
string? Detail,
string Severity,
DateTimeOffset OccurredAt,
DateTimeOffset? DueAt = null,
string? RetryActionKey = null,
bool AllowManualResolution = false,
object? Context = null);
public sealed class OperationsProjectionWriter(NpgsqlDataSource dataSource)
{
public async Task<Guid> UpsertAsync(UpsertWorkItem item, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var id = Guid.NewGuid();
const string sql = """
insert into kbx.work_items(
id, tenant_id, source_module, source_type, source_id, reference_no, source_screen_id, source_version,
code, title, detail, severity, status, retry_action_key, allow_manual_resolution,
context, occurred_at, due_at)
values(
@Id, @TenantId, @SourceModule, @SourceType, @SourceId, @ReferenceNo, @SourceScreenId, @SourceVersion,
@Code, @Title, @Detail, @Severity, 'open', @RetryActionKey, @AllowManualResolution,
cast(@Context as jsonb), @OccurredAt, @DueAt)
on conflict(tenant_id, source_module, source_type, source_id, code)
do update set
source_version = excluded.source_version,
reference_no = excluded.reference_no,
source_screen_id = excluded.source_screen_id,
title = excluded.title,
detail = excluded.detail,
severity = excluded.severity,
status = case when kbx.work_items.status = 'resolved' then 'open' else kbx.work_items.status end,
retry_action_key = excluded.retry_action_key,
allow_manual_resolution = excluded.allow_manual_resolution,
context = excluded.context,
occurred_at = excluded.occurred_at,
due_at = excluded.due_at,
resolved_at = null,
resolution_reason = null,
version = kbx.work_items.version + 1,
updated_at = now()
where excluded.source_version is null
or kbx.work_items.source_version is null
or excluded.source_version > kbx.work_items.source_version
returning id;
""";
var args = new {
Id = id,
item.TenantId, item.SourceModule, item.SourceType, item.SourceId, item.ReferenceNo, item.SourceScreenId, item.SourceVersion,
item.Code, item.Title, item.Detail, item.Severity, item.RetryActionKey, item.AllowManualResolution,
Context = JsonSerializer.Serialize(item.Context ?? new { }), item.OccurredAt, item.DueAt
};
var changedId = await connection.ExecuteScalarAsync<Guid?>(new CommandDefinition(sql, args, cancellationToken: ct));
if (changedId is not null) return changedId.Value;
return await connection.ExecuteScalarAsync<Guid>(new CommandDefinition("""
select id from kbx.work_items
where tenant_id=@TenantId and source_module=@SourceModule and source_type=@SourceType
and source_id=@SourceId and code=@Code;
""", args, cancellationToken: ct));
}
public async Task ResolveBySourceAsync(string tenantId, string sourceModule, string sourceType, string sourceId, string code, string reason, long? sourceVersion, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await connection.ExecuteAsync(new CommandDefinition("""
update kbx.work_items
set status = 'resolved', resolved_at = now(), resolution_reason = @Reason,
source_version = coalesce(@SourceVersion, source_version),
version = version + 1, updated_at = now()
where tenant_id = @TenantId and source_module = @SourceModule and source_type = @SourceType
and source_id = @SourceId and code = @Code and status <> 'resolved'
and (@SourceVersion is null or source_version is null or @SourceVersion >= source_version);
""", new { TenantId = tenantId, SourceModule = sourceModule, SourceType = sourceType, SourceId = sourceId, Code = code, Reason = reason, SourceVersion = sourceVersion }, cancellationToken: ct));
}
}
@@ -0,0 +1,15 @@
namespace Shared.Operations;
public static class ReconcilePolicy
{
public static string DecideStatus(
bool valuesMatch,
DateTimeOffset sourceChangedAt,
DateTimeOffset observedAt,
TimeSpan gracePeriod)
{
if (valuesMatch) return "matched";
if (observedAt < sourceChangedAt) throw new ArgumentOutOfRangeException(nameof(observedAt));
return observedAt - sourceChangedAt < gracePeriod ? "pending" : "mismatch";
}
}
@@ -0,0 +1,60 @@
using Dapper;
using Npgsql;
namespace Shared.Operations;
public sealed record UpsertReconcileItem(
string TenantId,
string ReconcileType,
string ReferenceNo,
string SourceLabel,
string TargetLabel,
string ExpectedValue,
string ActualValue,
string? DifferenceValue,
string? ReasonCode,
string? ReasonText,
string Status,
string? SourceId,
string? TargetId,
DateTimeOffset OccurredAt);
public sealed class ReconcileProjectionWriter(NpgsqlDataSource dataSource)
{
public async Task<Guid> UpsertAsync(UpsertReconcileItem item, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var id = Guid.NewGuid();
const string sql = """
insert into kbx.reconcile_items(
id, tenant_id, reconcile_type, reference_no, source_label, target_label,
expected_value, actual_value, difference_value, reason_code, reason_text, status,
source_id, target_id, identity_key, occurred_at)
values(
@Id, @TenantId, @ReconcileType, @ReferenceNo, @SourceLabel, @TargetLabel,
@ExpectedValue, @ActualValue, @DifferenceValue, @ReasonCode, @ReasonText, @Status,
@SourceId, @TargetId, @IdentityKey, @OccurredAt)
on conflict(tenant_id, reconcile_type, reference_no, identity_key)
do update set
source_label=excluded.source_label, target_label=excluded.target_label,
expected_value=excluded.expected_value, actual_value=excluded.actual_value,
difference_value=excluded.difference_value, reason_code=excluded.reason_code,
reason_text=excluded.reason_text, status=excluded.status,
occurred_at=excluded.occurred_at, version=kbx.reconcile_items.version+1, updated_at=now()
where excluded.occurred_at >= kbx.reconcile_items.occurred_at
returning id;
""";
var args = new {
Id = id,
item.TenantId, item.ReconcileType, item.ReferenceNo, item.SourceLabel, item.TargetLabel,
item.ExpectedValue, item.ActualValue, item.DifferenceValue, item.ReasonCode, item.ReasonText,
item.Status, item.SourceId, item.TargetId, IdentityKey = $"{item.SourceId ?? "-"}|{item.TargetId ?? "-"}", item.OccurredAt,
};
var changedId = await connection.ExecuteScalarAsync<Guid?>(new CommandDefinition(sql, args, cancellationToken: ct));
if (changedId is not null) return changedId.Value;
return await connection.ExecuteScalarAsync<Guid>(new CommandDefinition("""
select id from kbx.reconcile_items
where tenant_id=@TenantId and reconcile_type=@ReconcileType and reference_no=@ReferenceNo and identity_key=@IdentityKey;
""", args, cancellationToken: ct));
}
}
@@ -0,0 +1,28 @@
using Shared.Operations;
using Xunit;
namespace Shared.Operations.Tests;
public sealed class ReconcilePolicyTests
{
[Fact]
public void Equal_values_are_matched_immediately()
{
var now = DateTimeOffset.UtcNow;
Assert.Equal("matched", ReconcilePolicy.DecideStatus(true, now, now, TimeSpan.FromSeconds(30)));
}
[Fact]
public void Mismatch_inside_eventual_consistency_window_is_pending()
{
var changed = DateTimeOffset.UtcNow;
Assert.Equal("pending", ReconcilePolicy.DecideStatus(false, changed, changed.AddSeconds(10), TimeSpan.FromSeconds(30)));
}
[Fact]
public void Mismatch_after_grace_period_is_actionable()
{
var changed = DateTimeOffset.UtcNow;
Assert.Equal("mismatch", ReconcilePolicy.DecideStatus(false, changed, changed.AddMinutes(2), TimeSpan.FromSeconds(30)));
}
}
@@ -0,0 +1,19 @@
using Shared.Operations;
using Xunit;
namespace Shared.Operations.Tests;
public sealed class WorkItemActionPolicyTests
{
[Fact]
public void Domain_exception_is_not_manually_resolvable_by_default() =>
Assert.False(WorkItemActionPolicy.CanManualResolve("open", false));
[Fact]
public void Explicit_operational_item_can_be_manually_resolved() =>
Assert.True(WorkItemActionPolicy.CanManualResolve("claimed", true));
[Fact]
public void Retry_requires_registered_action_key() =>
Assert.False(WorkItemActionPolicy.CanRetry("open", null));
}
@@ -0,0 +1,10 @@
namespace Shared.Operations;
public static class WorkItemActionPolicy
{
public static bool CanClaim(string status) => status == "open";
public static bool CanManualResolve(string status, bool allowManualResolution) =>
allowManualResolution && status is "open" or "claimed";
public static bool CanRetry(string status, string? retryActionKey) =>
!string.IsNullOrWhiteSpace(retryActionKey) && status is "open" or "claimed";
}
@@ -0,0 +1,15 @@
namespace Shared.Operations;
public interface IWorkItemActionHandler
{
string Key { get; }
Task ExecuteAsync(string tenantId, string actorId, Guid workItemId, CancellationToken ct);
}
public sealed class WorkItemActionRegistry(IEnumerable<IWorkItemActionHandler> handlers)
{
private readonly IReadOnlyDictionary<string, IWorkItemActionHandler> _handlers =
handlers.ToDictionary(x => x.Key, StringComparer.OrdinalIgnoreCase);
public bool TryGet(string key, out IWorkItemActionHandler? handler) => _handlers.TryGetValue(key, out handler);
}