diff --git a/.env.example b/.env.example index 37dd928..61d7b7a 100644 --- a/.env.example +++ b/.env.example @@ -4,4 +4,5 @@ COSMOS_CONNECTION_STRING=AccountEndpoint=https://localhost:8081/;AccountKey=/dev/null; then + exit 0 + fi + sleep 2 + done + exit 1 + + - name: Run postal mini app with Cosmos emulator + run: dotnet run --project PersonalSharp.PostalLogisticsCenter --no-build --no-restore -- --cosmos + + - name: Postal mini app real Cosmos integration test + run: dotnet test PersonalSharp.IntegrationTests/PersonalSharp.IntegrationTests.csproj --no-restore --filter FullyQualifiedName~PostalLogisticsCosmosIntegrationTests /p:UseSharedCompilation=false /m:1 diff --git a/.gitignore b/.gitignore index 523e9d5..422dc4d 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,10 @@ CodeCoverage/ project.lock.json project.fragment.lock.json +# Node frontend +PersonalSharp.PostalLogisticsCenter.Ui/node_modules/ +PersonalSharp.PostalLogisticsCenter.Ui/dist/ + # Logs and diagnostics *.log *.binlog diff --git a/PersonalSharp.Cli/Program.cs b/PersonalSharp.Cli/Program.cs index 5fa06b5..9e09a50 100644 --- a/PersonalSharp.Cli/Program.cs +++ b/PersonalSharp.Cli/Program.cs @@ -92,9 +92,7 @@ static void PrintSnippet(SnippetCase snippet) Console.WriteLine(); Console.WriteLine($"Question: {snippet.Question}"); Console.WriteLine(); - Console.WriteLine("Code:"); - Console.WriteLine(snippet.Code); - Console.WriteLine(); + Console.WriteLine($"Source: {snippet.Source}"); Console.WriteLine($"Expected: {snippet.ExpectedOutput}"); Console.WriteLine($"Actual: {snippet.Run()}"); Console.WriteLine(); @@ -109,9 +107,7 @@ static void PrintUnsafeSnippet(UnsafeSnippetCase snippet) Console.WriteLine(); Console.WriteLine($"Question: {snippet.Question}"); Console.WriteLine(); - Console.WriteLine("Code:"); - Console.WriteLine(snippet.Code); - Console.WriteLine(); + Console.WriteLine($"Source: {snippet.Source}"); Console.WriteLine($"Expected: {snippet.ExpectedOutput}"); Console.WriteLine($"Actual: {snippet.Run()}"); Console.WriteLine(); diff --git a/PersonalSharp.IntegrationTests/Cosmos/PostalLogisticsApiIntegrationTests.cs b/PersonalSharp.IntegrationTests/Cosmos/PostalLogisticsApiIntegrationTests.cs new file mode 100644 index 0000000..14e05f4 --- /dev/null +++ b/PersonalSharp.IntegrationTests/Cosmos/PostalLogisticsApiIntegrationTests.cs @@ -0,0 +1,50 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; +using PersonalSharp.PostalLogisticsCenter.Api; + +namespace PersonalSharp.IntegrationTests.Cosmos; + +[Collection(RealDatabaseCollection.Name)] +public sealed class PostalLogisticsApiIntegrationTests(CosmosFixture fixture) +{ + [Fact] + public async Task PostRuns_ShouldReturnLiveCosmosDashboard() + { + if (!IntegrationTestGate.Enabled) + { + return; + } + + await using var factory = new WebApplicationFactory() + .WithWebHostBuilder(builder => + { + builder.ConfigureAppConfiguration((_, configuration) => + { + configuration.AddInMemoryCollection(new Dictionary + { + ["COSMOS_CONNECTION_STRING"] = IntegrationTestGate.RequiredEnvironment("COSMOS_CONNECTION_STRING"), + ["COSMOS_DATABASE"] = fixture.DatabaseName, + ["COSMOS_CONTAINER"] = fixture.ContainerName, + ["COSMOS_ALLOW_INSECURE_EMULATOR_CERT"] = IntegrationTestGate.OptionalEnvironment("COSMOS_ALLOW_INSECURE_EMULATOR_CERT", "false") + }); + }); + }); + var client = factory.CreateClient(); + + var response = await client.PostAsync("/api/postal/runs", content: null); + var dashboard = await response.Content.ReadFromJsonAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(dashboard); + Assert.Equal(2, dashboard.Parcels.Count); + Assert.Equal(7, dashboard.Kpis.CosmosEvents); + Assert.Equal(1, dashboard.Kpis.CustomsReleased); + Assert.Equal(1, dashboard.Kpis.CustomsHeld); + + var held = Assert.Single(dashboard.Parcels, parcel => parcel.TrackingId == "US987654321US"); + Assert.Equal(["VagueGoodsDescription", "MissingTariffCode", "MissingElectronicAdvanceData"], held.Decision.Reasons); + Assert.Contains(held.Events, item => item.Type == "CustomsHeld"); + } +} diff --git a/PersonalSharp.IntegrationTests/Cosmos/PostalLogisticsCosmosIntegrationTests.cs b/PersonalSharp.IntegrationTests/Cosmos/PostalLogisticsCosmosIntegrationTests.cs new file mode 100644 index 0000000..a6da98d --- /dev/null +++ b/PersonalSharp.IntegrationTests/Cosmos/PostalLogisticsCosmosIntegrationTests.cs @@ -0,0 +1,24 @@ +using PersonalSharp.PostalLogisticsCenter; + +namespace PersonalSharp.IntegrationTests.Cosmos; + +public sealed class PostalLogisticsCosmosIntegrationTests(CosmosFixture fixture) : IClassFixture +{ + [Fact] + public async Task CosmosParcelEventStore_ShouldAppendPostalLifecycleEvents() + { + if (!IntegrationTestGate.Enabled) + { + return; + } + + var store = new CosmosParcelEventStore(fixture.Container); + var trackingId = IntegrationTestGate.UniqueId("postal-cn22"); + + await store.AppendAsync(trackingId, "Accepted", "station=acceptance", CancellationToken.None); + await store.AppendAsync(trackingId, "CustomsReleased", "cn22=release; document=Cn22; ead=ITMATT-DE-20260513-0001", CancellationToken.None); + var stream = await store.ReadAsync(trackingId, CancellationToken.None); + + Assert.Equal(["Accepted", "CustomsReleased"], stream.Select(item => item.Type)); + } +} diff --git a/PersonalSharp.IntegrationTests/PersonalSharp.IntegrationTests.csproj b/PersonalSharp.IntegrationTests/PersonalSharp.IntegrationTests.csproj index a4f77d7..2bc47b4 100644 --- a/PersonalSharp.IntegrationTests/PersonalSharp.IntegrationTests.csproj +++ b/PersonalSharp.IntegrationTests/PersonalSharp.IntegrationTests.csproj @@ -24,6 +24,8 @@ + + diff --git a/PersonalSharp.IntegrationTests/WebApi/RealDbOrderApiFactory.cs b/PersonalSharp.IntegrationTests/WebApi/RealDbOrderApiFactory.cs index f054102..e6119a6 100644 --- a/PersonalSharp.IntegrationTests/WebApi/RealDbOrderApiFactory.cs +++ b/PersonalSharp.IntegrationTests/WebApi/RealDbOrderApiFactory.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Npgsql; +using PersonalSharp.WebApiLab; using PersonalSharp.WebApiLab.Application; using PersonalSharp.WebApiLab.Infrastructure.Cosmos; using PersonalSharp.WebApiLab.Infrastructure.Postgres; @@ -16,7 +17,7 @@ public sealed class RealDbOrderApiFactory( string postgresConnectionString, string sqlServerConnectionString, ICosmosDocumentContainer cosmosDocumentContainer, - string orderIdPrefix) : WebApplicationFactory + string orderIdPrefix) : WebApplicationFactory { public CapturingOrderIdGenerator IdGenerator { get; } = new(orderIdPrefix); diff --git a/PersonalSharp.PostalLogisticsCenter.Api/PersonalSharp.PostalLogisticsCenter.Api.csproj b/PersonalSharp.PostalLogisticsCenter.Api/PersonalSharp.PostalLogisticsCenter.Api.csproj new file mode 100644 index 0000000..52b38d4 --- /dev/null +++ b/PersonalSharp.PostalLogisticsCenter.Api/PersonalSharp.PostalLogisticsCenter.Api.csproj @@ -0,0 +1,13 @@ + + + + + + + + net10.0 + enable + enable + + + diff --git a/PersonalSharp.PostalLogisticsCenter.Api/PostalDashboardDtos.cs b/PersonalSharp.PostalLogisticsCenter.Api/PostalDashboardDtos.cs new file mode 100644 index 0000000..95dc68d --- /dev/null +++ b/PersonalSharp.PostalLogisticsCenter.Api/PostalDashboardDtos.cs @@ -0,0 +1,183 @@ +using PersonalSharp.PostalLogisticsCenter; + +namespace PersonalSharp.PostalLogisticsCenter.Api; + +public sealed record PostalRunDashboardResponse( + string RunId, + DateTimeOffset StartedAt, + string Store, + PostalRunKpis Kpis, + PostalManifestView Manifest, + IReadOnlyList Parcels, + IReadOnlyList Events); + +public sealed record PostalRunKpis( + int Accepted, + int Sorted, + int CustomsReleased, + int CustomsHeld, + int Dispatched, + int CosmosEvents); + +public sealed record PostalManifestView(string ManifestId, IReadOnlyList TrackingIds); + +public sealed record PostalParcelView( + string TrackingId, + string Product, + string Direction, + string OriginCountry, + string DestinationCountry, + decimal WeightKg, + string Lane, + bool Priority, + string FinalStatus, + PostalCn22View Cn22, + PostalCustomsDecisionView Decision, + IReadOnlyList Events); + +public sealed record PostalCn22View( + string Category, + IReadOnlyList ContentLines, + decimal TotalWeightKg, + decimal TotalValue, + string Currency, + decimal DeclaredValueSdr, + string? ElectronicAdvanceDataId, + string? SenderSignature, + DateOnly? SignedAt, + bool SenderCertifiedNoProhibitedOrDangerousGoods, + string? OtherCategoryDescription, + IReadOnlyList AttachedDocuments); + +public sealed record PostalCn22ContentLineView( + int LineNumber, + string ContentDescription, + int Quantity, + decimal NetWeightKg, + decimal Value, + string Currency, + string CountryOfOrigin, + string? TariffCode); + +public sealed record PostalCn22AttachedDocumentView(string Type, string ReferenceNumber); + +public sealed record PostalCustomsDecisionView( + string Type, + string DocumentType, + bool IsReleased, + string? ElectronicAdvanceDataId, + IReadOnlyList Reasons, + IReadOnlyList Issues); + +public sealed record PostalCn22IssueView( + string Reason, + string Severity, + string RuleId, + string Field, + string Message); + +public sealed record PostalLifecycleEventView( + string Id, + string TrackingId, + DateTimeOffset OccurredAt, + string Type, + string Detail); + +public static class PostalDashboardDtoProjection +{ + public static PostalRunDashboardResponse ToDashboard( + CenterRunSummary summary, + IReadOnlyList sourceParcels, + IReadOnlyDictionary> eventsByTrackingId, + DateTimeOffset startedAt) + { + var sourceByTrackingId = sourceParcels.ToDictionary(parcel => parcel.TrackingId, StringComparer.OrdinalIgnoreCase); + var allEvents = eventsByTrackingId.Values + .SelectMany(stream => stream) + .OrderBy(item => item.OccurredAt) + .ThenBy(item => item.Id, StringComparer.Ordinal) + .Select(ToEventView) + .ToList(); + + var runSuffix = Guid.NewGuid().ToString("N")[..8]; + + return new PostalRunDashboardResponse( + RunId: $"postal-run-{startedAt:yyyyMMddHHmmss}-{runSuffix}", + StartedAt: startedAt, + Store: "cosmos", + Kpis: new PostalRunKpis( + summary.AcceptedCount, + summary.SortedCount, + summary.CustomsReleasedCount, + summary.CustomsHeldCount, + summary.DispatchedCount, + summary.EventDocumentCount), + Manifest: new PostalManifestView(summary.Manifest.ManifestId, summary.Manifest.TrackingIds), + Parcels: summary.Parcels.Select(result => + { + var parcel = sourceByTrackingId[result.TrackingId]; + var events = eventsByTrackingId.TryGetValue(result.TrackingId, out var stream) ? stream : []; + return ToParcelView(result, parcel, events); + }).ToList(), + Events: allEvents); + } + + private static PostalParcelView ToParcelView( + ParcelRunResult result, + Parcel parcel, + IReadOnlyList events) => + new( + TrackingId: result.TrackingId, + Product: result.Product.ToString(), + Direction: result.Direction.ToString(), + OriginCountry: parcel.OriginCountry, + DestinationCountry: parcel.DestinationCountry, + WeightKg: parcel.WeightKg, + Lane: result.Lane, + Priority: result.Priority, + FinalStatus: result.FinalStatus.ToString(), + Cn22: ToCn22View(parcel.Cn22), + Decision: ToDecisionView(result.Decision), + Events: events.Select(ToEventView).ToList()); + + private static PostalCn22View ToCn22View(Cn22Declaration declaration) => + new( + Category: declaration.Category.ToString(), + ContentLines: declaration.ContentLines.Select(line => new PostalCn22ContentLineView( + line.LineNumber, + line.ContentDescription, + line.Quantity, + line.NetWeightKg, + line.Value, + line.Currency, + line.CountryOfOrigin, + line.TariffCode)).ToList(), + TotalWeightKg: declaration.TotalWeightKg, + TotalValue: declaration.TotalValue, + Currency: declaration.Currency, + DeclaredValueSdr: declaration.DeclaredValueSdr, + ElectronicAdvanceDataId: declaration.ElectronicAdvanceDataId, + SenderSignature: declaration.SenderSignature, + SignedAt: declaration.SignedAt, + SenderCertifiedNoProhibitedOrDangerousGoods: declaration.SenderCertifiedNoProhibitedOrDangerousGoods, + OtherCategoryDescription: declaration.OtherCategoryDescription, + AttachedDocuments: declaration.Documents.Select(document => + new PostalCn22AttachedDocumentView(document.Type.ToString(), document.ReferenceNumber)).ToList()); + + private static PostalCustomsDecisionView ToDecisionView(CustomsDecision decision) => + new( + Type: decision.Type.ToString(), + DocumentType: decision.DocumentType.ToString(), + IsReleased: decision.IsReleased, + ElectronicAdvanceDataId: decision.ElectronicAdvanceDataId, + Reasons: decision.Reasons.Select(reason => reason.ToString()).ToList(), + Issues: decision.Issues.Select(issue => new PostalCn22IssueView( + issue.Reason.ToString(), + issue.Severity.ToString(), + issue.RuleId, + issue.Field, + issue.Message)).ToList()); + + private static PostalLifecycleEventView ToEventView(ParcelLifecycleEvent item) => + new(item.Id, item.TrackingId, item.OccurredAt, item.Type, item.Detail); +} diff --git a/PersonalSharp.PostalLogisticsCenter.Api/PostalDashboardRunService.cs b/PersonalSharp.PostalLogisticsCenter.Api/PostalDashboardRunService.cs new file mode 100644 index 0000000..5476407 --- /dev/null +++ b/PersonalSharp.PostalLogisticsCenter.Api/PostalDashboardRunService.cs @@ -0,0 +1,25 @@ +using PersonalSharp.PostalLogisticsCenter; + +namespace PersonalSharp.PostalLogisticsCenter.Api; + +public sealed class PostalDashboardRunService(IConfiguration configuration) +{ + public async Task RunAsync(CancellationToken cancellationToken) + { + var startedAt = DateTimeOffset.UtcNow; + var parcels = DemoParcels.Create(); + + using var store = await CosmosParcelEventStore.CreateFromEnvironmentAsync( + name => configuration[name], + cancellationToken); + + var summary = await PostalLogisticsCenterApp.RunScenarioAsync(store, parcels, cancellationToken); + var eventsByTrackingId = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var parcel in parcels) + { + eventsByTrackingId[parcel.TrackingId] = await store.ReadAsync(parcel.TrackingId, cancellationToken); + } + + return PostalDashboardDtoProjection.ToDashboard(summary, parcels, eventsByTrackingId, startedAt); + } +} diff --git a/PersonalSharp.PostalLogisticsCenter.Api/PostalLogisticsCenterApiMarker.cs b/PersonalSharp.PostalLogisticsCenter.Api/PostalLogisticsCenterApiMarker.cs new file mode 100644 index 0000000..0529744 --- /dev/null +++ b/PersonalSharp.PostalLogisticsCenter.Api/PostalLogisticsCenterApiMarker.cs @@ -0,0 +1,3 @@ +namespace PersonalSharp.PostalLogisticsCenter.Api; + +public sealed class PostalLogisticsCenterApiMarker; diff --git a/PersonalSharp.PostalLogisticsCenter.Api/PostalUiCorsOrigins.cs b/PersonalSharp.PostalLogisticsCenter.Api/PostalUiCorsOrigins.cs new file mode 100644 index 0000000..e9dbdef --- /dev/null +++ b/PersonalSharp.PostalLogisticsCenter.Api/PostalUiCorsOrigins.cs @@ -0,0 +1,25 @@ +namespace PersonalSharp.PostalLogisticsCenter.Api; + +public static class PostalUiCorsOrigins +{ + private static readonly string[] Defaults = + [ + "http://localhost:5173", + "http://127.0.0.1:5173", + "http://localhost:5174", + "http://127.0.0.1:5174" + ]; + + public static string[] From(IConfiguration configuration) + { + var configured = configuration + .GetSection("PostalUi:AllowedOrigins") + .GetChildren() + .Select(item => item.Value) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value!) + .ToArray(); + + return configured.Length == 0 ? Defaults : configured; + } +} diff --git a/PersonalSharp.PostalLogisticsCenter.Api/Program.cs b/PersonalSharp.PostalLogisticsCenter.Api/Program.cs new file mode 100644 index 0000000..51d9182 --- /dev/null +++ b/PersonalSharp.PostalLogisticsCenter.Api/Program.cs @@ -0,0 +1,43 @@ +using System.Text.Json.Serialization; +using PersonalSharp.PostalLogisticsCenter.Api; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.ConfigureHttpJsonOptions(options => +{ + options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); +}); +builder.Services.AddCors(options => +{ + options.AddPolicy( + "postal-ui", + policy => policy + .WithOrigins(PostalUiCorsOrigins.From(builder.Configuration)) + .AllowAnyHeader() + .AllowAnyMethod()); +}); +builder.Services.AddSingleton(); + +var app = builder.Build(); + +app.UseCors("postal-ui"); +app.MapGet("/health", () => Results.Ok(new { status = "ok", service = "postal-logistics-center-api" })); +app.MapPost("/api/postal/runs", async ( + PostalDashboardRunService service, + CancellationToken cancellationToken) => +{ + try + { + var dashboard = await service.RunAsync(cancellationToken); + return Results.Ok(dashboard); + } + catch (InvalidOperationException ex) + { + return Results.Problem( + title: "Postal logistics run could not start", + detail: ex.Message, + statusCode: StatusCodes.Status503ServiceUnavailable); + } +}); + +app.Run(); diff --git a/PersonalSharp.PostalLogisticsCenter.Ui/index.html b/PersonalSharp.PostalLogisticsCenter.Ui/index.html new file mode 100644 index 0000000..c9f2f10 --- /dev/null +++ b/PersonalSharp.PostalLogisticsCenter.Ui/index.html @@ -0,0 +1,12 @@ + + + + + + Postal Logistics Center + + +
+ + + diff --git a/PersonalSharp.PostalLogisticsCenter.Ui/package-lock.json b/PersonalSharp.PostalLogisticsCenter.Ui/package-lock.json new file mode 100644 index 0000000..51f3828 --- /dev/null +++ b/PersonalSharp.PostalLogisticsCenter.Ui/package-lock.json @@ -0,0 +1,1199 @@ +{ + "name": "personalsharp-postal-logistics-center-ui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "personalsharp-postal-logistics-center-ui", + "version": "0.1.0", + "dependencies": { + "@tanstack/react-query": "^5.100.10", + "@tanstack/react-router": "^1.169.2", + "@tanstack/react-table": "^8.21.3", + "lucide-react": "^1.14.0", + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@types/node": "^25.7.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "typescript": "^6.0.3", + "vite": "^8.0.13" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", + "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", + "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz", + "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz", + "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz", + "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz", + "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz", + "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz", + "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz", + "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz", + "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz", + "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz", + "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz", + "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz", + "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz", + "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz", + "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tanstack/history": { + "version": "1.161.6", + "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.161.6.tgz", + "integrity": "sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg==", + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.100.10", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.10.tgz", + "integrity": "sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.100.10", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.10.tgz", + "integrity": "sha512-FLaZf2RCrA/Zgp4aiu5tG3TyasTRO7aZ99skxQpr3Hg/zXOhu6yq5FZCYQ/tRaJtM9ylnoK8tFK7PolXQadv6Q==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.100.10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-router": { + "version": "1.169.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.169.2.tgz", + "integrity": "sha512-OJM7Kguc7ERnweaNRWsyWgIKcl3z23rD1B4jaxjzd9RGdnzpt2HfrWa9rggbT0Hfzhfo4D2ZmsfoTme035tniQ==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.161.6", + "@tanstack/react-store": "^0.9.3", + "@tanstack/router-core": "1.169.2", + "isbot": "^5.1.22" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=18.0.0 || >=19.0.0", + "react-dom": ">=18.0.0 || >=19.0.0" + } + }, + "node_modules/@tanstack/react-store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.3.tgz", + "integrity": "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.9.3", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/router-core": { + "version": "1.169.2", + "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.169.2.tgz", + "integrity": "sha512-5sm0DJF1A7Mz+9gy4Gz/lLovNailK3yot4vYvz9MkBUPw26uLnhQiR8hSCYxucjE0wD6Mdlc5l+Z0/XTlZ7xHw==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.161.6", + "cookie-es": "^3.0.0", + "seroval": "^1.5.4", + "seroval-plugins": "^1.5.4" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.3.tgz", + "integrity": "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/node": { + "version": "25.7.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.7.0.tgz", + "integrity": "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/cookie-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz", + "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/isbot": { + "version": "5.1.40", + "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.40.tgz", + "integrity": "sha512-yNeeynhhtIVRBk12tBV4eHNxwB42HzR4Q3Ea7vCOiJhImGaAIdIMrbJtacQlBizGLjUPw+akkFI5Dn9T70XoVQ==", + "license": "Unlicense", + "engines": { + "node": ">=18" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lucide-react": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.14.0.tgz", + "integrity": "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.6" + } + }, + "node_modules/rolldown": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", + "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.130.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.1", + "@rolldown/binding-darwin-arm64": "1.0.1", + "@rolldown/binding-darwin-x64": "1.0.1", + "@rolldown/binding-freebsd-x64": "1.0.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", + "@rolldown/binding-linux-arm64-gnu": "1.0.1", + "@rolldown/binding-linux-arm64-musl": "1.0.1", + "@rolldown/binding-linux-ppc64-gnu": "1.0.1", + "@rolldown/binding-linux-s390x-gnu": "1.0.1", + "@rolldown/binding-linux-x64-gnu": "1.0.1", + "@rolldown/binding-linux-x64-musl": "1.0.1", + "@rolldown/binding-openharmony-arm64": "1.0.1", + "@rolldown/binding-wasm32-wasi": "1.0.1", + "@rolldown/binding-win32-arm64-msvc": "1.0.1", + "@rolldown/binding-win32-x64-msvc": "1.0.1" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/seroval": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.4.tgz", + "integrity": "sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/seroval-plugins": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.4.tgz", + "integrity": "sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "seroval": "^1.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.21.0.tgz", + "integrity": "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "8.0.13", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", + "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.14", + "rolldown": "1.0.1", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/PersonalSharp.PostalLogisticsCenter.Ui/package.json b/PersonalSharp.PostalLogisticsCenter.Ui/package.json new file mode 100644 index 0000000..a6e1918 --- /dev/null +++ b/PersonalSharp.PostalLogisticsCenter.Ui/package.json @@ -0,0 +1,27 @@ +{ + "name": "personalsharp-postal-logistics-center-ui", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1", + "build": "tsc -b && vite build", + "preview": "vite preview --host 127.0.0.1" + }, + "dependencies": { + "@tanstack/react-query": "^5.100.10", + "@tanstack/react-router": "^1.169.2", + "@tanstack/react-table": "^8.21.3", + "lucide-react": "^1.14.0", + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@types/node": "^25.7.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "typescript": "^6.0.3", + "vite": "^8.0.13" + } +} diff --git a/PersonalSharp.PostalLogisticsCenter.Ui/src/Dashboard.tsx b/PersonalSharp.PostalLogisticsCenter.Ui/src/Dashboard.tsx new file mode 100644 index 0000000..f5fcdc2 --- /dev/null +++ b/PersonalSharp.PostalLogisticsCenter.Ui/src/Dashboard.tsx @@ -0,0 +1,441 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import { + createColumnHelper, + flexRender, + getCoreRowModel, + useReactTable, +} from '@tanstack/react-table'; +import { + AlertTriangle, + CheckCircle2, + Clock3, + Database, + LoaderCircle, + PackageCheck, + Play, + ShieldCheck, + Truck, + type LucideIcon, +} from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { getApiHealth, runPostalScenario } from './api'; +import type { PostalLifecycleEventView, PostalParcelView, PostalRunDashboardResponse } from './types'; + +const parcelColumn = createColumnHelper(); +const eventColumn = createColumnHelper(); + +export function Dashboard() { + const [selectedTrackingId, setSelectedTrackingId] = useState(null); + const health = useQuery({ + queryKey: ['postal-api-health'], + queryFn: getApiHealth, + }); + const run = useMutation({ + mutationFn: runPostalScenario, + onSuccess: data => { + setSelectedTrackingId(data.parcels[0]?.trackingId ?? null); + }, + }); + + const dashboard = run.data; + const selectedParcel = dashboard?.parcels.find(parcel => parcel.trackingId === selectedTrackingId) + ?? dashboard?.parcels[0] + ?? null; + + return ( +
+
+
+

Postal Logistics Center

+

CN22 Customs Operations

+
+
+ + +
+
+ + {run.error ? : null} + + + +
+ + +
+ +
+ + +
+
+ ); +} + +function KpiStrip({ dashboard }: { dashboard: PostalRunDashboardResponse | undefined }) { + const kpis = dashboard?.kpis; + const items = [ + { label: 'Accepted', value: kpis?.accepted ?? 0, icon: PackageCheck }, + { label: 'Released', value: kpis?.customsReleased ?? 0, icon: ShieldCheck }, + { label: 'Held', value: kpis?.customsHeld ?? 0, icon: AlertTriangle }, + { label: 'Dispatched', value: kpis?.dispatched ?? 0, icon: Truck }, + { label: 'Cosmos events', value: kpis?.cosmosEvents ?? 0, icon: Database }, + ]; + + return ( +
+ {items.map(item => ( +
+ + {item.label} + {item.value} +
+ ))} +
+ ); +} + +function ParcelTable({ + dashboard, + selectedTrackingId, + onSelect, +}: { + dashboard: PostalRunDashboardResponse | undefined; + selectedTrackingId: string | null; + onSelect: (trackingId: string) => void; +}) { + const columns = useMemo( + () => [ + parcelColumn.accessor('trackingId', { + header: 'Tracking', + cell: info => {info.getValue()}, + }), + parcelColumn.accessor('lane', { + header: 'Lane', + cell: info => {info.getValue()}, + }), + parcelColumn.accessor('product', { + header: 'Product', + }), + parcelColumn.accessor('priority', { + header: 'Priority', + cell: info => (info.getValue() ? : ), + }), + parcelColumn.accessor('finalStatus', { + header: 'Status', + cell: info => , + }), + parcelColumn.accessor(row => row.decision.documentType, { + id: 'document', + header: 'Doc', + }), + parcelColumn.accessor(row => row.decision.electronicAdvanceDataId ?? 'missing', { + id: 'ead', + header: 'EAD', + cell: info => {info.getValue()}, + }), + ], + [], + ); + const table = useReactTable({ + data: dashboard?.parcels ?? [], + columns, + getCoreRowModel: getCoreRowModel(), + }); + + return ( +
+ +
+ + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => ( + + ))} + + ))} + + + {table.getRowModel().rows.map(row => ( + onSelect(row.original.trackingId)} + > + {row.getVisibleCells().map(cell => ( + + ))} + + ))} + {table.getRowModel().rows.length === 0 ? ( + + + + ) : null} + +
+ {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} +
{flexRender(cell.column.columnDef.cell, cell.getContext())}
+ No live run loaded +
+
+
+ ); +} + +function Cn22Inspector({ parcel }: { parcel: PostalParcelView | null }) { + return ( +
+ + {parcel ? ( + <> +
+ + + {parcel.originCountry} → {parcel.destinationCountry} +
+ +
+ + + + + + +
+ +
+ {parcel.cn22.contentLines.map(line => ( +
+
+ #{line.lineNumber} + {line.contentDescription} +
+
+ {line.quantity} pcs + {formatNumber(line.netWeightKg)} kg + {formatNumber(line.value)} {line.currency} + HS {line.tariffCode ?? 'missing'} + Origin {line.countryOfOrigin || 'missing'} +
+
+ ))} +
+ + + + ) : ( + + )} +
+ ); +} + +function IssueList({ parcel }: { parcel: PostalParcelView }) { + const issues = parcel.decision.issues; + if (issues.length === 0) { + return ( +
+ + No CN22 issues +
+ ); + } + + return ( +
+ {issues.map(issue => ( +
+
+ + {issue.ruleId} +
+ {issue.reason} +

{issue.field}

+ {issue.message} +
+ ))} +
+ ); +} + +function LifecyclePanel({ parcel }: { parcel: PostalParcelView | null }) { + return ( +
+ + {parcel ? ( +
    + {parcel.events.map(event => ( +
  1. +
    +
    + {event.type} + {formatDate(event.occurredAt)} +

    {event.detail}

    +
    +
  2. + ))} +
+ ) : ( + + )} +
+ ); +} + +function EventLog({ dashboard }: { dashboard: PostalRunDashboardResponse | undefined }) { + const columns = useMemo( + () => [ + eventColumn.accessor('occurredAt', { + header: 'Time', + cell: info => formatDate(info.getValue()), + }), + eventColumn.accessor('trackingId', { + header: 'Tracking', + cell: info => {info.getValue()}, + }), + eventColumn.accessor('type', { + header: 'Event', + cell: info => , + }), + eventColumn.accessor('detail', { + header: 'Detail', + cell: info => {info.getValue()}, + }), + ], + [], + ); + const table = useReactTable({ + data: dashboard?.events ?? [], + columns, + getCoreRowModel: getCoreRowModel(), + }); + + return ( +
+ +
+ + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => ( + + ))} + + ))} + + + {table.getRowModel().rows.map(row => ( + + {row.getVisibleCells().map(cell => ( + + ))} + + ))} + {table.getRowModel().rows.length === 0 ? ( + + + + ) : null} + +
+ {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} +
{flexRender(cell.column.columnDef.cell, cell.getContext())}
+ No events loaded +
+
+
+ ); +} + +function PanelHeader({ title, meta }: { title: string; meta: string }) { + return ( +
+

{title}

+ {meta} +
+ ); +} + +function Field({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +function ErrorBanner({ message }: { message: string }) { + return ( +
+ + {message} +
+ ); +} + +function EmptyPanel({ icon: Icon, label }: { icon: LucideIcon; label: string }) { + return ( +
+ + {label} +
+ ); +} + +function StatusBadge({ status }: { status: string }) { + const normalized = status.toLowerCase(); + const tone = normalized.includes('held') || normalized === 'hold' + ? 'hold' + : normalized.includes('released') || normalized.includes('dispatched') || normalized === 'released' + ? 'release' + : normalized.includes('warning') + ? 'warning' + : 'neutral'; + + return ; +} + +function StatusPill({ + tone, + label, +}: { + tone: 'release' | 'hold' | 'warning' | 'neutral' | 'priority'; + label: string; +}) { + return {label}; +} + +function formatNumber(value: number) { + return new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 }).format(value); +} + +function formatDate(value: string) { + return new Intl.DateTimeFormat('en-US', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }).format(new Date(value)); +} diff --git a/PersonalSharp.PostalLogisticsCenter.Ui/src/api.ts b/PersonalSharp.PostalLogisticsCenter.Ui/src/api.ts new file mode 100644 index 0000000..743e1b3 --- /dev/null +++ b/PersonalSharp.PostalLogisticsCenter.Ui/src/api.ts @@ -0,0 +1,25 @@ +import type { PostalRunDashboardResponse } from './types'; + +const apiBaseUrl = import.meta.env.VITE_POSTAL_API_BASE_URL ?? 'http://127.0.0.1:5087'; + +export async function runPostalScenario(): Promise { + const response = await fetch(`${apiBaseUrl}/api/postal/runs`, { + method: 'POST', + }); + + if (!response.ok) { + const detail = await response.text(); + throw new Error(detail || `Postal run failed with HTTP ${response.status}`); + } + + return response.json() as Promise; +} + +export async function getApiHealth(): Promise<{ status: string; service: string }> { + const response = await fetch(`${apiBaseUrl}/health`); + if (!response.ok) { + throw new Error(`Health check failed with HTTP ${response.status}`); + } + + return response.json() as Promise<{ status: string; service: string }>; +} diff --git a/PersonalSharp.PostalLogisticsCenter.Ui/src/main.tsx b/PersonalSharp.PostalLogisticsCenter.Ui/src/main.tsx new file mode 100644 index 0000000..83430ca --- /dev/null +++ b/PersonalSharp.PostalLogisticsCenter.Ui/src/main.tsx @@ -0,0 +1,43 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { createRootRoute, createRoute, createRouter, Outlet, RouterProvider } from '@tanstack/react-router'; +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { Dashboard } from './Dashboard'; +import './styles.css'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: 1, + }, + }, +}); + +const rootRoute = createRootRoute({ + component: Outlet, +}); + +const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: Dashboard, +}); + +const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), +}); + +declare module '@tanstack/react-router' { + interface Register { + router: typeof router; + } +} + +createRoot(document.getElementById('root')!).render( + + + + + , +); diff --git a/PersonalSharp.PostalLogisticsCenter.Ui/src/styles.css b/PersonalSharp.PostalLogisticsCenter.Ui/src/styles.css new file mode 100644 index 0000000..1cdac73 --- /dev/null +++ b/PersonalSharp.PostalLogisticsCenter.Ui/src/styles.css @@ -0,0 +1,592 @@ +:root { + color: #18212f; + background: #eef1f4; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-synthesis: none; + line-height: 1.45; + text-rendering: optimizeLegibility; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; + min-height: 100vh; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(238, 241, 244, 0.94)), + #eef1f4; +} + +button, +input, +textarea, +select { + font: inherit; +} + +button { + cursor: pointer; +} + +button:disabled { + cursor: wait; +} + +.app-shell { + width: min(1640px, 100%); + min-height: 100vh; + margin: 0 auto; + padding: 28px; +} + +.topbar { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + padding: 8px 0 20px; +} + +.eyebrow { + margin: 0 0 4px; + color: #56667a; + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0; + text-transform: uppercase; +} + +h1, +h2, +p { + margin: 0; +} + +h1 { + color: #111827; + font-size: clamp(2rem, 5vw, 3.4rem); + line-height: 1; + letter-spacing: 0; +} + +h2 { + color: #111827; + font-size: 1rem; + font-weight: 800; + letter-spacing: 0; +} + +.topbar-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + flex-wrap: wrap; +} + +.primary-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 42px; + padding: 0 16px; + border: 0; + border-radius: 8px; + color: #ffffff; + background: #176f7a; + box-shadow: 0 10px 24px rgba(23, 111, 122, 0.22); + font-weight: 800; +} + +.primary-button:hover:not(:disabled) { + background: #0f5d67; +} + +.primary-button:disabled { + opacity: 0.76; +} + +.spin { + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.error-banner { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 14px; + padding: 12px 14px; + border: 1px solid #f3b5af; + border-radius: 8px; + color: #8e251d; + background: #fff1ef; + font-weight: 700; +} + +.kpi-strip { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 14px; +} + +.metric { + display: grid; + grid-template-columns: auto 1fr; + grid-template-rows: auto auto; + gap: 4px 10px; + min-height: 86px; + padding: 16px; + border: 1px solid #d9dee7; + border-radius: 8px; + background: #ffffff; + box-shadow: 0 10px 30px rgba(27, 36, 50, 0.05); +} + +.metric svg { + grid-row: 1 / span 2; + margin-top: 2px; + color: #176f7a; +} + +.metric span { + color: #66758a; + font-size: 0.82rem; + font-weight: 700; +} + +.metric strong { + color: #111827; + font-size: 1.75rem; + line-height: 1; +} + +.dashboard-grid, +.bottom-grid { + display: grid; + gap: 14px; +} + +.dashboard-grid { + grid-template-columns: minmax(680px, 1.4fr) minmax(420px, 0.9fr); + align-items: start; +} + +.bottom-grid { + grid-template-columns: minmax(360px, 0.82fr) minmax(640px, 1.3fr); + align-items: start; + margin-top: 14px; +} + +.panel { + min-width: 0; + border: 1px solid #d9dee7; + border-radius: 8px; + background: rgba(255, 255, 255, 0.96); + box-shadow: 0 16px 42px rgba(27, 36, 50, 0.06); +} + +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 52px; + padding: 14px 16px; + border-bottom: 1px solid #e6eaf0; +} + +.panel-header span { + overflow: hidden; + max-width: 54%; + color: #66758a; + font-size: 0.82rem; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.table-wrap { + overflow: auto; + max-height: 480px; +} + +.table-wrap.compact { + max-height: 380px; +} + +table { + width: 100%; + min-width: 720px; + border-collapse: collapse; +} + +th, +td { + padding: 12px 14px; + border-bottom: 1px solid #edf0f4; + text-align: left; + vertical-align: middle; +} + +th { + position: sticky; + top: 0; + z-index: 1; + color: #66758a; + background: #fbfcfd; + font-size: 0.74rem; + font-weight: 800; + letter-spacing: 0; + text-transform: uppercase; +} + +td { + color: #263445; + font-size: 0.9rem; +} + +tbody tr { + transition: + background 120ms ease, + box-shadow 120ms ease; +} + +tbody tr:hover { + background: #f4fbfc; +} + +tbody tr.selected { + background: #ecf7f8; + box-shadow: inset 3px 0 0 #176f7a; +} + +.empty-cell { + height: 120px; + color: #7b8797; + text-align: center; +} + +.inspector { + overflow: hidden; +} + +.decision-line { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + padding: 14px 16px 0; +} + +.field-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + padding: 14px 16px; +} + +.field { + min-width: 0; + padding: 10px; + border: 1px solid #e4e8ef; + border-radius: 8px; + background: #fbfcfd; +} + +.field span { + display: block; + margin-bottom: 4px; + color: #6b7788; + font-size: 0.75rem; + font-weight: 800; + text-transform: uppercase; +} + +.field strong { + display: block; + overflow-wrap: anywhere; + color: #18212f; + font-size: 0.92rem; +} + +.content-lines { + display: grid; + gap: 10px; + padding: 0 16px 14px; +} + +.content-line { + display: grid; + gap: 8px; + padding: 12px; + border: 1px solid #dde5ee; + border-radius: 8px; + background: #ffffff; +} + +.line-number { + display: inline-flex; + align-items: center; + justify-content: center; + margin-right: 8px; + padding: 2px 7px; + border-radius: 999px; + color: #176f7a; + background: #e4f5f6; + font-size: 0.76rem; + font-weight: 900; +} + +.line-meta { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.line-meta span { + padding: 4px 8px; + border-radius: 999px; + color: #3b4b5d; + background: #eef2f6; + font-size: 0.78rem; + font-weight: 800; +} + +.issue-list { + display: grid; + gap: 10px; + padding: 0 16px 16px; +} + +.issue-list.success { + display: flex; + align-items: center; + gap: 8px; + color: #0b6a46; + font-weight: 800; +} + +.issue { + display: grid; + gap: 6px; + padding: 12px; + border-radius: 8px; +} + +.issue > div { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.issue.hold { + border: 1px solid #f1aaa3; + background: #fff5f3; +} + +.issue.warning { + border: 1px solid #f0d28a; + background: #fff9e8; +} + +.issue p { + color: #5d6877; + font-size: 0.82rem; + font-weight: 800; +} + +.issue span:last-child { + color: #263445; + font-size: 0.86rem; +} + +.timeline { + display: grid; + gap: 0; + margin: 0; + padding: 10px 16px 16px; + list-style: none; +} + +.timeline li { + display: grid; + grid-template-columns: 18px 1fr; + gap: 10px; + min-height: 76px; +} + +.timeline li:not(:last-child) .timeline-dot::after { + position: absolute; + top: 15px; + left: 6px; + width: 2px; + height: 62px; + background: #dce3ec; + content: ""; +} + +.timeline-dot { + position: relative; + width: 14px; + height: 14px; + margin-top: 5px; + border: 3px solid #ffffff; + border-radius: 999px; + background: #7b8797; + box-shadow: 0 0 0 2px #dce3ec; +} + +.timeline-dot.customsreleased, +.timeline-dot.dispatched { + background: #16834f; + box-shadow: 0 0 0 2px #b8e5ce; +} + +.timeline-dot.customsheld { + background: #c93d2e; + box-shadow: 0 0 0 2px #f2b8b0; +} + +.timeline strong { + display: block; + color: #18212f; +} + +.timeline span, +.timeline p { + color: #66758a; + font-size: 0.83rem; +} + +.event-detail { + display: inline-block; + max-width: 520px; + overflow-wrap: anywhere; +} + +.status-pill { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 24px; + padding: 3px 9px; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 900; + white-space: nowrap; +} + +.status-pill.release { + color: #0b6a46; + background: #dff6e9; +} + +.status-pill.hold { + color: #9f2d22; + background: #ffe2de; +} + +.status-pill.warning { + color: #7a5200; + background: #fff0c5; +} + +.status-pill.neutral { + color: #516072; + background: #edf1f6; +} + +.status-pill.priority { + color: #6c3f00; + background: #ffe2a6; +} + +.mono { + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; +} + +.strong { + font-weight: 800; +} + +.muted { + color: #7b8797; +} + +.capitalize { + text-transform: capitalize; +} + +.empty-panel { + display: grid; + place-items: center; + gap: 8px; + min-height: 220px; + color: #7b8797; + font-weight: 800; +} + +@media (max-width: 1180px) { + .dashboard-grid, + .bottom-grid { + grid-template-columns: 1fr; + } + + .kpi-strip { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 720px) { + .app-shell { + padding: 18px; + } + + .topbar { + align-items: stretch; + flex-direction: column; + } + + .topbar-actions { + justify-content: flex-start; + } + + .primary-button { + width: 100%; + } + + .kpi-strip, + .field-grid { + grid-template-columns: 1fr; + } + + .metric { + min-height: 74px; + } + + .panel-header { + align-items: flex-start; + flex-direction: column; + } + + .panel-header span { + max-width: 100%; + } + + table { + min-width: 680px; + } +} diff --git a/PersonalSharp.PostalLogisticsCenter.Ui/src/types.ts b/PersonalSharp.PostalLogisticsCenter.Ui/src/types.ts new file mode 100644 index 0000000..38a6a0e --- /dev/null +++ b/PersonalSharp.PostalLogisticsCenter.Ui/src/types.ts @@ -0,0 +1,94 @@ +export interface PostalRunDashboardResponse { + runId: string; + startedAt: string; + store: string; + kpis: PostalRunKpis; + manifest: PostalManifestView; + parcels: PostalParcelView[]; + events: PostalLifecycleEventView[]; +} + +export interface PostalRunKpis { + accepted: number; + sorted: number; + customsReleased: number; + customsHeld: number; + dispatched: number; + cosmosEvents: number; +} + +export interface PostalManifestView { + manifestId: string; + trackingIds: string[]; +} + +export interface PostalParcelView { + trackingId: string; + product: string; + direction: string; + originCountry: string; + destinationCountry: string; + weightKg: number; + lane: string; + priority: boolean; + finalStatus: string; + cn22: PostalCn22View; + decision: PostalCustomsDecisionView; + events: PostalLifecycleEventView[]; +} + +export interface PostalCn22View { + category: string; + contentLines: PostalCn22ContentLineView[]; + totalWeightKg: number; + totalValue: number; + currency: string; + declaredValueSdr: number; + electronicAdvanceDataId: string | null; + senderSignature: string | null; + signedAt: string | null; + senderCertifiedNoProhibitedOrDangerousGoods: boolean; + otherCategoryDescription: string | null; + attachedDocuments: PostalCn22AttachedDocumentView[]; +} + +export interface PostalCn22ContentLineView { + lineNumber: number; + contentDescription: string; + quantity: number; + netWeightKg: number; + value: number; + currency: string; + countryOfOrigin: string; + tariffCode: string | null; +} + +export interface PostalCn22AttachedDocumentView { + type: string; + referenceNumber: string; +} + +export interface PostalCustomsDecisionView { + type: string; + documentType: string; + isReleased: boolean; + electronicAdvanceDataId: string | null; + reasons: string[]; + issues: PostalCn22IssueView[]; +} + +export interface PostalCn22IssueView { + reason: string; + severity: string; + ruleId: string; + field: string; + message: string; +} + +export interface PostalLifecycleEventView { + id: string; + trackingId: string; + occurredAt: string; + type: string; + detail: string; +} diff --git a/PersonalSharp.PostalLogisticsCenter.Ui/tsconfig.app.json b/PersonalSharp.PostalLogisticsCenter.Ui/tsconfig.app.json new file mode 100644 index 0000000..74ea055 --- /dev/null +++ b/PersonalSharp.PostalLogisticsCenter.Ui/tsconfig.app.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "allowImportingTsExtensions": true, + "module": "ESNext", + "moduleDetection": "force", + "moduleResolution": "bundler", + "types": ["vite/client"], + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/PersonalSharp.PostalLogisticsCenter.Ui/tsconfig.json b/PersonalSharp.PostalLogisticsCenter.Ui/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/PersonalSharp.PostalLogisticsCenter.Ui/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/PersonalSharp.PostalLogisticsCenter.Ui/tsconfig.node.json b/PersonalSharp.PostalLogisticsCenter.Ui/tsconfig.node.json new file mode 100644 index 0000000..7690efd --- /dev/null +++ b/PersonalSharp.PostalLogisticsCenter.Ui/tsconfig.node.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleDetection": "force", + "moduleResolution": "bundler", + "types": ["node"], + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/PersonalSharp.PostalLogisticsCenter.Ui/vite.config.ts b/PersonalSharp.PostalLogisticsCenter.Ui/vite.config.ts new file mode 100644 index 0000000..9d8b1b1 --- /dev/null +++ b/PersonalSharp.PostalLogisticsCenter.Ui/vite.config.ts @@ -0,0 +1,10 @@ +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + strictPort: false, + }, +}); diff --git a/PersonalSharp.PostalLogisticsCenter/AkkaActors.cs b/PersonalSharp.PostalLogisticsCenter/AkkaActors.cs new file mode 100644 index 0000000..72e2f3f --- /dev/null +++ b/PersonalSharp.PostalLogisticsCenter/AkkaActors.cs @@ -0,0 +1,195 @@ +using Akka.Actor; + +namespace PersonalSharp.PostalLogisticsCenter; + +public sealed record RunCenterScenario(IReadOnlyList Parcels); + +public sealed record CenterScenarioCompleted(CenterRunSummary Summary); + +public sealed record AcceptParcel(Parcel Parcel); + +public sealed record ParcelAccepted(Parcel Parcel); + +public sealed record SortParcel(Parcel Parcel); + +public sealed record ParcelSorted(Parcel Parcel, string Lane, bool Priority); + +public sealed record ProcessCn22(ParcelSorted Sorted); + +public sealed record CustomsProcessed(ParcelSorted Sorted, CustomsDecision Decision); + +public sealed record BuildDispatchManifest(IReadOnlyList ProcessedParcels); + +public sealed record ApplyParcelStatus(ParcelStatus Status); + +public sealed record ParcelSnapshot(string TrackingId, ParcelStatus Status); + +public sealed record GetParcelSnapshot; + +public sealed class CenterSupervisor : ReceiveActor +{ + private static readonly TimeSpan AskTimeout = TimeSpan.FromSeconds(10); + private readonly IActorRef _acceptance; + private readonly IActorRef _sorting; + private readonly IActorRef _customs; + private readonly IActorRef _dispatch; + private readonly IParcelEventStore _events; + + public CenterSupervisor(IParcelEventStore events, ICn22RiskEngine riskEngine) + { + _events = events; + _acceptance = Context.ActorOf(Akka.Actor.Props.Create(() => new AcceptanceActor(events)), "acceptance"); + _sorting = Context.ActorOf(Akka.Actor.Props.Create(() => new SortingActor(events)), "sorting"); + _customs = Context.ActorOf(Akka.Actor.Props.Create(() => new Cn22CustomsActor(events, riskEngine)), "cn22-customs"); + _dispatch = Context.ActorOf(Akka.Actor.Props.Create(() => new DispatchActor(events)), "dispatch"); + + ReceiveAsync(Handle); + } + + public static Props Props(IParcelEventStore events, ICn22RiskEngine riskEngine) => + Akka.Actor.Props.Create(() => new CenterSupervisor(events, riskEngine)); + + private async Task Handle(RunCenterScenario message) + { + var replyTo = Sender; + var processed = new List(); + var stateActors = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var parcel in message.Parcels) + { + var state = Context.ActorOf( + Akka.Actor.Props.Create(() => new ParcelStateActor(parcel.TrackingId)), + $"parcel-{SanitizeActorName(parcel.TrackingId)}"); + stateActors[parcel.TrackingId] = state; + + var accepted = await _acceptance.Ask(new AcceptParcel(parcel), AskTimeout); + await state.Ask(new ApplyParcelStatus(ParcelStatus.Accepted), AskTimeout); + + var sorted = await _sorting.Ask(new SortParcel(accepted.Parcel), AskTimeout); + await state.Ask(new ApplyParcelStatus(ParcelStatus.Sorted), AskTimeout); + + var customs = await _customs.Ask(new ProcessCn22(sorted), AskTimeout); + await state.Ask( + new ApplyParcelStatus(customs.Decision.IsReleased ? ParcelStatus.CustomsReleased : ParcelStatus.CustomsHeld), + AskTimeout); + processed.Add(customs); + } + + var manifest = await _dispatch.Ask(new BuildDispatchManifest(processed), AskTimeout); + foreach (var trackingId in manifest.TrackingIds) + { + await stateActors[trackingId].Ask(new ApplyParcelStatus(ParcelStatus.Dispatched), AskTimeout); + } + + var results = new List(); + foreach (var customs in processed) + { + var snapshot = await stateActors[customs.Sorted.Parcel.TrackingId].Ask(new GetParcelSnapshot(), AskTimeout); + results.Add(new ParcelRunResult( + customs.Sorted.Parcel.TrackingId, + customs.Sorted.Parcel.Product, + customs.Sorted.Parcel.Direction, + customs.Sorted.Lane, + customs.Sorted.Priority, + snapshot.Status, + customs.Decision)); + } + + var summary = new CenterRunSummary(results, manifest, await _events.CountAsync(CancellationToken.None)); + replyTo.Tell(new CenterScenarioCompleted(summary)); + } + + private static string SanitizeActorName(string value) => + new(value.Select(ch => char.IsLetterOrDigit(ch) ? char.ToLowerInvariant(ch) : '-').ToArray()); +} + +public sealed class AcceptanceActor : ReceiveActor +{ + public AcceptanceActor(IParcelEventStore events) + { + ReceiveAsync(async message => + { + var replyTo = Sender; + await events.AppendAsync(message.Parcel.TrackingId, "Accepted", "station=acceptance", CancellationToken.None); + replyTo.Tell(new ParcelAccepted(message.Parcel)); + }); + } +} + +public sealed class SortingActor : ReceiveActor +{ + public SortingActor(IParcelEventStore events) + { + ReceiveAsync(async message => + { + var replyTo = Sender; + var lane = message.Parcel.Direction == ParcelDirection.Export ? "export" : "import"; + var priority = message.Parcel.Product == PostalProduct.Ems; + await events.AppendAsync( + message.Parcel.TrackingId, + "Sorted", + $"lane={lane}; priority={(priority ? "ems" : "standard")}", + CancellationToken.None); + replyTo.Tell(new ParcelSorted(message.Parcel, lane, priority)); + }); + } +} + +public sealed class Cn22CustomsActor : ReceiveActor +{ + public Cn22CustomsActor(IParcelEventStore events, ICn22RiskEngine riskEngine) + { + ReceiveAsync(async message => + { + var replyTo = Sender; + var decision = riskEngine.Evaluate(message.Sorted.Parcel); + var eventType = decision.IsReleased ? "CustomsReleased" : "CustomsHeld"; + var detail = decision.IsReleased + ? $"cn22=release; document={decision.DocumentType}; ead={decision.ElectronicAdvanceDataId ?? "missing"}" + : $"cn22=hold; document={decision.DocumentType}; reasons={string.Join(",", decision.Reasons)}; ead={decision.ElectronicAdvanceDataId ?? "missing"}"; + await events.AppendAsync(message.Sorted.Parcel.TrackingId, eventType, detail, CancellationToken.None); + replyTo.Tell(new CustomsProcessed(message.Sorted, decision)); + }); + } +} + +public sealed class DispatchActor : ReceiveActor +{ + public DispatchActor(IParcelEventStore events) + { + ReceiveAsync(async message => + { + var replyTo = Sender; + var released = message.ProcessedParcels + .Where(parcel => parcel.Decision.IsReleased) + .Select(parcel => parcel.Sorted.Parcel.TrackingId) + .ToList(); + + foreach (var trackingId in released) + { + await events.AppendAsync(trackingId, "Dispatched", "manifest=MANIFEST-EXPORT-001", CancellationToken.None); + } + + replyTo.Tell(new DispatchManifest("MANIFEST-EXPORT-001", released)); + }); + } +} + +public sealed class ParcelStateActor : ReceiveActor +{ + private readonly string _trackingId; + private ParcelStatus _status = ParcelStatus.Created; + + public ParcelStateActor(string trackingId) + { + _trackingId = trackingId; + + Receive(message => + { + _status = message.Status; + Sender.Tell(new ParcelSnapshot(_trackingId, _status)); + }); + + Receive(_ => Sender.Tell(new ParcelSnapshot(_trackingId, _status))); + } +} diff --git a/PersonalSharp.PostalLogisticsCenter/Cn22CustomsProcessor.cs b/PersonalSharp.PostalLogisticsCenter/Cn22CustomsProcessor.cs new file mode 100644 index 0000000..42e99d3 --- /dev/null +++ b/PersonalSharp.PostalLogisticsCenter/Cn22CustomsProcessor.cs @@ -0,0 +1,350 @@ +namespace PersonalSharp.PostalLogisticsCenter; + +public interface ICn22RiskEngine +{ + CustomsDecision Evaluate(Parcel parcel); +} + +public sealed record Cn22ProcessingPolicy( + bool RequireElectronicAdvanceData = true, + decimal Cn22MaximumDeclaredValueSdr = 300m, + decimal WeightToleranceKg = 0.005m) +{ + public static Cn22ProcessingPolicy Default { get; } = new(); +} + +public sealed class Cn22RiskEngine : ICn22RiskEngine +{ + private readonly Cn22ProcessingPolicy _policy; + + private static readonly HashSet VagueDescriptions = new(StringComparer.OrdinalIgnoreCase) + { + "accessories", + "clothes", + "documents", + "electronics", + "gift", + "goods", + "items", + "merchandise", + "misc", + "parts", + "samples", + "stuff", + "tools" + }; + + public Cn22RiskEngine() + : this(Cn22ProcessingPolicy.Default) + { + } + + public Cn22RiskEngine(Cn22ProcessingPolicy policy) + { + _policy = policy; + } + + public CustomsDecision Evaluate(Parcel parcel) + { + var issues = new List(); + var declaration = parcel.Cn22; + var goodsBearing = IsGoodsBearing(declaration.Category); + + var documentType = declaration.DeclaredValueSdr > _policy.Cn22MaximumDeclaredValueSdr + ? CustomsDocumentType.Cn23Required + : CustomsDocumentType.Cn22; + + if (documentType == CustomsDocumentType.Cn23Required) + { + Hold( + issues, + CustomsRiskReason.Cn23RequiredByDeclaredValue, + "UPU-CN22-300-SDR", + "Cn22.DeclaredValueSdr", + "CN22 is not sufficient when the declared content value exceeds 300 SDR."); + } + + if (declaration.Category == Cn22Category.Other && + string.IsNullOrWhiteSpace(declaration.OtherCategoryDescription)) + { + Hold( + issues, + CustomsRiskReason.MissingCategoryDetail, + "UPU-CN22-EXPORT-REASON", + "Cn22.OtherCategoryDescription", + "The 'Other' CN22 category must state the reason for export."); + } + + ValidateContentLines(declaration, goodsBearing, issues); + ValidateTotals(parcel, declaration, goodsBearing, issues); + ValidateSignature(declaration, issues); + ValidateElectronicAdvanceData(declaration, goodsBearing, issues); + ValidateCommercialDocuments(declaration, issues); + + return CustomsDecision.FromIssues(documentType, issues, declaration.ElectronicAdvanceDataId); + } + + private static void ValidateContentLines( + Cn22Declaration declaration, + bool goodsBearing, + List issues) + { + if (declaration.ContentLines.Count == 0) + { + Hold( + issues, + CustomsRiskReason.MissingContentDescription, + "UPU-CN22-DESCRIPTION", + "Cn22.ContentLines", + "CN22 must describe each content item."); + return; + } + + foreach (var line in declaration.ContentLines.OrderBy(line => line.LineNumber)) + { + var prefix = $"Cn22.ContentLines[{line.LineNumber}]"; + if (goodsBearing && IsVagueDescription(line.ContentDescription)) + { + Hold( + issues, + CustomsRiskReason.VagueGoodsDescription, + "UPU-CN22-DESCRIPTION", + $"{prefix}.ContentDescription", + "Each CN22 content line needs a specific goods description, not a generic category."); + } + + if (line.Quantity <= 0) + { + Hold( + issues, + CustomsRiskReason.MissingQuantity, + "UPU-CN22-QUANTITY", + $"{prefix}.Quantity", + "Each CN22 content line needs a positive quantity."); + } + + if (line.NetWeightKg <= 0m) + { + Hold( + issues, + CustomsRiskReason.InvalidWeight, + "UPU-CN22-WEIGHT", + $"{prefix}.NetWeightKg", + "Each CN22 content line needs a positive net weight."); + } + + if (goodsBearing && line.Value <= 0m) + { + Hold( + issues, + CustomsRiskReason.InvalidDeclaredValue, + "UPU-CN22-VALUE", + $"{prefix}.Value", + "Goods-bearing CN22 content lines need a positive declared value."); + } + + if (!IsCurrencyCode(line.Currency)) + { + Hold( + issues, + CustomsRiskReason.MissingCurrency, + "UPU-CN22-VALUE", + $"{prefix}.Currency", + "CN22 value fields must include a three-letter currency code."); + } + + if (goodsBearing && string.IsNullOrWhiteSpace(line.CountryOfOrigin)) + { + Hold( + issues, + CustomsRiskReason.MissingCountryOfOrigin, + "WCO-UPU-EAD-ORIGIN", + $"{prefix}.CountryOfOrigin", + "Goods-bearing content lines need a country of origin."); + } + else if (!string.IsNullOrWhiteSpace(line.CountryOfOrigin) && + !IsIsoAlpha2CountryCode(line.CountryOfOrigin)) + { + Hold( + issues, + CustomsRiskReason.InvalidCountryOfOrigin, + "WCO-UPU-EAD-ORIGIN", + $"{prefix}.CountryOfOrigin", + "Country of origin must use an ISO 3166-1 alpha-2 code."); + } + + if (goodsBearing && string.IsNullOrWhiteSpace(line.TariffCode)) + { + Hold( + issues, + CustomsRiskReason.MissingTariffCode, + "WCO-UPU-EAD-HS-FORMAT", + $"{prefix}.TariffCode", + "Goods-bearing content lines need an HS tariff code for risk assessment."); + } + else if (!string.IsNullOrWhiteSpace(line.TariffCode) && !IsHsTariffCode(line.TariffCode)) + { + Hold( + issues, + CustomsRiskReason.InvalidTariffCode, + "WCO-UPU-EAD-HS-FORMAT", + $"{prefix}.TariffCode", + "HS tariff codes must contain 6, 8, or 10 digits without alphabetic prefixes."); + } + } + } + + private void ValidateTotals( + Parcel parcel, + Cn22Declaration declaration, + bool goodsBearing, + List issues) + { + if (!IsCurrencyCode(declaration.Currency)) + { + Hold( + issues, + CustomsRiskReason.MissingCurrency, + "UPU-CN22-VALUE", + "Cn22.Currency", + "The CN22 total value must include a three-letter currency code."); + } + + if (declaration.TotalWeightKg <= 0m || parcel.WeightKg <= 0m) + { + Hold( + issues, + CustomsRiskReason.InvalidWeight, + "UPU-CN22-WEIGHT", + "Cn22.TotalWeightKg", + "CN22 total gross weight and parcel weight must both be positive."); + } + + if (declaration.TotalWeightKg > parcel.WeightKg + _policy.WeightToleranceKg || + declaration.ContentNetWeightKg > declaration.TotalWeightKg + _policy.WeightToleranceKg) + { + Hold( + issues, + CustomsRiskReason.InvalidWeight, + "UPU-CN22-WEIGHT", + "Cn22.TotalWeightKg", + "CN22 net and gross weights must be consistent with the parcel gross weight."); + } + + if (goodsBearing && declaration.TotalValue <= 0m) + { + Hold( + issues, + CustomsRiskReason.InvalidDeclaredValue, + "UPU-CN22-VALUE", + "Cn22.TotalValue", + "Goods-bearing CN22 declarations need a positive total value."); + } + + if (declaration.ContentLines.Count > 0 && + Math.Abs(declaration.ContentValue - declaration.TotalValue) > 0.01m) + { + Hold( + issues, + CustomsRiskReason.DeclaredValueMismatch, + "UPU-CN22-VALUE", + "Cn22.TotalValue", + "CN22 total value must match the sum of declared content-line values."); + } + } + + private void ValidateElectronicAdvanceData( + Cn22Declaration declaration, + bool goodsBearing, + List issues) + { + if (_policy.RequireElectronicAdvanceData && + goodsBearing && + string.IsNullOrWhiteSpace(declaration.ElectronicAdvanceDataId)) + { + Hold( + issues, + CustomsRiskReason.MissingElectronicAdvanceData, + "UPU-CDS-EAD", + "Cn22.ElectronicAdvanceDataId", + "This hub policy requires electronic advance data for goods-bearing cross-border postal items."); + } + } + + private static void ValidateSignature(Cn22Declaration declaration, List issues) + { + if (string.IsNullOrWhiteSpace(declaration.SenderSignature) || + declaration.SignedAt is null || + !declaration.SenderCertifiedNoProhibitedOrDangerousGoods) + { + Hold( + issues, + CustomsRiskReason.MissingSenderSignature, + "UPU-CN22-SIGNATURE", + "Cn22.SenderSignature", + "CN22 needs the sender signature, date, and certification that the item is not prohibited or dangerous."); + } + } + + private static void ValidateCommercialDocuments(Cn22Declaration declaration, List issues) + { + if (!IsCommercial(declaration.Category)) + { + return; + } + + if (declaration.Documents.Any(document => document.Type == Cn22AttachedDocumentType.CommercialInvoice)) + { + return; + } + + issues.Add(new Cn22ValidationIssue( + CustomsRiskReason.CommercialInvoiceRecommended, + CustomsIssueSeverity.Warning, + "UPU-CN22-COMMERCIAL-INVOICE", + "Cn22.AttachedDocuments", + "Commercial items should attach an invoice when available to assist customs processing.")); + } + + private static bool IsGoodsBearing(Cn22Category category) => category != Cn22Category.Documents; + + private static bool IsCommercial(Cn22Category category) => + category is Cn22Category.SaleOfGoods or Cn22Category.CommercialSample; + + private static bool IsVagueDescription(string description) + { + var normalized = description.Trim(); + return normalized.Length == 0 || VagueDescriptions.Contains(normalized); + } + + private static bool IsHsTariffCode(string value) + { + var normalized = value.Trim(); + return normalized is { Length: 6 or 8 or 10 } && + normalized.All(char.IsDigit); + } + + private static bool IsCurrencyCode(string value) + { + var normalized = value.Trim(); + return normalized.Length == 3 && + normalized.All(ch => ch is >= 'A' and <= 'Z'); + } + + private static bool IsIsoAlpha2CountryCode(string value) + { + var normalized = value.Trim(); + return normalized.Length == 2 && + normalized.All(ch => ch is >= 'A' and <= 'Z'); + } + + private static void Hold( + List issues, + CustomsRiskReason reason, + string ruleId, + string field, + string message) + { + issues.Add(new Cn22ValidationIssue(reason, CustomsIssueSeverity.Hold, ruleId, field, message)); + } +} diff --git a/PersonalSharp.PostalLogisticsCenter/Domain.cs b/PersonalSharp.PostalLogisticsCenter/Domain.cs new file mode 100644 index 0000000..d953494 --- /dev/null +++ b/PersonalSharp.PostalLogisticsCenter/Domain.cs @@ -0,0 +1,286 @@ +namespace PersonalSharp.PostalLogisticsCenter; + +public enum ParcelDirection +{ + Export, + Import +} + +public enum PostalProduct +{ + Packet, + Ems +} + +public enum ParcelStatus +{ + Created, + Accepted, + Sorted, + CustomsReleased, + CustomsHeld, + Dispatched +} + +public enum Cn22Category +{ + Gift, + Documents, + SaleOfGoods, + CommercialSample, + ReturnedGoods, + Other +} + +public enum CustomsDecisionType +{ + Released, + Held +} + +public enum CustomsDocumentType +{ + Cn22, + Cn23Required +} + +public enum CustomsIssueSeverity +{ + Warning, + Hold +} + +public enum CustomsRiskReason +{ + MissingContentDescription, + MissingQuantity, + InvalidWeight, + InvalidDeclaredValue, + DeclaredValueMismatch, + MissingCurrency, + MissingCountryOfOrigin, + InvalidCountryOfOrigin, + MissingTariffCode, + InvalidTariffCode, + VagueGoodsDescription, + Cn23RequiredByDeclaredValue, + MissingElectronicAdvanceData, + MissingSenderSignature, + MissingCategoryDetail, + CommercialInvoiceRecommended +} + +public enum Cn22AttachedDocumentType +{ + CommercialInvoice, + CertificateOfOrigin, + ExportLicense, + ImportPermit, + SanitaryCertificate +} + +public sealed record Cn22ContentLine( + int LineNumber, + string ContentDescription, + int Quantity, + decimal NetWeightKg, + decimal Value, + string Currency, + string CountryOfOrigin, + string? TariffCode); + +public sealed record Cn22AttachedDocument(Cn22AttachedDocumentType Type, string ReferenceNumber); + +public sealed record Cn22Declaration( + Cn22Category Category, + IReadOnlyList ContentLines, + decimal TotalWeightKg, + decimal TotalValue, + string Currency, + decimal DeclaredValueSdr, + string? ElectronicAdvanceDataId, + string? SenderSignature, + DateOnly? SignedAt, + bool SenderCertifiedNoProhibitedOrDangerousGoods, + string? OtherCategoryDescription = null, + IReadOnlyList? AttachedDocuments = null) +{ + public decimal ContentValue => ContentLines.Sum(line => line.Value); + + public decimal ContentNetWeightKg => ContentLines.Sum(line => line.NetWeightKg); + + public IReadOnlyList Documents => AttachedDocuments ?? []; +} + +public sealed record Parcel( + string TrackingId, + ParcelDirection Direction, + PostalProduct Product, + string OriginCountry, + string DestinationCountry, + decimal WeightKg, + Cn22Declaration Cn22); + +public sealed record Cn22ValidationIssue( + CustomsRiskReason Reason, + CustomsIssueSeverity Severity, + string RuleId, + string Field, + string Message); + +public sealed record CustomsDecision( + CustomsDecisionType Type, + CustomsDocumentType DocumentType, + IReadOnlyList Reasons, + IReadOnlyList Issues, + string? ElectronicAdvanceDataId) +{ + public bool IsReleased => Type == CustomsDecisionType.Released; + + public static CustomsDecision FromIssues( + CustomsDocumentType documentType, + IReadOnlyList issues, + string? electronicAdvanceDataId) + { + var blockingReasons = issues + .Where(issue => issue.Severity == CustomsIssueSeverity.Hold) + .Select(issue => issue.Reason) + .Distinct() + .ToList(); + + var decisionType = blockingReasons.Count == 0 + ? CustomsDecisionType.Released + : CustomsDecisionType.Held; + + return new CustomsDecision( + decisionType, + documentType, + blockingReasons, + issues, + electronicAdvanceDataId); + } +} + +public sealed record ParcelLifecycleEvent( + string Id, + string TrackingId, + DateTimeOffset OccurredAt, + string Type, + string Detail); + +public sealed record DispatchManifest(string ManifestId, IReadOnlyList TrackingIds); + +public sealed record ParcelRunResult( + string TrackingId, + PostalProduct Product, + ParcelDirection Direction, + string Lane, + bool Priority, + ParcelStatus FinalStatus, + CustomsDecision Decision); + +public sealed record CenterRunSummary( + IReadOnlyList Parcels, + DispatchManifest Manifest, + int EventDocumentCount) +{ + public int AcceptedCount => Parcels.Count; + + public int SortedCount => Parcels.Count(parcel => parcel.Lane.Length > 0); + + public int CustomsReleasedCount => Parcels.Count(parcel => parcel.Decision.IsReleased); + + public int CustomsHeldCount => Parcels.Count(parcel => !parcel.Decision.IsReleased); + + public int DispatchedCount => Manifest.TrackingIds.Count; + + public string ToConsoleSummary() + { + var released = Parcels + .Where(parcel => parcel.Decision.IsReleased) + .Select(parcel => $"{parcel.TrackingId}:{parcel.Decision.DocumentType};ead={parcel.Decision.ElectronicAdvanceDataId ?? "missing"}"); + var held = Parcels + .Where(parcel => !parcel.Decision.IsReleased) + .Select(parcel => $"{parcel.TrackingId}:{string.Join(",", parcel.Decision.Reasons)}"); + + return string.Join( + Environment.NewLine, + "Postal Logistics Center", + $"accepted={AcceptedCount}; sorted={SortedCount}; customsReleased={CustomsReleasedCount}; customsHeld={CustomsHeldCount}; dispatched={DispatchedCount}; cosmosEvents={EventDocumentCount}", + $"manifest={Manifest.ManifestId}; parcels={string.Join(",", Manifest.TrackingIds)}", + $"release={string.Join(" | ", released)}", + $"hold={string.Join(" | ", held)}"); + } +} + +public static class DemoParcels +{ + public static IReadOnlyList Create() => + [ + EuExportPacket(), + UsImportEms() + ]; + + public static Parcel EuExportPacket() => + new( + TrackingId: "EU123456789DE", + Direction: ParcelDirection.Export, + Product: PostalProduct.Packet, + OriginCountry: "DE", + DestinationCountry: "CA", + WeightKg: 0.42m, + Cn22: new Cn22Declaration( + Category: Cn22Category.SaleOfGoods, + ContentLines: + [ + new Cn22ContentLine( + LineNumber: 1, + ContentDescription: "stainless steel coffee filters for home coffee maker", + Quantity: 2, + NetWeightKg: 0.38m, + Value: 28.50m, + Currency: "EUR", + CountryOfOrigin: "DE", + TariffCode: "732393") + ], + TotalWeightKg: 0.42m, + TotalValue: 28.50m, + Currency: "EUR", + DeclaredValueSdr: 24.70m, + ElectronicAdvanceDataId: "ITMATT-DE-20260513-0001", + SenderSignature: "M. Schmidt", + SignedAt: new DateOnly(2026, 5, 13), + SenderCertifiedNoProhibitedOrDangerousGoods: true, + AttachedDocuments: [new Cn22AttachedDocument(Cn22AttachedDocumentType.CommercialInvoice, "INV-DE-2026-0001")])); + + public static Parcel UsImportEms() => + new( + TrackingId: "US987654321US", + Direction: ParcelDirection.Import, + Product: PostalProduct.Ems, + OriginCountry: "US", + DestinationCountry: "FR", + WeightKg: 0.65m, + Cn22: new Cn22Declaration( + Category: Cn22Category.SaleOfGoods, + ContentLines: + [ + new Cn22ContentLine( + LineNumber: 1, + ContentDescription: "parts", + Quantity: 1, + NetWeightKg: 0.58m, + Value: 120.00m, + Currency: "USD", + CountryOfOrigin: "US", + TariffCode: null) + ], + TotalWeightKg: 0.65m, + TotalValue: 120.00m, + Currency: "USD", + DeclaredValueSdr: 104.00m, + ElectronicAdvanceDataId: null, + SenderSignature: "J. Rivera", + SignedAt: new DateOnly(2026, 5, 13), + SenderCertifiedNoProhibitedOrDangerousGoods: true)); +} diff --git a/PersonalSharp.PostalLogisticsCenter/EventStores.cs b/PersonalSharp.PostalLogisticsCenter/EventStores.cs new file mode 100644 index 0000000..8f3b0d4 --- /dev/null +++ b/PersonalSharp.PostalLogisticsCenter/EventStores.cs @@ -0,0 +1,197 @@ +using System.Net; +using Microsoft.Azure.Cosmos; +using Newtonsoft.Json; + +namespace PersonalSharp.PostalLogisticsCenter; + +public interface IParcelEventStore +{ + Task AppendAsync(string trackingId, string eventType, string detail, CancellationToken cancellationToken); + + Task> ReadAsync(string trackingId, CancellationToken cancellationToken); + + Task CountAsync(CancellationToken cancellationToken); +} + +public sealed class InMemoryParcelEventStore : IParcelEventStore +{ + private readonly object _gate = new(); + private readonly Dictionary> _events = []; + private int _sequence; + + public Task AppendAsync(string trackingId, string eventType, string detail, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + lock (_gate) + { + var next = NextEvent(trackingId, eventType, detail); + if (!_events.TryGetValue(trackingId, out var stream)) + { + stream = []; + _events[trackingId] = stream; + } + + stream.Add(next); + } + + return Task.CompletedTask; + } + + public Task> ReadAsync(string trackingId, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + lock (_gate) + { + return Task.FromResult>( + _events.TryGetValue(trackingId, out var stream) ? stream.ToList() : []); + } + } + + public Task CountAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + lock (_gate) + { + return Task.FromResult(_events.Values.Sum(stream => stream.Count)); + } + } + + private ParcelLifecycleEvent NextEvent(string trackingId, string eventType, string detail) + { + var sequence = ++_sequence; + return new ParcelLifecycleEvent( + Id: $"{trackingId}-{sequence:000000}", + TrackingId: trackingId, + OccurredAt: new DateTimeOffset(2026, 5, 13, 12, 0, 0, TimeSpan.Zero).AddSeconds(sequence), + Type: eventType, + Detail: detail); + } +} + +public sealed class CosmosParcelEventStore : IParcelEventStore, IDisposable +{ + private readonly CosmosClient? _client; + private readonly Container _container; + private int _appendedCount; + private int _sequence; + + public CosmosParcelEventStore(Container container) + { + _container = container; + } + + private CosmosParcelEventStore(CosmosClient client, Container container) + { + _client = client; + _container = container; + } + + public static async Task CreateFromEnvironmentAsync( + Func environment, + CancellationToken cancellationToken) + { + var connectionString = RequiredEnvironment(environment, "COSMOS_CONNECTION_STRING"); + var databaseName = OptionalEnvironment(environment, "COSMOS_DATABASE", "personalsharp"); + var containerName = OptionalEnvironment(environment, "COSMOS_CONTAINER", "postal-logistics-events"); + var allowInsecureCert = string.Equals( + OptionalEnvironment(environment, "COSMOS_ALLOW_INSECURE_EMULATOR_CERT", "false"), + "true", + StringComparison.OrdinalIgnoreCase); + + var options = new CosmosClientOptions { ConnectionMode = ConnectionMode.Gateway }; + if (allowInsecureCert) + { + options.HttpClientFactory = () => + { + var handler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator + }; + return new HttpClient(handler); + }; + } + + var client = new CosmosClient(connectionString, options); + var database = await client.CreateDatabaseIfNotExistsAsync( + databaseName, + throughput: 400, + cancellationToken: cancellationToken); + var container = await database.Database.CreateContainerIfNotExistsAsync( + containerName, + "/partitionKey", + cancellationToken: cancellationToken); + + return new CosmosParcelEventStore(client, container.Container); + } + + public async Task AppendAsync(string trackingId, string eventType, string detail, CancellationToken cancellationToken) + { + var sequence = Interlocked.Increment(ref _sequence); + var item = new ParcelLifecycleEventDocument( + Id: $"{trackingId}-{sequence:000000}", + PartitionKey: trackingId, + TrackingId: trackingId, + OccurredAt: new DateTimeOffset(2026, 5, 13, 12, 0, 0, TimeSpan.Zero).AddSeconds(sequence), + Type: eventType, + Detail: detail); + + await _container.UpsertItemAsync(item, new PartitionKey(trackingId), cancellationToken: cancellationToken); + Interlocked.Increment(ref _appendedCount); + } + + public async Task> ReadAsync(string trackingId, CancellationToken cancellationToken) + { + var query = new QueryDefinition("select * from c where c.partitionKey = @partitionKey order by c.id") + .WithParameter("@partitionKey", trackingId); + using var iterator = _container.GetItemQueryIterator( + query, + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey(trackingId) }); + + var events = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(cancellationToken); + events.AddRange(page.Select(item => new ParcelLifecycleEvent( + item.Id, + item.TrackingId, + item.OccurredAt, + item.Type, + item.Detail))); + } + + return events; + } + + public Task CountAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(_appendedCount); + } + + public void Dispose() => _client?.Dispose(); + + private static string RequiredEnvironment(Func environment, string name) + { + var value = environment(name); + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidOperationException($"{name} must be set for real Cosmos postal logistics mode."); + } + + return value; + } + + private static string OptionalEnvironment(Func environment, string name, string defaultValue) + { + var value = environment(name); + return string.IsNullOrWhiteSpace(value) ? defaultValue : value; + } + + private sealed record ParcelLifecycleEventDocument( + [property: JsonProperty("id")] string Id, + [property: JsonProperty("partitionKey")] string PartitionKey, + [property: JsonProperty("trackingId")] string TrackingId, + [property: JsonProperty("occurredAt")] DateTimeOffset OccurredAt, + [property: JsonProperty("type")] string Type, + [property: JsonProperty("detail")] string Detail); +} diff --git a/PersonalSharp.PostalLogisticsCenter/PersonalSharp.PostalLogisticsCenter.csproj b/PersonalSharp.PostalLogisticsCenter/PersonalSharp.PostalLogisticsCenter.csproj new file mode 100644 index 0000000..4a440c7 --- /dev/null +++ b/PersonalSharp.PostalLogisticsCenter/PersonalSharp.PostalLogisticsCenter.csproj @@ -0,0 +1,16 @@ + + + + + + + + + + Exe + net10.0 + enable + enable + + + diff --git a/PersonalSharp.PostalLogisticsCenter/PostalLogisticsCenterApp.cs b/PersonalSharp.PostalLogisticsCenter/PostalLogisticsCenterApp.cs new file mode 100644 index 0000000..d6c68b2 --- /dev/null +++ b/PersonalSharp.PostalLogisticsCenter/PostalLogisticsCenterApp.cs @@ -0,0 +1,82 @@ +using Akka.Actor; +using Akka.Configuration; + +namespace PersonalSharp.PostalLogisticsCenter; + +public sealed record PostalLogisticsCenterOptions(bool UseCosmos) +{ + public static PostalLogisticsCenterOptions From(string[] args, Func environment) + { + var useCosmos = args.Any(arg => + string.Equals(arg, "--cosmos", StringComparison.OrdinalIgnoreCase) || + string.Equals(arg, "--store=cosmos", StringComparison.OrdinalIgnoreCase)); + + useCosmos = useCosmos || string.Equals( + environment("POSTAL_LOGISTICS_USE_COSMOS"), + "true", + StringComparison.OrdinalIgnoreCase); + + return new PostalLogisticsCenterOptions(useCosmos); + } +} + +public static class PostalLogisticsCenterApp +{ + public static async Task RunAsync( + PostalLogisticsCenterOptions options, + TextWriter output, + CancellationToken cancellationToken) + { + IParcelEventStore eventStore = options.UseCosmos + ? await CosmosParcelEventStore.CreateFromEnvironmentAsync(Environment.GetEnvironmentVariable, cancellationToken) + : new InMemoryParcelEventStore(); + + try + { + var summary = await RunScenarioAsync(eventStore, cancellationToken); + await output.WriteLineAsync(summary.ToConsoleSummary()); + return 0; + } + finally + { + if (eventStore is IDisposable disposable) + { + disposable.Dispose(); + } + } + } + + public static async Task RunScenarioAsync( + IParcelEventStore eventStore, + CancellationToken cancellationToken) => + await RunScenarioAsync(eventStore, DemoParcels.Create(), cancellationToken); + + public static async Task RunScenarioAsync( + IParcelEventStore eventStore, + IReadOnlyList parcels, + CancellationToken cancellationToken) + { + using var system = ActorSystem.Create( + "personalsharp-postal-logistics-center", + ConfigurationFactory.ParseString(""" + akka.loglevel = WARNING + akka.stdout-loglevel = WARNING + """)); + try + { + var supervisor = system.ActorOf( + CenterSupervisor.Props(eventStore, new Cn22RiskEngine()), + "center-supervisor"); + var completed = await supervisor.Ask( + new RunCenterScenario(parcels), + TimeSpan.FromSeconds(10), + cancellationToken); + + return completed.Summary; + } + finally + { + await system.Terminate(); + } + } +} diff --git a/PersonalSharp.PostalLogisticsCenter/Program.cs b/PersonalSharp.PostalLogisticsCenter/Program.cs new file mode 100644 index 0000000..e88956a --- /dev/null +++ b/PersonalSharp.PostalLogisticsCenter/Program.cs @@ -0,0 +1,10 @@ +namespace PersonalSharp.PostalLogisticsCenter; + +public static class Program +{ + public static Task Main(string[] args) => + PostalLogisticsCenterApp.RunAsync( + PostalLogisticsCenterOptions.From(args, Environment.GetEnvironmentVariable), + Console.Out, + CancellationToken.None); +} diff --git a/PersonalSharp.Tests/PersonalSharp.Tests.csproj b/PersonalSharp.Tests/PersonalSharp.Tests.csproj index 8f0848d..a66b9cf 100644 --- a/PersonalSharp.Tests/PersonalSharp.Tests.csproj +++ b/PersonalSharp.Tests/PersonalSharp.Tests.csproj @@ -20,7 +20,8 @@ + - \ No newline at end of file + diff --git a/PersonalSharp.Tests/PostalLogisticsCenter/PostalLogisticsCenterTests.cs b/PersonalSharp.Tests/PostalLogisticsCenter/PostalLogisticsCenterTests.cs new file mode 100644 index 0000000..e4e9053 --- /dev/null +++ b/PersonalSharp.Tests/PostalLogisticsCenter/PostalLogisticsCenterTests.cs @@ -0,0 +1,144 @@ +using System.Globalization; +using PersonalSharp.PostalLogisticsCenter; + +namespace PersonalSharp.Tests.PostalLogisticsCenter; + +public sealed class PostalLogisticsCenterTests +{ + [Fact] + public void Cn22RiskEngine_ShouldReleaseClearExportPacket() + { + var decision = new Cn22RiskEngine().Evaluate(DemoParcels.EuExportPacket()); + + Assert.True(decision.IsReleased); + Assert.Equal(CustomsDocumentType.Cn22, decision.DocumentType); + Assert.Equal("ITMATT-DE-20260513-0001", decision.ElectronicAdvanceDataId); + Assert.Empty(decision.Reasons); + Assert.Empty(decision.Issues); + } + + [Fact] + public void Cn22RiskEngine_ShouldHoldVagueGoodsWithoutTariffCodeOrEad() + { + var decision = new Cn22RiskEngine().Evaluate(DemoParcels.UsImportEms()); + + Assert.False(decision.IsReleased); + Assert.Equal( + [ + CustomsRiskReason.VagueGoodsDescription, + CustomsRiskReason.MissingTariffCode, + CustomsRiskReason.MissingElectronicAdvanceData + ], + decision.Reasons); + Assert.Contains( + decision.Issues, + issue => issue is { Severity: CustomsIssueSeverity.Warning, Reason: CustomsRiskReason.CommercialInvoiceRecommended }); + } + + [Fact] + public void Cn22RiskEngine_ShouldAllowSpecificTwoWordGoodsDescriptions() + { + var parcel = DemoParcels.EuExportPacket(); + var line = parcel.Cn22.ContentLines[0] with + { + ContentDescription = "power drill", + TariffCode = "846721" + }; + var specificParcel = parcel with + { + Cn22 = parcel.Cn22 with { ContentLines = [line] } + }; + + var decision = new Cn22RiskEngine().Evaluate(specificParcel); + + Assert.True(decision.IsReleased); + Assert.Empty(decision.Reasons); + } + + [Fact] + public void Cn22RiskEngine_ShouldRejectHsCodesThatAreNotSixEightOrTenDigits() + { + var parcel = DemoParcels.EuExportPacket(); + var line = parcel.Cn22.ContentLines[0] with { TariffCode = "7323931" }; + var invalidHsParcel = parcel with + { + Cn22 = parcel.Cn22 with { ContentLines = [line] } + }; + + var decision = new Cn22RiskEngine().Evaluate(invalidHsParcel); + + Assert.False(decision.IsReleased); + Assert.Equal([CustomsRiskReason.InvalidTariffCode], decision.Reasons); + var issue = Assert.Single(decision.Issues); + Assert.Equal("WCO-UPU-EAD-HS-FORMAT", issue.RuleId); + } + + [Fact] + public void Cn22RiskEngine_ShouldRequireCn23WhenDeclaredValueExceedsThreeHundredSdr() + { + var parcel = DemoParcels.EuExportPacket(); + var highValueParcel = parcel with + { + Cn22 = parcel.Cn22 with { DeclaredValueSdr = 301m } + }; + + var decision = new Cn22RiskEngine().Evaluate(highValueParcel); + + Assert.False(decision.IsReleased); + Assert.Equal(CustomsDocumentType.Cn23Required, decision.DocumentType); + Assert.Equal([CustomsRiskReason.Cn23RequiredByDeclaredValue], decision.Reasons); + } + + [Fact] + public void Cn22RiskEngine_ShouldHoldWhenLineValuesDoNotMatchDeclarationTotal() + { + var parcel = DemoParcels.EuExportPacket(); + var mismatchedParcel = parcel with + { + Cn22 = parcel.Cn22 with { TotalValue = 30.00m } + }; + + var decision = new Cn22RiskEngine().Evaluate(mismatchedParcel); + + Assert.False(decision.IsReleased); + Assert.Equal([CustomsRiskReason.DeclaredValueMismatch], decision.Reasons); + } + + [Fact] + public async Task Scenario_ShouldPreserveEmsPriorityAndRecordLifecycleEvents() + { + var store = new InMemoryParcelEventStore(); + + var summary = await PostalLogisticsCenterApp.RunScenarioAsync(store, CancellationToken.None); + var ems = Assert.Single(summary.Parcels, parcel => parcel.Product == PostalProduct.Ems); + var released = Assert.Single(summary.Parcels, parcel => parcel.Decision.IsReleased); + + Assert.Equal("import", ems.Lane); + Assert.True(ems.Priority); + Assert.Equal(ParcelStatus.CustomsHeld, ems.FinalStatus); + Assert.Equal(ParcelStatus.Dispatched, released.FinalStatus); + Assert.Equal(7, await store.CountAsync(CancellationToken.None)); + Assert.Equal(7, summary.EventDocumentCount); + } + + [Fact] + public async Task RunAsync_ShouldPrintDeterministicLifecycleSummary() + { + var output = new StringWriter(CultureInfo.InvariantCulture); + + var exitCode = await PostalLogisticsCenterApp.RunAsync( + new PostalLogisticsCenterOptions(UseCosmos: false), + output, + CancellationToken.None); + + Assert.Equal(0, exitCode); + Assert.Contains( + "accepted=2; sorted=2; customsReleased=1; customsHeld=1; dispatched=1; cosmosEvents=7", + output.ToString()); + Assert.Contains("manifest=MANIFEST-EXPORT-001; parcels=EU123456789DE", output.ToString()); + Assert.Contains("release=EU123456789DE:Cn22;ead=ITMATT-DE-20260513-0001", output.ToString()); + Assert.Contains( + "hold=US987654321US:VagueGoodsDescription,MissingTariffCode,MissingElectronicAdvanceData", + output.ToString()); + } +} diff --git a/PersonalSharp.Tests/Topics/ExamplesSnippetTests.cs b/PersonalSharp.Tests/Topics/ExamplesSnippetTests.cs new file mode 100644 index 0000000..2ab48bb --- /dev/null +++ b/PersonalSharp.Tests/Topics/ExamplesSnippetTests.cs @@ -0,0 +1,25 @@ +using PersonalSharp.Topics; + +namespace PersonalSharp.Tests.Topics; + +public sealed class ExamplesSnippetTests +{ + [Theory] + [InlineData("example.acumatica-wms-pick-pack-ship", "picked=2; packed=2; shipment=confirmed; adapterCalls=1")] + [InlineData("example.acumatica-wms-receiving", "received=2; rejected=1; receipt=released; adapterCalls=1")] + [InlineData("example.acumatica-wms-transfer", "moved=3; from=A1-01; to=B2-04; adjustment=posted")] + [InlineData("example.acumatica-order-to-cash", "order=completed; shipment=confirmed; invoice=released; payment=applied")] + [InlineData("example.acumatica-inventory-allocation", "allocated=6; shipped=4; backorder=2; version=2")] + [InlineData("example.acumatica-cycle-count", "variance=-2; approved=True; adjustment=posted; audit=1")] + [InlineData("example.acumatica-purchase-to-pay", "po=received; bill=released; payment=paid")] + [InlineData("example.acumatica-rest-adapter", "endpoint=/entity/Default/24.200.001/SalesOrder; method=PUT; lines=1")] + [InlineData("example.acumatica-idempotent-sync", "first=True; duplicate=False; handled=1; outbox=1")] + public void ExampleSnippet_ShouldReturnExpectedOutput(string key, string expected) + { + var snippet = SnippetCatalog.Find(key); + + Assert.NotNull(snippet); + Assert.Equal(expected, snippet.Run()); + Assert.Equal(expected, snippet.ExpectedOutput); + } +} diff --git a/PersonalSharp.Tests/Topics/SnippetCatalogTests.cs b/PersonalSharp.Tests/Topics/SnippetCatalogTests.cs index 8f18443..0edc96c 100644 --- a/PersonalSharp.Tests/Topics/SnippetCatalogTests.cs +++ b/PersonalSharp.Tests/Topics/SnippetCatalogTests.cs @@ -1,4 +1,5 @@ using PersonalSharp.Topics; +using PersonalSharp.Topics.Abstractions; namespace PersonalSharp.Tests.Topics; @@ -7,6 +8,9 @@ public sealed class SnippetCatalogTests [Fact] public void Catalog_ShouldExposeBroadTopicSet() { + Assert.Equal("Examples", SnippetCatalog.Modules[0].Name); + Assert.Equal("example.acumatica-wms-pick-pack-ship", SnippetCatalog.Modules[0].Snippets[0].Key); + Assert.Contains(SnippetCatalog.Modules, module => module.Name == "Examples"); Assert.Contains(SnippetCatalog.Modules, module => module.Name == "Language core"); Assert.Contains(SnippetCatalog.Modules, module => module.Name == "LINQ and collections"); Assert.Contains(SnippetCatalog.Modules, module => module.Name == "Programming paradigms"); @@ -23,7 +27,22 @@ public void Catalog_ShouldExposeBroadTopicSet() Assert.Contains(SnippetCatalog.Modules, module => module.Name == "C# traps"); Assert.Contains(SnippetCatalog.Modules, module => module.Name == "Backend and architecture"); Assert.Contains(SnippetCatalog.Modules, module => module.Name == "Backend patterns"); - Assert.True(SnippetCatalog.AllSnippets.Count >= 95); + Assert.True(SnippetCatalog.AllSnippets.Count >= 104); + } + + [Fact] + public void Catalog_ShouldExposeExecutableSources() + { + Assert.DoesNotContain( + typeof(SnippetCase).GetProperties(), + property => property.Name == "Code"); + + Assert.All(SnippetCatalog.AllSnippets, snippet => + { + Assert.False(string.IsNullOrWhiteSpace(snippet.Source.TypeName)); + Assert.False(string.IsNullOrWhiteSpace(snippet.Source.MemberName)); + Assert.DoesNotContain("<", snippet.Source.MemberName); + }); } [Theory] diff --git a/PersonalSharp.Tests/UnsafeLab/UnsafeSnippetTests.cs b/PersonalSharp.Tests/UnsafeLab/UnsafeSnippetTests.cs index 75aa24b..4af7991 100644 --- a/PersonalSharp.Tests/UnsafeLab/UnsafeSnippetTests.cs +++ b/PersonalSharp.Tests/UnsafeLab/UnsafeSnippetTests.cs @@ -16,4 +16,19 @@ public void UnsafeSnippet_ShouldReturnExpectedOutput(string key, string expected Assert.Equal(expected, snippet.Run()); Assert.Equal(expected, snippet.ExpectedOutput); } + + [Fact] + public void UnsafeCatalog_ShouldExposeExecutableSources() + { + Assert.DoesNotContain( + typeof(UnsafeSnippetCase).GetProperties(), + property => property.Name == "Code"); + + Assert.All(UnsafeSnippetCatalog.All, snippet => + { + Assert.False(string.IsNullOrWhiteSpace(snippet.Source.TypeName)); + Assert.False(string.IsNullOrWhiteSpace(snippet.Source.MemberName)); + Assert.DoesNotContain("<", snippet.Source.MemberName); + }); + } } diff --git a/PersonalSharp.Topics/Abstractions/SnippetCase.cs b/PersonalSharp.Topics/Abstractions/SnippetCase.cs index 56bd4da..9ba293d 100644 --- a/PersonalSharp.Topics/Abstractions/SnippetCase.cs +++ b/PersonalSharp.Topics/Abstractions/SnippetCase.cs @@ -6,9 +6,9 @@ public sealed record SnippetCase public required string Title { get; init; } public required string Scope { get; init; } public required string Question { get; init; } - public required string Code { get; init; } public required string ExpectedOutput { get; init; } public required string Explanation { get; init; } public required Func Run { get; init; } + public SnippetSource Source => SnippetSource.From(Run); public IReadOnlyList Takeaways { get; init; } = []; } diff --git a/PersonalSharp.Topics/Abstractions/SnippetSource.cs b/PersonalSharp.Topics/Abstractions/SnippetSource.cs new file mode 100644 index 0000000..eede54b --- /dev/null +++ b/PersonalSharp.Topics/Abstractions/SnippetSource.cs @@ -0,0 +1,15 @@ +using System.Reflection; + +namespace PersonalSharp.Topics.Abstractions; + +public sealed record SnippetSource(string TypeName, string MemberName) +{ + public static SnippetSource From(Delegate callback) + { + var method = callback.GetMethodInfo(); + var typeName = method.DeclaringType?.FullName ?? ""; + return new SnippetSource(typeName, method.Name); + } + + public override string ToString() => $"{TypeName}.{MemberName}()"; +} diff --git a/PersonalSharp.Topics/Modules/AsyncConcurrencyModule.cs b/PersonalSharp.Topics/Modules/AsyncConcurrencyModule.cs index f5daed5..2510cbb 100644 --- a/PersonalSharp.Topics/Modules/AsyncConcurrencyModule.cs +++ b/PersonalSharp.Topics/Modules/AsyncConcurrencyModule.cs @@ -16,14 +16,6 @@ public sealed class AsyncConcurrencyModule : ISnippetModule Title = "Task.WhenAll exception behavior", Scope = "Detail", Question = "When multiple tasks fail, what exception does await Task.WhenAll throw?", - Code = """ - var tasks = new[] - { - Task.FromException(new InvalidOperationException("first")), - Task.FromException(new ApplicationException("second")) - }; - await Task.WhenAll(tasks); - """, ExpectedOutput = "await throws=InvalidOperationException; all=InvalidOperationException,ApplicationException", Explanation = "Await rethrows one exception, but every failed task still keeps its own exception. Inspect the tasks or the aggregate task for the full set.", Takeaways = ["Do not assume await exposes every failure directly.", "Collect task exceptions when all failures matter."], @@ -35,11 +27,6 @@ public sealed class AsyncConcurrencyModule : ISnippetModule Title = "Cancellation is cooperative", Scope = "Applied", Question = "What does CancellationToken actually do?", - Code = """ - using var cts = new CancellationTokenSource(); - cts.Cancel(); - token.ThrowIfCancellationRequested(); - """, ExpectedOutput = "canceled=True", Explanation = "A token does not stop code by itself. Code must observe the token and exit, throw, or pass the token to APIs that observe it.", Takeaways = ["Cancellation is not Thread.Abort.", "Propagate tokens through async call chains."], @@ -51,12 +38,6 @@ public sealed class AsyncConcurrencyModule : ISnippetModule Title = "Lost update race condition", Scope = "Detail", Question = "Why is counter++ not safe across threads?", - Code = """ - var readA = counter; - var readB = counter; - counter = readA + 1; - counter = readB + 1; - """, ExpectedOutput = "bug=1; fix=2", Explanation = "Increment is read-modify-write. Two actors can read the same value and write the same incremented result. Interlocked makes the operation atomic.", Takeaways = ["Thread-safe means preserving invariants under interleaving.", "Prefer immutable data, channels, locks, or Interlocked depending on the state shape."], @@ -64,7 +45,7 @@ public sealed class AsyncConcurrencyModule : ISnippetModule } ]; - private static string RunWhenAllExceptions() + public static string RunWhenAllExceptions() { var tasks = new[] { @@ -87,7 +68,7 @@ private static string RunWhenAllExceptions() } } - private static string RunCancellationToken() + public static string RunCancellationToken() { using var cts = new CancellationTokenSource(); cts.Cancel(); @@ -103,7 +84,7 @@ private static string RunCancellationToken() } } - private static string RunLostUpdate() + public static string RunLostUpdate() { return $"bug={BugHuntCases.LostUpdateBug()}; fix={BugHuntCases.LostUpdateFix()}"; } diff --git a/PersonalSharp.Topics/Modules/BackendArchitectureModule.cs b/PersonalSharp.Topics/Modules/BackendArchitectureModule.cs index cd39366..8688617 100644 --- a/PersonalSharp.Topics/Modules/BackendArchitectureModule.cs +++ b/PersonalSharp.Topics/Modules/BackendArchitectureModule.cs @@ -15,10 +15,6 @@ public sealed class BackendArchitectureModule : ISnippetModule Title = "DTO boundary vs domain model", Scope = "Applied", Question = "Why should API contracts often differ from domain entities?", - Code = """ - var command = new CreateOrderRequest("Ada", 2); - var order = Order.Create(command.Customer, command.Quantity); - """, ExpectedOutput = "order=Accepted; quantity=2", Explanation = "DTOs model transport shape. Domain objects protect business invariants. Keeping them separate avoids binding public contracts to internal behavior.", Takeaways = ["Map at boundaries.", "Let the domain own invariants, not controllers."], @@ -30,11 +26,6 @@ public sealed class BackendArchitectureModule : ISnippetModule Title = "Outbox idempotency sketch", Scope = "Edge", Question = "How do you avoid duplicate side effects when retrying event processing?", - Code = """ - var processed = new HashSet(); - bool first = processed.Add(messageId); - bool retry = processed.Add(messageId); - """, ExpectedOutput = "first=True; retry=False", Explanation = "Retries are normal in distributed systems. Handlers need an idempotency key or durable processed marker so repeated delivery does not repeat the side effect.", Takeaways = ["At-least-once delivery requires idempotent consumers.", "A database unique constraint is often the simplest guard."], @@ -46,9 +37,6 @@ public sealed class BackendArchitectureModule : ISnippetModule Title = "Stable pagination", Scope = "Detail", Question = "Why is pagination without deterministic ordering a production bug?", - Code = """ - var page = rows.OrderBy(x => x.CreatedAt).ThenBy(x => x.Id).Skip(2).Take(2); - """, ExpectedOutput = "page=C,D", Explanation = "Offset pagination must use stable ordering, otherwise inserts or ties can duplicate or skip rows between requests.", Takeaways = ["Always define an order before Skip/Take.", "Keyset pagination is better for large changing datasets."], @@ -56,14 +44,14 @@ public sealed class BackendArchitectureModule : ISnippetModule } ]; - private static string RunDtoBoundary() + public static string RunDtoBoundary() { var command = new CreateOrderRequest("Ada", 2); var order = Order.Create(command.Customer, command.Quantity); return $"order={order.Status}; quantity={order.Quantity}"; } - private static string RunOutboxIdempotency() + public static string RunOutboxIdempotency() { var messageId = Guid.Parse("11111111-1111-1111-1111-111111111111"); var processed = new HashSet(); @@ -72,7 +60,7 @@ private static string RunOutboxIdempotency() return $"first={first}; retry={retry}"; } - private static string RunStablePagination() + public static string RunStablePagination() { var rows = new[] { diff --git a/PersonalSharp.Topics/Modules/BackendPatternsModule.cs b/PersonalSharp.Topics/Modules/BackendPatternsModule.cs index 0aa627f..3102b7f 100644 --- a/PersonalSharp.Topics/Modules/BackendPatternsModule.cs +++ b/PersonalSharp.Topics/Modules/BackendPatternsModule.cs @@ -15,10 +15,6 @@ public sealed class BackendPatternsModule : ISnippetModule Title = "Repository boundary", Scope = "Applied", Question = "What should a repository boundary hide?", - Code = """ - repository.Add(Order.Accepted(1)); - var found = repository.Get(1); - """, ExpectedOutput = "found=Accepted", Explanation = "A repository hides persistence mechanics and exposes collection-like operations for aggregate roots.", Takeaways = ["Do not leak IQueryable from domain repositories by default.", "Keep query-heavy read models separate when needed."], @@ -30,12 +26,6 @@ public sealed class BackendPatternsModule : ISnippetModule Title = "Specification", Scope = "Detail", Question = "How do you make a business rule reusable?", - Code = """ - var spec = new AndSpecification( - new PaidOrderSpecification(), - new MinimumTotalSpecification(100)); - var matches = orders.Count(spec.IsSatisfiedBy); - """, ExpectedOutput = "matches=2", Explanation = "Specification packages a predicate with business meaning. It can be composed and tested separately from the caller.", Takeaways = ["Specifications are useful for named business filters.", "Keep translation to SQL or LINQ provider limits in mind."], @@ -47,10 +37,6 @@ public sealed class BackendPatternsModule : ISnippetModule Title = "Unit of Work", Scope = "Detail", Question = "What does a unit of work coordinate?", - Code = """ - unitOfWork.Orders.Add(Order.Accepted(10)); - var committed = unitOfWork.Commit(); - """, ExpectedOutput = "pending=1; committed=1", Explanation = "Unit of Work groups changes and commits them at one boundary. ORMs like EF Core already implement this shape through DbContext.", Takeaways = ["Commit once per use case boundary.", "Avoid nested commits inside low-level helpers."], @@ -62,10 +48,6 @@ public sealed class BackendPatternsModule : ISnippetModule Title = "CQRS split", Scope = "Detail", Question = "Why split commands from read models?", - Code = """ - var commandResult = commandHandler.Handle(new PlaceOrder("Ada", 2)); - var readModel = query.Get(commandResult.OrderId); - """, ExpectedOutput = "command=accepted; read=Ada:2", Explanation = "Commands protect invariants and produce state changes. Queries can use a shape optimized for reads.", Takeaways = ["CQRS can be simple in-process separation.", "Use separate models when read and write needs diverge."], @@ -77,11 +59,6 @@ public sealed class BackendPatternsModule : ISnippetModule Title = "Mediator", Scope = "Detail", Question = "What does a mediator decouple?", - Code = """ - var mediator = new SimpleMediator(); - mediator.Register(request => request.Text.ToUpperInvariant()); - var response = mediator.Send(new Ping("hello")); - """, ExpectedOutput = "pong=HELLO", Explanation = "A mediator routes requests to handlers so senders do not directly depend on concrete use-case classes.", Takeaways = ["Mediator can reduce direct coupling.", "Do not hide control flow so much that debugging suffers."], @@ -93,10 +70,6 @@ public sealed class BackendPatternsModule : ISnippetModule Title = "Domain event", Scope = "Detail", Question = "What should a domain event represent?", - Code = """ - var order = Order.Accepted(7); - order.PullEvents(); - """, ExpectedOutput = "events=OrderAccepted", Explanation = "A domain event records something meaningful that already happened inside the domain model.", Takeaways = ["Name events in past tense.", "Publish after the aggregate state change is valid."], @@ -108,10 +81,6 @@ public sealed class BackendPatternsModule : ISnippetModule Title = "Outbox dispatch", Scope = "Edge", Question = "How does an outbox bridge database commits and message publishing?", - Code = """ - outbox.Add(new OutboxMessage("OrderAccepted")); - dispatcher.DispatchPending(); - """, ExpectedOutput = "published=1; pending=0", Explanation = "The outbox stores messages with the same transaction as domain state. A dispatcher publishes pending rows later and marks them sent.", Takeaways = ["Outbox avoids publishing a message for a transaction that rolled back.", "Consumers still need idempotency."], @@ -119,7 +88,7 @@ public sealed class BackendPatternsModule : ISnippetModule } ]; - private static string RunRepositoryBoundary() + public static string RunRepositoryBoundary() { var repository = new InMemoryOrderRepository(); repository.Add(Order.Accepted(1)); @@ -127,7 +96,7 @@ private static string RunRepositoryBoundary() return $"found={found?.Status}"; } - private static string RunSpecification() + public static string RunSpecification() { var orders = new[] { @@ -140,7 +109,7 @@ private static string RunSpecification() return $"matches={orders.Count(spec.IsSatisfiedBy)}"; } - private static string RunUnitOfWork() + public static string RunUnitOfWork() { var unitOfWork = new FakeUnitOfWork(); unitOfWork.Orders.Add(Order.Accepted(10)); @@ -150,7 +119,7 @@ private static string RunUnitOfWork() return $"pending={pending}; committed={committed}"; } - private static string RunCqrsSplit() + public static string RunCqrsSplit() { var projection = new OrderProjectionStore(); var commandHandler = new PlaceOrderHandler(projection); @@ -162,20 +131,20 @@ private static string RunCqrsSplit() return $"command={result.Status}; read={readModel}"; } - private static string RunMediator() + public static string RunMediator() { var mediator = new SimpleMediator(); mediator.Register(request => request.Text.ToUpperInvariant()); return $"pong={mediator.Send(new Ping("hello"))}"; } - private static string RunDomainEvent() + public static string RunDomainEvent() { var order = Order.Accepted(7); return $"events={string.Join(",", order.PullEvents().Select(domainEvent => domainEvent.Name))}"; } - private static string RunOutboxDispatch() + public static string RunOutboxDispatch() { var outbox = new Outbox(); var publisher = new CapturingPublisher(); diff --git a/PersonalSharp.Topics/Modules/CSharpTrapsModule.cs b/PersonalSharp.Topics/Modules/CSharpTrapsModule.cs index 2d66b29..dd502c4 100644 --- a/PersonalSharp.Topics/Modules/CSharpTrapsModule.cs +++ b/PersonalSharp.Topics/Modules/CSharpTrapsModule.cs @@ -16,17 +16,10 @@ public sealed class CSharpTrapsModule : ISnippetModule Title = "async void trap", Scope = "Applied", Question = "Why is async void dangerous outside event handlers?", - Code = """ - async void FireAndForget() - { - await SendAsync(); - throw new InvalidOperationException(); - } - """, ExpectedOutput = "observed=False", Explanation = "async void cannot be awaited by the caller and exceptions escape normal Task observation. Use Task-returning methods for async work.", Takeaways = ["Reserve async void for event handlers.", "Return Task so callers can await and observe failures."], - Run = () => "observed=False" + Run = RunAsyncVoidTrap }, new() { @@ -34,13 +27,10 @@ async void FireAndForget() Title = "Sync-over-async deadlock risk", Scope = "Detail", Question = "Why can .Result or .Wait() be dangerous on async code?", - Code = """ - var value = GetValueAsync().Result; - """, ExpectedOutput = "risk=deadlock", Explanation = "Blocking on async can deadlock in synchronization-context environments and wastes threads on servers.", Takeaways = ["Prefer async all the way.", "Use ConfigureAwait intentionally in library code."], - Run = () => "risk=deadlock" + Run = RunSyncOverAsync }, new() { @@ -48,10 +38,6 @@ async void FireAndForget() Title = "Mutable struct copy", Scope = "Detail", Question = "Why are mutable structs surprising?", - Code = """ - var copy = list[0]; - copy.Value++; - """, ExpectedOutput = "list=1; copy=2", Explanation = "Structs are copied by value. Mutating a copy does not mutate the original element unless you write it back.", Takeaways = ["Keep structs small and immutable.", "Be careful with properties and indexers returning struct copies."], @@ -63,9 +49,6 @@ async void FireAndForget() Title = "DateTimeOffset for instants", Scope = "Applied", Question = "Why is DateTimeOffset often safer for backend timestamps?", - Code = """ - var instant = new DateTimeOffset(2026, 5, 13, 10, 0, 0, TimeSpan.Zero); - """, ExpectedOutput = "offset=00:00", Explanation = "DateTimeOffset carries an offset and represents an instant more explicitly than an unspecified DateTime.", Takeaways = ["Store instants in UTC.", "Be explicit about local time vs instant."], @@ -77,10 +60,6 @@ async void FireAndForget() Title = "Culture-sensitive string compare", Scope = "Detail", Question = "Why should identifiers use ordinal comparison?", - Code = """ - StringComparer.OrdinalIgnoreCase.Equals("i", "I"); - StringComparer.Create(new CultureInfo("tr-TR"), true).Equals("i", "I"); - """, ExpectedOutput = "ordinal=True; turkish=False", Explanation = "Culture rules can change string comparison. Identifiers, keys, and protocol values should use ordinal comparison.", Takeaways = ["Use Ordinal or OrdinalIgnoreCase for identifiers.", "Use culture-aware comparison for human language."], @@ -92,10 +71,6 @@ async void FireAndForget() Title = "decimal vs double", Scope = "Core", Question = "Why is decimal preferred for money?", - Code = """ - 0.1m + 0.2m == 0.3m; - 0.1 + 0.2 == 0.3; - """, ExpectedOutput = "decimal=True; double=False", Explanation = "double is binary floating point and cannot represent many decimal fractions exactly. decimal is base-10 oriented.", Takeaways = ["Use decimal for money.", "Use double for scientific/measurement calculations where floating error is expected."], @@ -107,10 +82,6 @@ async void FireAndForget() Title = "Multiple enumeration", Scope = "Applied", Question = "Why can enumerating IEnumerable twice be a bug?", - Code = """ - var count = query.Count(); - var first = query.First(); - """, ExpectedOutput = "count=2", Explanation = "IEnumerable can be lazy. Enumerating twice can rerun queries, side effects, or expensive work.", Takeaways = ["Materialize when you need multiple passes.", "Accept IReadOnlyList when the API requires indexing or repeated enumeration."], @@ -122,9 +93,6 @@ async void FireAndForget() Title = "ValueTask misuse", Scope = "Edge", Question = "Why is ValueTask not a drop-in faster Task?", - Code = """ - ValueTask value = MaybeCachedAsync(); - """, ExpectedOutput = "cached=True", Explanation = "ValueTask helps when synchronous completion is common, but it has usage restrictions. Do not await an arbitrary ValueTask multiple times.", Takeaways = ["Use Task by default.", "Use ValueTask only on measured hot paths with clear ownership."], @@ -132,7 +100,17 @@ async void FireAndForget() } ]; - private static string RunMutableStruct() + public static string RunAsyncVoidTrap() + { + return "observed=False"; + } + + public static string RunSyncOverAsync() + { + return "risk=deadlock"; + } + + public static string RunMutableStruct() { var list = new List { new(1) }; var copy = list[0]; @@ -140,27 +118,27 @@ private static string RunMutableStruct() return $"list={list[0].Value}; copy={copy.Value}"; } - private static string RunDateTimeOffset() + public static string RunDateTimeOffset() { var instant = new DateTimeOffset(2026, 5, 13, 10, 0, 0, TimeSpan.Zero); return $"offset={instant.Offset:hh\\:mm}"; } - private static string RunCultureStringCompare() + public static string RunCultureStringCompare() { var ordinal = StringComparer.OrdinalIgnoreCase.Equals("i", "I"); var turkish = StringComparer.Create(new CultureInfo("tr-TR"), ignoreCase: true).Equals("i", "I"); return $"ordinal={ordinal}; turkish={turkish}"; } - private static string RunDecimalDouble() + public static string RunDecimalDouble() { var decimalEquals = 0.1m + 0.2m == 0.3m; var doubleEquals = 0.1 + 0.2 == 0.3; return $"decimal={decimalEquals}; double={doubleEquals}"; } - private static string RunMultipleEnumeration() + public static string RunMultipleEnumeration() { var enumerations = 0; var query = Query(); @@ -176,7 +154,7 @@ IEnumerable Query() } } - private static string RunValueTaskMisuse() + public static string RunValueTaskMisuse() { var value = MaybeCachedAsync(cached: true); return $"cached={value.IsCompletedSuccessfully}"; diff --git a/PersonalSharp.Topics/Modules/ConcurrencyPatternsModule.cs b/PersonalSharp.Topics/Modules/ConcurrencyPatternsModule.cs index 29d3831..4fc0743 100644 --- a/PersonalSharp.Topics/Modules/ConcurrencyPatternsModule.cs +++ b/PersonalSharp.Topics/Modules/ConcurrencyPatternsModule.cs @@ -16,16 +16,10 @@ public sealed class ConcurrencyPatternsModule : ISnippetModule Title = "Producer/consumer with Channel", Scope = "Detail", Question = "How do you connect async producers and consumers without sharing mutable queues directly?", - Code = """ - var channel = Channel.CreateBounded(2); - await channel.Writer.WriteAsync(1); - await channel.Writer.WriteAsync(2); - channel.Writer.Complete(); - """, ExpectedOutput = "sum=3", Explanation = "Channel gives producers and consumers an async queue with completion and optional backpressure.", Takeaways = ["Bounded channels apply backpressure.", "Complete the writer so consumers can finish."], - Run = () => RunProducerConsumerChannelAsync().GetAwaiter().GetResult() + Run = RunProducerConsumerChannel }, new() { @@ -33,10 +27,6 @@ public sealed class ConcurrencyPatternsModule : ISnippetModule Title = "Lock vs Interlocked", Scope = "Applied", Question = "When is Interlocked enough and when do you need a lock?", - Code = """ - lock (gate) { protectedCounter++; } - Interlocked.Increment(ref atomicCounter); - """, ExpectedOutput = "lock=2; interlocked=2", Explanation = "Interlocked is ideal for single atomic numeric operations. Use lock when multiple fields or invariants must change together.", Takeaways = ["Atomic operations protect one operation, not a whole object invariant.", "Keep lock sections short and predictable."], @@ -48,15 +38,10 @@ public sealed class ConcurrencyPatternsModule : ISnippetModule Title = "SemaphoreSlim throttling", Scope = "Detail", Question = "How do you limit concurrent async work?", - Code = """ - await semaphore.WaitAsync(); - try { await WorkAsync(); } - finally { semaphore.Release(); } - """, ExpectedOutput = "max=2", Explanation = "SemaphoreSlim limits how many workers can enter a section at once. This is common around APIs, databases, and expensive resources.", Takeaways = ["Always release in finally.", "Throttling is different from locking shared state."], - Run = () => RunSemaphoreThrottleAsync().GetAwaiter().GetResult() + Run = RunSemaphoreThrottle }, new() { @@ -64,10 +49,6 @@ public sealed class ConcurrencyPatternsModule : ISnippetModule Title = "Idempotent event handler", Scope = "Detail", Question = "How should a handler behave when the same event is delivered twice?", - Code = """ - var first = handler.Handle(message); - var duplicate = handler.Handle(message); - """, ExpectedOutput = "first=True; duplicate=False; sideEffects=1", Explanation = "At-least-once delivery means duplicates are normal. Handlers need a durable processed marker or idempotency key.", Takeaways = ["Design event consumers for retries.", "Use unique message IDs or natural idempotency keys."], @@ -79,11 +60,6 @@ public sealed class ConcurrencyPatternsModule : ISnippetModule Title = "Eventual consistency projection", Scope = "Edge", Question = "Why can a read model lag behind a command?", - Code = """ - var before = projection.Get(orderId); - projector.Handle(new OrderAccepted(orderId, "Accepted")); - var after = projection.Get(orderId); - """, ExpectedOutput = "before=missing; after=Accepted", Explanation = "With event-driven projections, the write model can commit before the read model catches up. APIs must choose whether to wait, poll, or expose pending state.", Takeaways = ["Eventual consistency is a product behavior, not just an implementation detail.", "Expose clear status when reads can lag writes."], @@ -91,7 +67,7 @@ public sealed class ConcurrencyPatternsModule : ISnippetModule } ]; - private static async Task RunProducerConsumerChannelAsync() + public static async Task RunProducerConsumerChannelAsync() { var channel = Channel.CreateBounded(2); @@ -108,7 +84,12 @@ private static async Task RunProducerConsumerChannelAsync() return $"sum={sum}"; } - private static string RunLockVsInterlocked() + public static string RunProducerConsumerChannel() + { + return RunProducerConsumerChannelAsync().GetAwaiter().GetResult(); + } + + public static string RunLockVsInterlocked() { var gate = new object(); var protectedCounter = 0; @@ -130,7 +111,7 @@ private static string RunLockVsInterlocked() return $"lock={protectedCounter}; interlocked={atomicCounter}"; } - private static async Task RunSemaphoreThrottleAsync() + public static async Task RunSemaphoreThrottleAsync() { using var semaphore = new SemaphoreSlim(2); var gate = new object(); @@ -167,7 +148,12 @@ async Task WorkAsync(int _) } } - private static string RunIdempotentHandler() + public static string RunSemaphoreThrottle() + { + return RunSemaphoreThrottleAsync().GetAwaiter().GetResult(); + } + + public static string RunIdempotentHandler() { var handler = new IdempotentHandler(); var message = new IntegrationEvent(Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")); @@ -178,7 +164,7 @@ private static string RunIdempotentHandler() return $"first={first}; duplicate={duplicate}; sideEffects={handler.SideEffects}"; } - private static string RunEventualConsistency() + public static string RunEventualConsistency() { const int orderId = 42; var projection = new OrderProjection(); diff --git a/PersonalSharp.Topics/Modules/DataSqlModule.cs b/PersonalSharp.Topics/Modules/DataSqlModule.cs index 8aba7ef..7403737 100644 --- a/PersonalSharp.Topics/Modules/DataSqlModule.cs +++ b/PersonalSharp.Topics/Modules/DataSqlModule.cs @@ -15,11 +15,6 @@ public sealed class DataSqlModule : ISnippetModule Title = "Tracking vs no-tracking", Scope = "Applied", Question = "What does EF Core tracking buy you, and what does no-tracking avoid?", - Code = """ - var trackedA = context.FindTracked(1); - var trackedB = context.FindTracked(1); - var detached = context.FindNoTracking(1); - """, ExpectedOutput = "same=True; detached=False", Explanation = "Tracking returns the same in-memory entity instance and records changes. No-tracking is cheaper for read-only queries.", Takeaways = ["Use tracking for updates.", "Use no-tracking for read-only projections."], @@ -31,12 +26,6 @@ public sealed class DataSqlModule : ISnippetModule Title = "N+1 query problem", Scope = "Detail", Question = "Why can lazy loading destroy query performance?", - Code = """ - foreach (var order in orders) - { - total += LoadLines(order.Id).Count; - } - """, ExpectedOutput = "queries=4; optimized=1", Explanation = "N+1 means one query for the parent list plus one query per row. Projection or explicit include can reduce it to one planned query.", Takeaways = ["Count database round-trips, not just lines of code.", "Prefer projections for read models."], @@ -48,10 +37,6 @@ public sealed class DataSqlModule : ISnippetModule Title = "SQL injection boundary", Scope = "Core", Question = "What is the safe boundary between user input and SQL?", - Code = """ - command.CommandText = "select * from users where name = @name"; - command.Parameters.Add("@name", userInput); - """, ExpectedOutput = "safe=True", Explanation = "Parameterized SQL sends data separately from the command text. String interpolation with user input changes the program.", Takeaways = ["Parameterize user input.", "Raw SQL is fine when the boundary is explicit and tested."], @@ -63,11 +48,6 @@ public sealed class DataSqlModule : ISnippetModule Title = "Transaction rollback", Scope = "Applied", Question = "What should happen when one operation in a transaction fails?", - Code = """ - tx.Add("reserve"); - tx.Fail(); - tx.Rollback(); - """, ExpectedOutput = "committed=0", Explanation = "A transaction makes a group of writes atomic. If the use case fails, the partial writes must not become visible.", Takeaways = ["Keep transaction boundaries at use-case level.", "Do not publish external messages before commit."], @@ -79,11 +59,6 @@ public sealed class DataSqlModule : ISnippetModule Title = "PostgreSQL upsert and JSONB", Scope = "Detail", Question = "Why are PostgreSQL upsert and JSONB useful backend tools?", - Code = """ - insert into orders(id, metadata) - values (@id, @jsonb) - on conflict (id) do update set metadata = excluded.metadata; - """, ExpectedOutput = "status=updated; priority=high", Explanation = "Upsert handles idempotent writes. JSONB can store flexible metadata while keeping relational identity and indexes.", Takeaways = ["Use unique constraints for idempotency.", "Index JSONB paths that are queried often."], @@ -95,12 +70,6 @@ insert into orders(id, metadata) Title = "PostgreSQL SKIP LOCKED queue", Scope = "Edge", Question = "How do workers safely claim jobs without blocking each other?", - Code = """ - select id from jobs - where status = 'pending' - for update skip locked - limit 1; - """, ExpectedOutput = "claimed=job-1; remaining=job-2", Explanation = "SKIP LOCKED lets workers skip rows another transaction already claimed, which is useful for database-backed queues.", Takeaways = ["Claim and update in one transaction.", "Design retry and dead-letter behavior explicitly."], @@ -112,9 +81,6 @@ select id from jobs Title = "Cosmos DB partition key point read", Scope = "Detail", Question = "Why does Cosmos DB care so much about partition keys?", - Code = """ - container.ReadItemAsync("order-1", new PartitionKey("customer-1")); - """, ExpectedOutput = "pointRead=True; crossPartition=False", Explanation = "A point read with id and partition key is the cheapest access path. Cross-partition queries cost more and need different design scrutiny.", Takeaways = ["Choose partition keys around access patterns.", "Model id and partition key together in repository APIs."], @@ -126,10 +92,6 @@ select id from jobs Title = "Cosmos DB ETag concurrency", Scope = "Detail", Question = "How does Cosmos DB detect stale updates?", - Code = """ - var first = store.Replace(id, currentEtag); - var stale = store.Replace(id, oldEtag); - """, ExpectedOutput = "first=True; stale=False", Explanation = "ETags implement optimistic concurrency. A write with an old ETag fails instead of overwriting a newer version.", Takeaways = ["ETag checks prevent lost updates.", "Return conflict semantics to clients instead of hiding them."], @@ -141,10 +103,6 @@ select id from jobs Title = "Cosmos DB transactional batch", Scope = "Edge", Question = "What is the key limitation of Cosmos DB transactional batch?", - Code = """ - batch.CreateItem(order); - batch.CreateItem(outboxMessage); - """, ExpectedOutput = "samePartition=True; committed=2", Explanation = "Transactional batch is atomic only inside one logical partition. Cross-partition transactions need a different design.", Takeaways = ["Put aggregate state and its outbox message in the same partition when possible.", "Partition design is consistency design."], @@ -156,10 +114,6 @@ select id from jobs Title = "Cosmos event stream projection", Scope = "Edge", Question = "How can Cosmos DB model an event stream and projection?", - Code = """ - stream.Append(new Event("OrderPlaced")); - projection.Apply(stream.ReadAll()); - """, ExpectedOutput = "events=1; projection=Placed", Explanation = "A partition-per-aggregate event stream keeps append and read paths local. Projections are derived state and can lag.", Takeaways = ["Events are source facts; projections are read models.", "Plan snapshotting if streams get large."], @@ -167,7 +121,7 @@ select id from jobs } ]; - private static string RunEfTracking() + public static string RunEfTracking() { var context = new FakeTrackingContext(); var trackedA = context.FindTracked(1); @@ -176,7 +130,7 @@ private static string RunEfTracking() return $"same={ReferenceEquals(trackedA, trackedB)}; detached={ReferenceEquals(trackedA, detached)}"; } - private static string RunNPlusOne() + public static string RunNPlusOne() { var queryCounter = 1; var orders = new[] { 1, 2, 3 }; @@ -191,14 +145,14 @@ private static string RunNPlusOne() static IReadOnlyList LoadLines(int orderId) => [orderId]; } - private static string RunSqlInjectionBoundary() + public static string RunSqlInjectionBoundary() { var command = new SqlCommandSketch("select * from users where name = @name"); command.AddParameter("@name", "'; drop table users; --"); return $"safe={command.IsParameterized}"; } - private static string RunTransactionRollback() + public static string RunTransactionRollback() { var tx = new FakeTransaction(); tx.Add("reserve"); @@ -207,7 +161,7 @@ private static string RunTransactionRollback() return $"committed={tx.Committed.Count}"; } - private static string RunPostgresUpsertJsonb() + public static string RunPostgresUpsertJsonb() { var table = new Dictionary>(); Upsert("order-1", new Dictionary { ["priority"] = "low" }); @@ -218,7 +172,7 @@ private static string RunPostgresUpsertJsonb() void Upsert(string id, Dictionary metadata) => table[id] = metadata; } - private static string RunPostgresSkipLockedQueue() + public static string RunPostgresSkipLockedQueue() { var jobs = new[] { @@ -233,13 +187,13 @@ private static string RunPostgresSkipLockedQueue() return $"claimed={claimed.Id}; remaining={remaining.Id}"; } - private static string RunCosmosPartitionPointRead() + public static string RunCosmosPartitionPointRead() { var request = new CosmosReadRequest("order-1", "customer-1", Query: false); return $"pointRead={request.IsPointRead}; crossPartition={request.IsCrossPartition}"; } - private static string RunCosmosEtagConcurrency() + public static string RunCosmosEtagConcurrency() { var document = new VersionedDocument("order-1", "v1"); var first = document.Replace("v1"); @@ -247,7 +201,7 @@ private static string RunCosmosEtagConcurrency() return $"first={first}; stale={stale}"; } - private static string RunCosmosTransactionalBatch() + public static string RunCosmosTransactionalBatch() { var batch = new TransactionalBatchSketch("order-1"); batch.Create("order-1", "order"); @@ -255,7 +209,7 @@ private static string RunCosmosTransactionalBatch() return $"samePartition={batch.SamePartition}; committed={batch.Count}"; } - private static string RunCosmosEventStreamProjection() + public static string RunCosmosEventStreamProjection() { var stream = new EventStream(); var projection = new OrderProjection(); diff --git a/PersonalSharp.Topics/Modules/DesignPatternsModule.cs b/PersonalSharp.Topics/Modules/DesignPatternsModule.cs index b7de66f..f88390d 100644 --- a/PersonalSharp.Topics/Modules/DesignPatternsModule.cs +++ b/PersonalSharp.Topics/Modules/DesignPatternsModule.cs @@ -15,10 +15,6 @@ public sealed class DesignPatternsModule : ISnippetModule Title = "Strategy", Scope = "Applied", Question = "How do you swap an algorithm without spreading switch statements?", - Code = """ - IShippingCost standard = new StandardShipping(); - IShippingCost express = new ExpressShipping(); - """, ExpectedOutput = "standard=8; express=18", Explanation = "Strategy extracts a varying algorithm behind a stable interface. The caller chooses the strategy, not the internal formula.", Takeaways = ["Use Strategy when behavior varies independently from the object using it.", "It often replaces repeated switch statements."], @@ -30,10 +26,6 @@ public sealed class DesignPatternsModule : ISnippetModule Title = "Factory Method", Scope = "Applied", Question = "Where should object creation decisions live?", - Code = """ - var notification = NotificationFactory.Create("email"); - notification.Send("ready"); - """, ExpectedOutput = "channel=email", Explanation = "A factory centralizes construction choices and shields the caller from concrete classes.", Takeaways = ["Factories are useful when construction has policy.", "Do not hide simple constructors behind factories without a reason."], @@ -45,11 +37,6 @@ public sealed class DesignPatternsModule : ISnippetModule Title = "Observer", Scope = "Applied", Question = "How do many listeners react to one state change?", - Code = """ - ticker.PriceChanged += price => audit.Add($"Ada:{price}"); - ticker.PriceChanged += price => audit.Add($"Bob:{price}"); - ticker.Update(42); - """, ExpectedOutput = "Ada:42,Bob:42", Explanation = "Observer notifies subscribers without the subject knowing who they are. In C#, events are the common built-in shape.", Takeaways = ["Remember to unsubscribe long-lived events.", "Observer is synchronous unless you explicitly queue work."], @@ -61,10 +48,6 @@ public sealed class DesignPatternsModule : ISnippetModule Title = "Decorator", Scope = "Applied", Question = "How do you add behavior without editing the original service?", - Code = """ - IMessageSender sender = new LoggingSender(new PlainSender()); - sender.Send("hi"); - """, ExpectedOutput = "log(send:hi)", Explanation = "Decorator wraps the same interface and adds behavior before or after delegating to the inner service.", Takeaways = ["Decorators preserve the public contract.", "They compose well for logging, caching, retries, and metrics."], @@ -76,10 +59,6 @@ public sealed class DesignPatternsModule : ISnippetModule Title = "Adapter", Scope = "Applied", Question = "How do you use a legacy API behind a modern interface?", - Code = """ - IPaymentGateway gateway = new LegacyPaymentAdapter(new LegacyPaymentClient()); - gateway.Pay(12.34m); - """, ExpectedOutput = "charged=1234", Explanation = "Adapter translates one interface into another. The application depends on its own port, not the legacy shape.", Takeaways = ["Adapters are common at integration boundaries.", "Keep translation logic near the boundary."], @@ -91,9 +70,6 @@ public sealed class DesignPatternsModule : ISnippetModule Title = "Template Method", Scope = "Detail", Question = "How do you keep a workflow fixed while allowing steps to vary?", - Code = """ - var result = new CsvImportJob().Execute(); - """, ExpectedOutput = "csv:read>validate>save", Explanation = "Template Method defines the skeleton in a base class and lets subclasses customize specific steps.", Takeaways = ["Prefer composition first, but recognize framework-style base workflows.", "Keep overridable steps narrow and documented."], @@ -105,10 +81,6 @@ public sealed class DesignPatternsModule : ISnippetModule Title = "Chain of Responsibility", Scope = "Detail", Question = "How do you build a validation or handling pipeline?", - Code = """ - var chain = new QuantityValidator(new StockValidator(null)); - var accepted = chain.Handle(new Purchase(2, 3)); - """, ExpectedOutput = "accepted=True", Explanation = "Each handler decides whether to stop or pass the request to the next handler. Middleware pipelines use this shape heavily.", Takeaways = ["Chains are useful when steps are optional or reorderable.", "Make failure behavior explicit."], @@ -120,10 +92,6 @@ public sealed class DesignPatternsModule : ISnippetModule Title = "Command", Scope = "Applied", Question = "How do you represent an action as data?", - Code = """ - ICommand[] commands = [new Deposit(account, 40), new Withdraw(account, 15)]; - foreach (var command in commands) command.Execute(); - """, ExpectedOutput = "balance=25", Explanation = "Command encapsulates an operation. It can be queued, logged, retried, undone, or dispatched later.", Takeaways = ["Commands are natural for task queues and use cases.", "Keep commands focused on one action."], @@ -131,7 +99,7 @@ public sealed class DesignPatternsModule : ISnippetModule } ]; - private static string RunStrategy() + public static string RunStrategy() { var package = new Package(3); IShippingCost standard = new StandardShipping(); @@ -140,13 +108,13 @@ private static string RunStrategy() return $"standard={standard.Calculate(package)}; express={express.Calculate(package)}"; } - private static string RunFactoryMethod() + public static string RunFactoryMethod() { var notification = NotificationFactory.Create("email"); return $"channel={notification.Send("ready")}"; } - private static string RunObserver() + public static string RunObserver() { var ticker = new PriceTicker(); var audit = new List(); @@ -158,13 +126,13 @@ private static string RunObserver() return string.Join(",", audit); } - private static string RunDecorator() + public static string RunDecorator() { IMessageSender sender = new LoggingSender(new PlainSender()); return sender.Send("hi"); } - private static string RunAdapter() + public static string RunAdapter() { var client = new LegacyPaymentClient(); IPaymentGateway gateway = new LegacyPaymentAdapter(client); @@ -172,18 +140,18 @@ private static string RunAdapter() return $"charged={client.LastChargeInCents}"; } - private static string RunTemplateMethod() + public static string RunTemplateMethod() { return new CsvImportJob().Execute(); } - private static string RunChainOfResponsibility() + public static string RunChainOfResponsibility() { var chain = new QuantityValidator(new StockValidator(null)); return $"accepted={chain.Handle(new Purchase(2, 3))}"; } - private static string RunCommand() + public static string RunCommand() { var account = new Account(); ICommand[] commands = [new Deposit(account, 40), new Withdraw(account, 15)]; diff --git a/PersonalSharp.Topics/Modules/ExamplesModule.cs b/PersonalSharp.Topics/Modules/ExamplesModule.cs new file mode 100644 index 0000000..6e22fd5 --- /dev/null +++ b/PersonalSharp.Topics/Modules/ExamplesModule.cs @@ -0,0 +1,659 @@ +using PersonalSharp.Topics.Abstractions; + +namespace PersonalSharp.Topics.Modules; + +public sealed class ExamplesModule : ISnippetModule +{ + public string Name => "Examples"; + public string Description => "Concrete business workflow examples."; + + public IReadOnlyList Snippets => + [ + new() + { + Key = "example.acumatica-wms-pick-pack-ship", + Title = "Acumatica WMS pick-pack-ship", + Scope = "Example", + Question = "How can a WMS scan workflow stay separate from Acumatica-specific calls?", + ExpectedOutput = "picked=2; packed=2; shipment=confirmed; adapterCalls=1", + Explanation = "The WMS flow validates scans and builds a packed shipment. The Acumatica boundary is an adapter, so the workflow can be tested without coupling scan logic to ERP transport details.", + Takeaways = + [ + "Keep scanner rules close to the WMS workflow.", + "Keep Acumatica transport and document update details behind an adapter.", + "Make the packed shipment shape explicit before calling the ERP boundary." + ], + Run = RunAcumaticaWmsPickPackShip + }, + new() + { + Key = "example.acumatica-wms-receiving", + Title = "Acumatica WMS receiving", + Scope = "Example", + Question = "How can PO receiving validate scans before releasing a receipt in ERP?", + ExpectedOutput = "received=2; rejected=1; receipt=released; adapterCalls=1", + Explanation = "The receiving session owns scan validation and over-receipt protection. The ERP adapter receives a complete receipt draft and releases it.", + Takeaways = + [ + "Validate location, item, serial, and quantity before touching the ERP boundary.", + "Treat over-receipt as a workflow decision instead of an adapter concern.", + "Release only a receipt draft that has already passed WMS checks." + ], + Run = RunAcumaticaWmsReceiving + }, + new() + { + Key = "example.acumatica-wms-transfer", + Title = "Acumatica WMS transfer", + Scope = "Example", + Question = "How can a bin transfer validate source, destination, and lot before posting inventory movement?", + ExpectedOutput = "moved=3; from=A1-01; to=B2-04; adjustment=posted", + Explanation = "The transfer session checks the physical move. The adapter boundary receives a posting-ready inventory transfer document.", + Takeaways = + [ + "Keep warehouse scan checks outside the ERP transport layer.", + "Model source and destination explicitly.", + "Use a posting boundary for the final inventory movement." + ], + Run = RunAcumaticaWmsTransfer + }, + new() + { + Key = "example.acumatica-order-to-cash", + Title = "Acumatica order-to-cash", + Scope = "Example", + Question = "What status flow connects sales order, shipment, invoice, and payment?", + ExpectedOutput = "order=completed; shipment=confirmed; invoice=released; payment=applied", + Explanation = "Order-to-cash is a status progression across separate documents. Each step should depend on the previous document state.", + Takeaways = + [ + "Represent each document state separately.", + "Avoid skipping shipment and invoice boundaries.", + "Make the completed order state a result of the whole flow." + ], + Run = RunAcumaticaOrderToCash + }, + new() + { + Key = "example.acumatica-inventory-allocation", + Title = "Acumatica inventory allocation", + Scope = "Example", + Question = "How can hard allocation handle partial shipment and leave a backorder?", + ExpectedOutput = "allocated=6; shipped=4; backorder=2; version=2", + Explanation = "Allocation reserves stock with an expected version. Shipment can be partial, leaving the remaining allocated quantity as a backorder.", + Takeaways = + [ + "Use the expected version at the inventory boundary.", + "Separate allocation quantity from shipped quantity.", + "Keep backorder calculation explicit." + ], + Run = RunAcumaticaInventoryAllocation + }, + new() + { + Key = "example.acumatica-cycle-count", + Title = "Acumatica cycle count", + Scope = "Example", + Question = "How can a count variance be approved, posted, and audited?", + ExpectedOutput = "variance=-2; approved=True; adjustment=posted; audit=1", + Explanation = "Cycle count keeps the counted quantity, variance approval, posting request, and audit trail visible in one workflow.", + Takeaways = + [ + "Do not post variance until it is approved.", + "Carry count context into the adjustment request.", + "Record who approved the variance." + ], + Run = RunAcumaticaCycleCount + }, + new() + { + Key = "example.acumatica-purchase-to-pay", + Title = "Acumatica purchase-to-pay", + Scope = "Example", + Question = "What document flow connects purchase order, receipt, AP bill, and payment?", + ExpectedOutput = "po=received; bill=released; payment=paid", + Explanation = "Purchase-to-pay mirrors the vendor side of the business flow: receive goods, release the AP bill, and pay the vendor.", + Takeaways = + [ + "Keep purchasing states separate from sales states.", + "Do not create payable state before receipt state exists.", + "Make vendor payment the final document transition." + ], + Run = RunAcumaticaPurchaseToPay + }, + new() + { + Key = "example.acumatica-rest-adapter", + Title = "Acumatica REST adapter", + Scope = "Example", + Question = "How can a domain command map to an Acumatica-style REST request?", + ExpectedOutput = "endpoint=/entity/Default/24.200.001/SalesOrder; method=PUT; lines=1", + Explanation = "The adapter turns an internal command into a transport DTO. The domain code does not need endpoint paths or REST payload details.", + Takeaways = + [ + "Keep endpoint and DTO shape at the adapter boundary.", + "Do not leak REST payload fields into the domain workflow.", + "Make mapping output testable without a network call." + ], + Run = RunAcumaticaRestAdapter + }, + new() + { + Key = "example.acumatica-idempotent-sync", + Title = "Acumatica idempotent sync", + Scope = "Example", + Question = "How can sync processing ignore duplicate events while still producing an outbox message?", + ExpectedOutput = "first=True; duplicate=False; handled=1; outbox=1", + Explanation = "The processor records an idempotency key before applying side effects. A retry or duplicate delivery becomes a no-op.", + Takeaways = + [ + "Use a stable external event id as the idempotency key.", + "Record handled events before emitting follow-up work.", + "Keep duplicate delivery behavior deterministic." + ], + Run = RunAcumaticaIdempotentSync + } + ]; + + public static string RunAcumaticaWmsPickPackShip() + { + var shipment = new ShipmentPickTask("SO-000123", "MAIN", "A1-01", "BOLT-10", 2); + var session = new WmsScanSession(shipment); + var acumatica = new CapturingAcumaticaWmsAdapter(); + + session.Scan("A1-01", "BOLT-10", 2); + var packed = session.Pack(); + var result = acumatica.ConfirmShipment(packed); + + return $"picked={session.PickedQuantity}; packed={packed.Quantity}; shipment={result.Status}; adapterCalls={acumatica.CallCount}"; + } + + public static string RunAcumaticaWmsReceiving() + { + var line = new PurchaseReceiptLine("PO-000145", "RECEIVING", "BOLT-10", 2); + var session = new ReceivingSession(line); + var acumatica = new CapturingReceivingAdapter(); + + session.Scan("RECEIVING", "BOLT-10", "SN-001"); + session.Scan("RECEIVING", "BOLT-10", "SN-002"); + session.TryScan("RECEIVING", "BOLT-10", "SN-003"); + var result = acumatica.ReleaseReceipt(session.BuildReceipt()); + + return $"received={session.ReceivedQuantity}; rejected={session.RejectedScans}; receipt={result.Status}; adapterCalls={acumatica.CallCount}"; + } + + public static string RunAcumaticaWmsTransfer() + { + var request = new InventoryTransferRequest("MAIN", "A1-01", "B2-04", "BOLT-10", "LOT-77", 3); + var session = new TransferSession(request); + var acumatica = new CapturingTransferAdapter(); + + session.Move("A1-01", "B2-04", "BOLT-10", "LOT-77", 3); + var transfer = session.BuildTransfer(); + var result = acumatica.PostTransfer(transfer); + + return $"moved={transfer.Quantity}; from={transfer.FromLocation}; to={transfer.ToLocation}; adjustment={result.Status}"; + } + + public static string RunAcumaticaOrderToCash() + { + var flow = new OrderToCashFlow("SO-000222"); + + flow.ConfirmShipment(); + flow.ReleaseInvoice(); + flow.ApplyPayment(); + + return $"order={flow.OrderStatus}; shipment={flow.ShipmentStatus}; invoice={flow.InvoiceStatus}; payment={flow.PaymentStatus}"; + } + + public static string RunAcumaticaInventoryAllocation() + { + var inventory = new InventoryAllocationStore("BOLT-10", available: 10, version: 1); + + var allocation = inventory.HardAllocate("BOLT-10", quantity: 6, expectedVersion: 1); + var shipment = allocation.PlanShipment(quantity: 4); + + return $"allocated={allocation.Quantity}; shipped={shipment.ShippedQuantity}; backorder={shipment.BackorderQuantity}; version={inventory.Version}"; + } + + public static string RunAcumaticaCycleCount() + { + var session = new CycleCountSession("MAIN", "A1-01", "BOLT-10", expected: 10); + var acumatica = new CapturingCycleCountAdapter(); + + session.Count(actual: 8); + session.ApproveVariance("supervisor"); + var result = acumatica.PostAdjustment(session.BuildAdjustment()); + + return $"variance={session.Variance}; approved={session.Approved}; adjustment={result.Status}; audit={session.AuditCount}"; + } + + public static string RunAcumaticaPurchaseToPay() + { + var flow = new PurchaseToPayFlow("PO-000310"); + + flow.Receive(); + flow.ReleaseBill(); + flow.Pay(); + + return $"po={flow.PurchaseOrderStatus}; bill={flow.BillStatus}; payment={flow.PaymentStatus}"; + } + + public static string RunAcumaticaRestAdapter() + { + var command = new UpsertSalesOrder("SO-000222", "ABARTENDE", [new SalesOrderLine("BOLT-10", 2)]); + var adapter = new SalesOrderRestAdapter(); + + var request = adapter.Map(command); + + return $"endpoint={request.Endpoint}; method={request.Method}; lines={request.LineCount}"; + } + + public static string RunAcumaticaIdempotentSync() + { + var processor = new IdempotentSyncProcessor(); + + var first = processor.Handle(new SyncEvent("evt-100", "ShipmentConfirmed")); + var duplicate = processor.Handle(new SyncEvent("evt-100", "ShipmentConfirmed")); + + return $"first={first}; duplicate={duplicate}; handled={processor.HandledCount}; outbox={processor.OutboxCount}"; + } + + private sealed record ShipmentPickTask( + string ShipmentNumber, + string Warehouse, + string Location, + string InventoryId, + int Quantity); + + private sealed class WmsScanSession(ShipmentPickTask task) + { + public int PickedQuantity { get; private set; } + + public void Scan(string location, string inventoryId, int quantity) + { + if (!string.Equals(task.Location, location, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("Wrong location."); + } + + if (!string.Equals(task.InventoryId, inventoryId, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("Wrong inventory item."); + } + + if (quantity <= 0 || PickedQuantity + quantity > task.Quantity) + { + throw new InvalidOperationException("Invalid quantity."); + } + + PickedQuantity += quantity; + } + + public PackedShipment Pack() + { + if (PickedQuantity != task.Quantity) + { + throw new InvalidOperationException("Shipment is not fully picked."); + } + + return new PackedShipment(task.ShipmentNumber, task.Warehouse, task.InventoryId, PickedQuantity); + } + } + + private sealed record PackedShipment(string ShipmentNumber, string Warehouse, string InventoryId, int Quantity); + + private sealed record ShipmentConfirmation(string Status); + + private interface IAcumaticaWmsAdapter + { + ShipmentConfirmation ConfirmShipment(PackedShipment shipment); + } + + private sealed class CapturingAcumaticaWmsAdapter : IAcumaticaWmsAdapter + { + public int CallCount { get; private set; } + + public ShipmentConfirmation ConfirmShipment(PackedShipment shipment) + { + _ = shipment.ShipmentNumber; + _ = shipment.Warehouse; + _ = shipment.InventoryId; + _ = shipment.Quantity; + CallCount++; + return new ShipmentConfirmation("confirmed"); + } + } + + private sealed record PurchaseReceiptLine(string PurchaseOrderNumber, string Location, string InventoryId, int Quantity); + + private sealed record PurchaseReceiptDraft(string PurchaseOrderNumber, string InventoryId, int Quantity); + + private sealed class ReceivingSession(PurchaseReceiptLine line) + { + private readonly HashSet _serials = []; + + public int ReceivedQuantity => _serials.Count; + + public int RejectedScans { get; private set; } + + public void Scan(string location, string inventoryId, string serialNumber) + { + if (!TryScan(location, inventoryId, serialNumber)) + { + throw new InvalidOperationException("Invalid receipt scan."); + } + } + + public bool TryScan(string location, string inventoryId, string serialNumber) + { + if (!string.Equals(line.Location, location, StringComparison.OrdinalIgnoreCase) || + !string.Equals(line.InventoryId, inventoryId, StringComparison.OrdinalIgnoreCase) || + _serials.Count >= line.Quantity || + !_serials.Add(serialNumber)) + { + RejectedScans++; + return false; + } + + return true; + } + + public PurchaseReceiptDraft BuildReceipt() + { + if (ReceivedQuantity != line.Quantity) + { + throw new InvalidOperationException("Purchase receipt is not complete."); + } + + return new PurchaseReceiptDraft(line.PurchaseOrderNumber, line.InventoryId, ReceivedQuantity); + } + } + + private sealed record ReceiptReleaseResult(string Status); + + private sealed class CapturingReceivingAdapter + { + public int CallCount { get; private set; } + + public ReceiptReleaseResult ReleaseReceipt(PurchaseReceiptDraft receipt) + { + _ = receipt.PurchaseOrderNumber; + _ = receipt.InventoryId; + _ = receipt.Quantity; + CallCount++; + return new ReceiptReleaseResult("released"); + } + } + + private sealed record InventoryTransferRequest( + string Warehouse, + string FromLocation, + string ToLocation, + string InventoryId, + string LotNumber, + int Quantity); + + private sealed record InventoryTransferDraft( + string Warehouse, + string FromLocation, + string ToLocation, + string InventoryId, + string LotNumber, + int Quantity); + + private sealed class TransferSession(InventoryTransferRequest request) + { + private int _movedQuantity; + + public void Move(string fromLocation, string toLocation, string inventoryId, string lotNumber, int quantity) + { + if (!string.Equals(request.FromLocation, fromLocation, StringComparison.OrdinalIgnoreCase) || + !string.Equals(request.ToLocation, toLocation, StringComparison.OrdinalIgnoreCase) || + !string.Equals(request.InventoryId, inventoryId, StringComparison.OrdinalIgnoreCase) || + !string.Equals(request.LotNumber, lotNumber, StringComparison.OrdinalIgnoreCase) || + quantity != request.Quantity) + { + throw new InvalidOperationException("Invalid transfer scan."); + } + + _movedQuantity = quantity; + } + + public InventoryTransferDraft BuildTransfer() + { + if (_movedQuantity != request.Quantity) + { + throw new InvalidOperationException("Inventory transfer is not complete."); + } + + return new InventoryTransferDraft( + request.Warehouse, + request.FromLocation, + request.ToLocation, + request.InventoryId, + request.LotNumber, + _movedQuantity); + } + } + + private sealed record InventoryAdjustmentResult(string Status); + + private sealed class CapturingTransferAdapter + { + public InventoryAdjustmentResult PostTransfer(InventoryTransferDraft transfer) + { + _ = transfer.Warehouse; + _ = transfer.InventoryId; + _ = transfer.LotNumber; + return new InventoryAdjustmentResult("posted"); + } + } + + private sealed class OrderToCashFlow(string salesOrderNumber) + { + public string OrderStatus { get; private set; } = "open"; + + public string ShipmentStatus { get; private set; } = "pending"; + + public string InvoiceStatus { get; private set; } = "pending"; + + public string PaymentStatus { get; private set; } = "pending"; + + public void ConfirmShipment() + { + _ = salesOrderNumber; + ShipmentStatus = "confirmed"; + } + + public void ReleaseInvoice() + { + if (ShipmentStatus != "confirmed") + { + throw new InvalidOperationException("Shipment must be confirmed before invoice release."); + } + + InvoiceStatus = "released"; + } + + public void ApplyPayment() + { + if (InvoiceStatus != "released") + { + throw new InvalidOperationException("Invoice must be released before payment."); + } + + PaymentStatus = "applied"; + OrderStatus = "completed"; + } + } + + private sealed class InventoryAllocationStore(string inventoryId, int available, int version) + { + public int Version { get; private set; } = version; + + public InventoryAllocation HardAllocate(string requestedInventoryId, int quantity, int expectedVersion) + { + if (!string.Equals(inventoryId, requestedInventoryId, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("Wrong inventory item."); + } + + if (expectedVersion != Version) + { + throw new InvalidOperationException("Inventory version conflict."); + } + + if (quantity <= 0 || quantity > available) + { + throw new InvalidOperationException("Quantity is not available."); + } + + available -= quantity; + Version++; + return new InventoryAllocation(requestedInventoryId, quantity); + } + } + + private sealed record InventoryAllocation(string InventoryId, int Quantity) + { + public AllocationShipment PlanShipment(int quantity) + { + if (quantity <= 0 || quantity > Quantity) + { + throw new InvalidOperationException("Invalid shipment quantity."); + } + + return new AllocationShipment(quantity, Quantity - quantity); + } + } + + private sealed record AllocationShipment(int ShippedQuantity, int BackorderQuantity); + + private sealed class CycleCountSession(string warehouse, string location, string inventoryId, int expected) + { + private readonly List _audit = []; + private int? _actual; + + public int Variance => _actual.GetValueOrDefault() - expected; + + public bool Approved { get; private set; } + + public int AuditCount => _audit.Count; + + public void Count(int actual) + { + _actual = actual; + } + + public void ApproveVariance(string approvedBy) + { + if (_actual is null) + { + throw new InvalidOperationException("Count quantity is missing."); + } + + Approved = true; + _audit.Add($"approved-by:{approvedBy}"); + } + + public CycleCountAdjustment BuildAdjustment() + { + if (!Approved) + { + throw new InvalidOperationException("Variance must be approved."); + } + + return new CycleCountAdjustment(warehouse, location, inventoryId, Variance); + } + } + + private sealed record CycleCountAdjustment(string Warehouse, string Location, string InventoryId, int Variance); + + private sealed class CapturingCycleCountAdapter + { + public InventoryAdjustmentResult PostAdjustment(CycleCountAdjustment adjustment) + { + _ = adjustment.Warehouse; + _ = adjustment.Location; + _ = adjustment.InventoryId; + _ = adjustment.Variance; + return new InventoryAdjustmentResult("posted"); + } + } + + private sealed class PurchaseToPayFlow(string purchaseOrderNumber) + { + public string PurchaseOrderStatus { get; private set; } = "open"; + + public string BillStatus { get; private set; } = "pending"; + + public string PaymentStatus { get; private set; } = "pending"; + + public void Receive() + { + _ = purchaseOrderNumber; + PurchaseOrderStatus = "received"; + } + + public void ReleaseBill() + { + if (PurchaseOrderStatus != "received") + { + throw new InvalidOperationException("Purchase order must be received before bill release."); + } + + BillStatus = "released"; + } + + public void Pay() + { + if (BillStatus != "released") + { + throw new InvalidOperationException("Bill must be released before payment."); + } + + PaymentStatus = "paid"; + } + } + + private sealed record UpsertSalesOrder(string OrderNumber, string CustomerId, IReadOnlyList Lines); + + private sealed record SalesOrderLine(string InventoryId, int Quantity); + + private sealed record AcumaticaRestRequest(string Endpoint, string Method, int LineCount); + + private sealed class SalesOrderRestAdapter + { + public AcumaticaRestRequest Map(UpsertSalesOrder command) + { + _ = command.OrderNumber; + _ = command.CustomerId; + return new AcumaticaRestRequest("/entity/Default/24.200.001/SalesOrder", "PUT", command.Lines.Count); + } + } + + private sealed record SyncEvent(string EventId, string Type); + + private sealed class IdempotentSyncProcessor + { + private readonly HashSet _handledKeys = []; + private readonly List _outbox = []; + + public int HandledCount { get; private set; } + + public int OutboxCount => _outbox.Count; + + public bool Handle(SyncEvent syncEvent) + { + if (!_handledKeys.Add(syncEvent.EventId)) + { + return false; + } + + HandledCount++; + _outbox.Add($"publish:{syncEvent.Type}"); + return true; + } + } +} diff --git a/PersonalSharp.Topics/Modules/FunctionalDomainModule.cs b/PersonalSharp.Topics/Modules/FunctionalDomainModule.cs index 0a2a145..a2f1e6a 100644 --- a/PersonalSharp.Topics/Modules/FunctionalDomainModule.cs +++ b/PersonalSharp.Topics/Modules/FunctionalDomainModule.cs @@ -17,10 +17,6 @@ public sealed class FunctionalDomainModule : ISnippetModule Title = "Result map", Scope = "Applied", Question = "How do you transform a successful value while preserving failures?", - Code = """ - var result = Result.Ok(21) - .Map(value => value * 2); - """, ExpectedOutput = "value=42", Explanation = "Map transforms only the success track. A failure would bypass the function and keep the original error.", Takeaways = ["Result makes failure explicit in the type.", "Map is for success-only value transformation."], @@ -32,11 +28,6 @@ public sealed class FunctionalDomainModule : ISnippetModule Title = "Railway Bind", Scope = "Detail", Question = "How does railway programming stop after the first failed step?", - Code = """ - var result = Normalize(input) - .Bind(ParseEmail) - .Bind(EnsureCompanyDomain); - """, ExpectedOutput = "error=invalid-email", Explanation = "Bind composes operations that can fail. Once one function returns Fail, later functions are not called.", Takeaways = ["Bind is for chaining fallible operations.", "Railway style keeps happy path linear without exceptions for expected validation errors."], @@ -48,11 +39,6 @@ public sealed class FunctionalDomainModule : ISnippetModule Title = "Option instead of null", Scope = "Applied", Question = "How can you model a missing value without returning null?", - Code = """ - var displayName = FindUser("42") - .Map(user => user.Name) - .Match(name => name, () => "missing"); - """, ExpectedOutput = "name=missing", Explanation = "Option forces the caller to handle both Some and None, instead of relying on nullable conventions.", Takeaways = ["Option is useful for expected absence.", "Do not use Option to hide exceptional failures."], @@ -64,12 +50,6 @@ public sealed class FunctionalDomainModule : ISnippetModule Title = "Validation accumulation", Scope = "Detail", Question = "When is validation accumulation better than Result short-circuiting?", - Code = """ - var validation = Validation.Combine( - ValidateName(""), - ValidateQuantity(0), - (name, quantity) => new DraftOrder(name, quantity)); - """, ExpectedOutput = "errors=name,quantity", Explanation = "Result is good for stop-on-first-error workflows. Validation is better for forms and commands where the user needs every invalid field.", Takeaways = ["Use Result for workflows.", "Use Validation when multiple independent checks should report together."], @@ -81,11 +61,6 @@ public sealed class FunctionalDomainModule : ISnippetModule Title = "Functional core, imperative shell", Scope = "Edge", Question = "How do you keep domain decisions pure while still doing I/O?", - Code = """ - var decision = OrderDecider.Place(command); - repository.Save(decision.Order); - eventBus.Publish(decision.Events); - """, ExpectedOutput = "saved=order-1; events=OrderPlaced", Explanation = "The core decides what should happen using pure data. The shell performs side effects like persistence and publishing.", Takeaways = ["Pure domain logic is easy to unit test.", "Keep I/O at the edges of the use case."], @@ -97,9 +72,6 @@ public sealed class FunctionalDomainModule : ISnippetModule Title = "Value object validation", Scope = "Applied", Question = "Where should primitive validation live in a domain model?", - Code = """ - var email = EmailAddress.Create(" Ada@Example.COM "); - """, ExpectedOutput = "email=ada@example.com", Explanation = "A value object owns normalization and validation for a concept. After creation, the rest of the domain can trust it.", Takeaways = ["Avoid primitive obsession for important domain concepts.", "Normalize once at the boundary."], @@ -111,10 +83,6 @@ public sealed class FunctionalDomainModule : ISnippetModule Title = "Immutable aggregate transition", Scope = "Edge", Question = "How can an aggregate change state without exposing mutation?", - Code = """ - var placed = OrderAggregate.Place("order-1"); - var paid = placed.MarkPaid(); - """, ExpectedOutput = "status=Paid; events=OrderPlaced,OrderPaid", Explanation = "The aggregate returns a new version with the next valid state and new events. The old instance remains unchanged.", Takeaways = ["Immutable aggregates make transitions explicit.", "Return events as data, not hidden side effects."], @@ -122,13 +90,13 @@ public sealed class FunctionalDomainModule : ISnippetModule } ]; - private static string RunResultMap() + public static string RunResultMap() { var result = Result.Ok(21).Map(value => value * 2); return result.Match(value => $"value={value}", error => $"error={error}"); } - private static string RunRailwayBind() + public static string RunRailwayBind() { var result = Normalize(" not-an-email ") .Bind(ParseEmail) @@ -137,7 +105,7 @@ private static string RunRailwayBind() return result.Match(email => $"email={email.Value}", error => $"error={error}"); } - private static string RunOption() + public static string RunOption() { var displayName = FindUser("42") .Map(user => user.Name) @@ -146,7 +114,7 @@ private static string RunOption() return $"name={displayName}"; } - private static string RunValidationAccumulation() + public static string RunValidationAccumulation() { var validation = Validation.Combine( ValidateName(""), @@ -156,7 +124,7 @@ private static string RunValidationAccumulation() return $"errors={string.Join(",", validation.Errors)}"; } - private static string RunFunctionalCoreShell() + public static string RunFunctionalCoreShell() { var command = new PlaceOrderCommand("order-1", "Ada", 2); var decision = OrderDecider.Place(command); @@ -169,13 +137,13 @@ private static string RunFunctionalCoreShell() return $"saved={repository.LastSavedId}; events={string.Join(",", eventBus.Events.Select(e => e.Name))}"; } - private static string RunValueObject() + public static string RunValueObject() { var email = EmailAddress.Create(" Ada@Example.COM "); return email.Match(value => $"email={value.Value}", error => $"error={error.Code}"); } - private static string RunImmutableAggregate() + public static string RunImmutableAggregate() { var paid = OrderAggregate.Place("order-1").MarkPaid(); return $"status={paid.Status}; events={string.Join(",", paid.Events.Select(e => e.Name))}"; diff --git a/PersonalSharp.Topics/Modules/GenericsDelegatesModule.cs b/PersonalSharp.Topics/Modules/GenericsDelegatesModule.cs index 39cb8bf..215330f 100644 --- a/PersonalSharp.Topics/Modules/GenericsDelegatesModule.cs +++ b/PersonalSharp.Topics/Modules/GenericsDelegatesModule.cs @@ -16,10 +16,6 @@ public sealed class GenericsDelegatesModule : ISnippetModule Title = "Generic constraints", Scope = "Applied", Question = "What do constraints buy you beyond type safety?", - Code = """ - static T Max(T left, T right) where T : IComparable => - left.CompareTo(right) >= 0 ? left : right; - """, ExpectedOutput = "max=13", Explanation = "Constraints let generic code call members guaranteed by the constraint without reflection or dynamic dispatch.", Takeaways = ["Constraints are API documentation and compiler help.", "Use the narrowest constraint that expresses the algorithm."], @@ -31,10 +27,6 @@ static T Max(T left, T right) where T : IComparable => Title = "Covariance with IEnumerable", Scope = "Detail", Question = "Why can IEnumerable be used where IEnumerable is expected?", - Code = """ - IEnumerable names = ["Ada"]; - IEnumerable objects = names; - """, ExpectedOutput = "Ada", Explanation = "IEnumerable is covariant because it only produces T values. Mutable collections like List are invariant because accepting writes would be unsafe.", Takeaways = ["out T means producer/covariant.", "in T means consumer/contravariant."], @@ -46,10 +38,6 @@ static T Max(T left, T right) where T : IComparable => Title = "Func delegate vs expression tree", Scope = "Detail", Question = "Why do ORMs care whether you pass Func or Expression>?", - Code = """ - Func compiled = x => x > 10; - Expression> tree = x => x > 10; - """, ExpectedOutput = "func=True; expression=GreaterThan", Explanation = "Func is executable code. Expression trees preserve structure so providers can translate the predicate, for example into SQL.", Takeaways = ["IEnumerable uses delegates in memory.", "IQueryable providers inspect expression trees."], @@ -57,7 +45,7 @@ static T Max(T left, T right) where T : IComparable => } ]; - private static string RunGenericConstraints() + public static string RunGenericConstraints() { return $"max={Max(13, 8)}"; @@ -65,14 +53,14 @@ static T Max(T left, T right) where T : IComparable => left.CompareTo(right) >= 0 ? left : right; } - private static string RunVariance() + public static string RunVariance() { IEnumerable names = ["Ada"]; IEnumerable objects = names; return objects.Single().ToString() ?? string.Empty; } - private static string RunExpressionVsFunc() + public static string RunExpressionVsFunc() { Func compiled = x => x > 10; Expression> tree = x => x > 10; diff --git a/PersonalSharp.Topics/Modules/LanguageCoreModule.cs b/PersonalSharp.Topics/Modules/LanguageCoreModule.cs index b0bf5b6..c9677d5 100644 --- a/PersonalSharp.Topics/Modules/LanguageCoreModule.cs +++ b/PersonalSharp.Topics/Modules/LanguageCoreModule.cs @@ -15,13 +15,6 @@ public sealed class LanguageCoreModule : ISnippetModule Title = "Value type copy vs reference type alias", Scope = "Core", Question = "What changes when you pass a struct or a class into a method?", - Code = """ - var point = new MutablePoint { X = 1 }; - var box = new MutableBox { Value = 1 }; - Move(point); - Increment(box); - Console.WriteLine($"struct:{point.X}; class:{box.Value}"); - """, ExpectedOutput = "struct:1; class:2", Explanation = "A struct argument is copied by value unless passed by ref. A class variable copies the reference, so both variables point at the same object.", Takeaways = ["Structs are not automatically immutable.", "Reference variables are copied, not the object itself."], @@ -33,13 +26,6 @@ public sealed class LanguageCoreModule : ISnippetModule Title = "Record value equality", Scope = "Core", Question = "How does equality differ between record types and normal classes?", - Code = """ - var a = new PersonRecord("Ada", "Lovelace"); - var b = new PersonRecord("Ada", "Lovelace"); - var c = new PersonClass("Ada", "Lovelace"); - var d = new PersonClass("Ada", "Lovelace"); - Console.WriteLine($"record:{a == b}; class:{c == d}"); - """, ExpectedOutput = "record:True; class:False", Explanation = "Records synthesize value-based equality from their primary constructor data. Classes use reference equality unless equality is overridden.", Takeaways = ["Records are strong DTO/value-object candidates.", "Entity classes usually need identity-based equality."], @@ -51,10 +37,6 @@ public sealed class LanguageCoreModule : ISnippetModule Title = "Nullable reference types and patterns", Scope = "Applied", Question = "How can pattern matching remove nullable warnings and encode guards?", - Code = """ - static string Describe(User? user) => - user is { Name.Length: > 0 } known ? $"known:{known.Name.ToUpperInvariant()}" : "missing"; - """, ExpectedOutput = "known:ADA; missing", Explanation = "Property patterns both test shape and narrow nullable flow analysis, so the matched variable is non-null in the true branch.", Takeaways = ["Nullable annotations are compile-time contracts.", "Patterns can combine validation and narrowing."], @@ -62,7 +44,7 @@ static string Describe(User? user) => } ]; - private static string RunValueVsReference() + public static string RunValueVsReference() { var point = new MutablePoint { X = 1 }; var box = new MutableBox { Value = 1 }; @@ -76,7 +58,7 @@ private static string RunValueVsReference() static void Increment(MutableBox box) => box.Value++; } - private static string RunRecordsEquality() + public static string RunRecordsEquality() { var recordA = new PersonRecord("Ada", "Lovelace"); var recordB = new PersonRecord("Ada", "Lovelace"); @@ -86,7 +68,7 @@ private static string RunRecordsEquality() return $"record:{recordA == recordB}; class:{classA == classB}"; } - private static string RunNullabilityPatterns() + public static string RunNullabilityPatterns() { return $"{Describe(new User("Ada"))}; {Describe(null)}"; diff --git a/PersonalSharp.Topics/Modules/LinqCollectionsModule.cs b/PersonalSharp.Topics/Modules/LinqCollectionsModule.cs index 570a046..87673e3 100644 --- a/PersonalSharp.Topics/Modules/LinqCollectionsModule.cs +++ b/PersonalSharp.Topics/Modules/LinqCollectionsModule.cs @@ -16,13 +16,6 @@ public sealed class LinqCollectionsModule : ISnippetModule Title = "Deferred execution", Scope = "Applied", Question = "When does a LINQ query actually run?", - Code = """ - var seen = 0; - var query = numbers.Where(n => { seen++; return n > 2; }); - Console.WriteLine($"before={seen}"); - _ = query.First(); - Console.WriteLine($"after={seen}"); - """, ExpectedOutput = "before=0; first=3; after=3", Explanation = "Most LINQ operators over IEnumerable are lazy. The predicate runs only when the query is enumerated, and it stops when First finds a match.", Takeaways = ["Deferred execution can repeat side effects.", "Materialize with ToList when you need a stable snapshot."], @@ -34,12 +27,6 @@ public sealed class LinqCollectionsModule : ISnippetModule Title = "Closure capture in loops", Scope = "Applied", Question = "Why do lambdas created in a for loop sometimes see the same final value?", - Code = """ - for (var i = 0; i < 3; i++) - { - actions.Add(() => i); - } - """, ExpectedOutput = "bug=3,3,3; fix=0,1,2", Explanation = "The for-loop variable is one captured variable shared by all lambdas. Copy it into a local variable inside the loop when each lambda needs its own value.", Takeaways = ["foreach capture was fixed in modern C#, but for-loop capture still matters.", "Bug-hunt: look for lambdas escaping a loop."], @@ -51,11 +38,6 @@ public sealed class LinqCollectionsModule : ISnippetModule Title = "HashSet with custom equality", Scope = "Applied", Question = "What must be true for Dictionary or HashSet lookups to work correctly?", - Code = """ - var set = new HashSet(OrderLineSkuComparer.Instance); - set.Add(new OrderLine("ABC", 1)); - set.Add(new OrderLine("abc", 99)); - """, ExpectedOutput = "count=1; quantity=1", Explanation = "Hash-based collections depend on equality and hash code using the same logical identity. This comparer treats SKU as case-insensitive identity.", Takeaways = ["Equal objects must produce equal hash codes.", "Custom comparers are often better than changing entity equality globally."], @@ -63,7 +45,7 @@ public sealed class LinqCollectionsModule : ISnippetModule } ]; - private static string RunDeferredExecution() + public static string RunDeferredExecution() { var seen = 0; var numbers = new[] { 1, 2, 3, 4 }; @@ -78,12 +60,12 @@ private static string RunDeferredExecution() return $"before={before}; first={first}; after={seen}"; } - private static string RunClosureCapture() + public static string RunClosureCapture() { return $"bug={BugHuntCases.ClosureCaptureBug()}; fix={BugHuntCases.ClosureCaptureFix()}"; } - private static string RunCustomEquality() + public static string RunCustomEquality() { var set = new HashSet(OrderLineSkuComparer.Instance) { diff --git a/PersonalSharp.Topics/Modules/MemoryPerformanceModule.cs b/PersonalSharp.Topics/Modules/MemoryPerformanceModule.cs index b1f5ffa..d27be68 100644 --- a/PersonalSharp.Topics/Modules/MemoryPerformanceModule.cs +++ b/PersonalSharp.Topics/Modules/MemoryPerformanceModule.cs @@ -16,11 +16,6 @@ public sealed class MemoryPerformanceModule : ISnippetModule Title = "Boxing and unboxing", Scope = "Detail", Question = "When does a value type allocate on the heap?", - Code = """ - int value = 42; - object boxed = value; - int copy = (int)boxed; - """, ExpectedOutput = "boxed=Int32; copy=42", Explanation = "Boxing wraps a value type in an object. Unboxing copies the value back out and requires the exact runtime value type.", Takeaways = ["Boxing can hide in interface calls and object collections.", "Generic APIs often avoid boxing when constrained correctly."], @@ -32,10 +27,6 @@ public sealed class MemoryPerformanceModule : ISnippetModule Title = "Span over stackalloc memory", Scope = "Edge", Question = "Why can Span point at stack memory safely?", - Code = """ - Span values = stackalloc[] { 1, 2, 3 }; - values[1] = 20; - """, ExpectedOutput = "sum=24", Explanation = "Span is a ref struct, so it cannot escape to the heap. That restriction lets it safely represent stack, array, or native memory.", Takeaways = ["Span is allocation-aware, not magic performance dust.", "The compiler enforces escape rules for ref structs."], @@ -47,11 +38,6 @@ public sealed class MemoryPerformanceModule : ISnippetModule Title = "ArrayPool", Scope = "Detail", Question = "What is the tradeoff when using ArrayPool?", - Code = """ - var rented = ArrayPool.Shared.Rent(4); - try { rented[0] = 99; } - finally { ArrayPool.Shared.Return(rented, clearArray: true); } - """, ExpectedOutput = "first=99; returned=True", Explanation = "Pooling reduces allocation pressure but rented arrays can be larger than requested and may contain old data unless cleared.", Takeaways = ["Always return rented buffers.", "Clear arrays when data sensitivity or stale values matter."], @@ -59,7 +45,7 @@ public sealed class MemoryPerformanceModule : ISnippetModule } ]; - private static string RunBoxing() + public static string RunBoxing() { var value = 42; object boxed = value; @@ -67,14 +53,14 @@ private static string RunBoxing() return $"boxed={boxed.GetType().Name}; copy={copy}"; } - private static string RunSpanStackalloc() + public static string RunSpanStackalloc() { Span values = stackalloc[] { 1, 2, 3 }; values[1] = 20; return $"sum={values[0] + values[1] + values[2]}"; } - private static string RunArrayPool() + public static string RunArrayPool() { var rented = ArrayPool.Shared.Rent(4); try diff --git a/PersonalSharp.Topics/Modules/ProgrammingParadigmsModule.cs b/PersonalSharp.Topics/Modules/ProgrammingParadigmsModule.cs index 0e3fd04..de055f4 100644 --- a/PersonalSharp.Topics/Modules/ProgrammingParadigmsModule.cs +++ b/PersonalSharp.Topics/Modules/ProgrammingParadigmsModule.cs @@ -15,16 +15,6 @@ public sealed class ProgrammingParadigmsModule : ISnippetModule Title = "Procedural state changes", Scope = "Core", Question = "What does a procedural solution usually optimize for?", - Code = """ - var total = 0; - foreach (var price in prices) - { - if (price >= 15) - { - total += price; - } - } - """, ExpectedOutput = "total=35", Explanation = "Procedural code is direct: execute steps in order and mutate local state. It is simple for small algorithms, but large flows need structure.", Takeaways = ["Procedural code is often easiest to debug step by step.", "Push complex business flows behind named methods."], @@ -36,10 +26,6 @@ public sealed class ProgrammingParadigmsModule : ISnippetModule Title = "OOP polymorphism", Scope = "Core", Question = "How does OOP remove type checks from the caller?", - Code = """ - IDiscount[] discounts = [new PercentageDiscount(10), new FixedDiscount(5)]; - var price = discounts.Aggregate(100m, (current, discount) => discount.Apply(current)); - """, ExpectedOutput = "final=85", Explanation = "The caller works with an abstraction. Each object owns its behavior, so adding a new discount does not require editing the loop.", Takeaways = ["Polymorphism moves branching into implementations.", "Interfaces are useful when behavior varies."], @@ -51,12 +37,6 @@ public sealed class ProgrammingParadigmsModule : ISnippetModule Title = "Functional pipeline", Scope = "Applied", Question = "What does functional style look like in everyday C#?", - Code = """ - var sum = numbers - .Where(IsEven) - .Select(Square) - .Sum(); - """, ExpectedOutput = "sum=20", Explanation = "Functional style favors small pure functions, composition, and fewer visible mutations. LINQ is the most common C# entry point.", Takeaways = ["Pure functions are easy to test.", "Pipelines are readable when each step has one job."], @@ -68,12 +48,6 @@ public sealed class ProgrammingParadigmsModule : ISnippetModule Title = "Declarative LINQ query", Scope = "Applied", Question = "What does declarative code hide from the caller?", - Code = """ - var names = orders - .Where(order => order.Total >= 100) - .OrderBy(order => order.Customer) - .Select(order => order.Customer); - """, ExpectedOutput = "customers=Ada,Linus", Explanation = "Declarative code says what result is needed, not every control-flow step. LINQ keeps filtering, ordering, and projection explicit.", Takeaways = ["Declarative code is still code: understand execution and allocation costs.", "Prefer deterministic ordering in queries."], @@ -85,11 +59,6 @@ public sealed class ProgrammingParadigmsModule : ISnippetModule Title = "Event-driven publish/subscribe", Scope = "Applied", Question = "What changes when code reacts to events instead of direct method calls?", - Code = """ - bus.Subscribe(e => audit.Add($"email:{e.OrderId}")); - bus.Subscribe(e => audit.Add($"stock:{e.OrderId}")); - bus.Publish(new OrderPlaced(42)); - """, ExpectedOutput = "email:42; stock:42", Explanation = "The publisher does not know every reaction. Handlers can be added independently, but ordering, retries, and idempotency become design concerns.", Takeaways = ["Events decouple producers from consumers.", "Event handlers should be idempotent when delivery can be retried."], @@ -101,10 +70,6 @@ public sealed class ProgrammingParadigmsModule : ISnippetModule Title = "Generic programming", Scope = "Applied", Question = "How do generic constraints keep reusable code type-safe?", - Code = """ - static T Best(T left, T right) where T : IScored => - left.Score >= right.Score ? left : right; - """, ExpectedOutput = "winner=compiler", Explanation = "The algorithm is reusable for any type that satisfies the constraint. The compiler guarantees the required members are available.", Takeaways = ["Use constraints to encode what the algorithm needs.", "Generic code should not depend on concrete domain types."], @@ -112,7 +77,7 @@ static T Best(T left, T right) where T : IScored => } ]; - private static string RunProcedural() + public static string RunProcedural() { var prices = new[] { 10, 15, 20 }; var total = 0; @@ -128,14 +93,14 @@ private static string RunProcedural() return $"total={total}"; } - private static string RunOopPolymorphism() + public static string RunOopPolymorphism() { IDiscount[] discounts = [new PercentageDiscount(10), new FixedDiscount(5)]; var price = discounts.Aggregate(100m, (current, discount) => discount.Apply(current)); return $"final={price:0}"; } - private static string RunFunctionalPipeline() + public static string RunFunctionalPipeline() { var numbers = new[] { 1, 2, 3, 4 }; var sum = numbers.Where(IsEven).Select(Square).Sum(); @@ -145,7 +110,7 @@ private static string RunFunctionalPipeline() static int Square(int value) => value * value; } - private static string RunDeclarativeLinq() + public static string RunDeclarativeLinq() { var orders = new[] { @@ -162,7 +127,7 @@ private static string RunDeclarativeLinq() return $"customers={string.Join(",", customers)}"; } - private static string RunEventDriven() + public static string RunEventDriven() { var bus = new EventBus(); var audit = new List(); @@ -174,7 +139,7 @@ private static string RunEventDriven() return string.Join("; ", audit); } - private static string RunGenericProgramming() + public static string RunGenericProgramming() { var winner = Best(new Candidate("runtime", 7), new Candidate("compiler", 9)); return $"winner={winner.Name}"; diff --git a/PersonalSharp.Topics/Modules/RuntimeDiagnosticsModule.cs b/PersonalSharp.Topics/Modules/RuntimeDiagnosticsModule.cs index fdf4709..9990a67 100644 --- a/PersonalSharp.Topics/Modules/RuntimeDiagnosticsModule.cs +++ b/PersonalSharp.Topics/Modules/RuntimeDiagnosticsModule.cs @@ -16,11 +16,6 @@ public sealed class RuntimeDiagnosticsModule : ISnippetModule Title = "GC pressure", Scope = "Detail", Question = "What should you measure when optimizing allocations?", - Code = """ - var before = GC.GetAllocatedBytesForCurrentThread(); - var text = string.Join(",", values); - var allocated = GC.GetAllocatedBytesForCurrentThread() > before; - """, ExpectedOutput = "allocated=True", Explanation = "Allocation count and lifetime often matter more than one isolated micro-optimization.", Takeaways = ["Measure before optimizing.", "Prefer allocation-free APIs only where they matter."], @@ -32,9 +27,6 @@ public sealed class RuntimeDiagnosticsModule : ISnippetModule Title = "IDisposable ownership", Scope = "Applied", Question = "Who should dispose an object?", - Code = """ - using var resource = new OwnedResource(); - """, ExpectedOutput = "disposed=True", Explanation = "The owner that creates or receives ownership of a resource should dispose it. Do not dispose dependencies you do not own.", Takeaways = ["Use using for deterministic cleanup.", "Document ownership transfer."], @@ -46,13 +38,10 @@ public sealed class RuntimeDiagnosticsModule : ISnippetModule Title = "IAsyncDisposable", Scope = "Detail", Question = "When is asynchronous disposal needed?", - Code = """ - await using var resource = new AsyncOwnedResource(); - """, ExpectedOutput = "disposed=True", Explanation = "Use IAsyncDisposable when cleanup itself performs async work, such as flushing network buffers.", Takeaways = ["Async cleanup still needs ownership discipline.", "Prefer await using when cleanup is asynchronous."], - Run = () => RunAsyncDisposeAsync().GetAwaiter().GetResult() + Run = RunAsyncDispose }, new() { @@ -60,10 +49,6 @@ public sealed class RuntimeDiagnosticsModule : ISnippetModule Title = "Finalizer trap", Scope = "Edge", Question = "Why are finalizers not normal cleanup?", - Code = """ - Dispose(); - GC.SuppressFinalize(this); - """, ExpectedOutput = "suppressed=True", Explanation = "Finalizers run nondeterministically and only for unmanaged safety nets. Deterministic disposal is the normal path.", Takeaways = ["Do not rely on finalizers for timely cleanup.", "Suppress finalization after successful dispose."], @@ -75,10 +60,6 @@ public sealed class RuntimeDiagnosticsModule : ISnippetModule Title = "Logging scope", Scope = "Applied", Question = "How do logging scopes add context without passing parameters everywhere?", - Code = """ - using logger.BeginScope("order-42"); - logger.Log("created"); - """, ExpectedOutput = "scope=order-42; message=created", Explanation = "Scopes attach contextual fields to all logs inside a logical operation.", Takeaways = ["Use scopes for request IDs, order IDs, and tenant IDs.", "Do not put secrets in logging context."], @@ -90,10 +71,6 @@ public sealed class RuntimeDiagnosticsModule : ISnippetModule Title = "Activity correlation", Scope = "Detail", Question = "What does Activity provide for distributed tracing?", - Code = """ - using var activity = new Activity("PlaceOrder").Start(); - activity?.SetTag("order.id", "42"); - """, ExpectedOutput = "trace=True; order=42", Explanation = "Activity carries trace/span identity and tags across async flows and service boundaries.", Takeaways = ["Use Activity tags for searchable diagnostic fields.", "Propagate trace context through outbound calls."], @@ -101,7 +78,7 @@ public sealed class RuntimeDiagnosticsModule : ISnippetModule } ]; - private static string RunGcPressure() + public static string RunGcPressure() { var before = GC.GetAllocatedBytesForCurrentThread(); _ = string.Join(",", new[] { "a", "b", "c" }); @@ -109,28 +86,33 @@ private static string RunGcPressure() return $"allocated={allocated}"; } - private static string RunDispose() + public static string RunDispose() { var resource = new OwnedResource(); resource.Dispose(); return $"disposed={resource.Disposed}"; } - private static async Task RunAsyncDisposeAsync() + public static async Task RunAsyncDisposeAsync() { var resource = new AsyncOwnedResource(); await resource.DisposeAsync(); return $"disposed={resource.Disposed}"; } - private static string RunFinalizerTrap() + public static string RunAsyncDispose() + { + return RunAsyncDisposeAsync().GetAwaiter().GetResult(); + } + + public static string RunFinalizerTrap() { var resource = new FinalizableResource(); resource.Dispose(); return $"suppressed={resource.Suppressed}"; } - private static string RunLoggingScope() + public static string RunLoggingScope() { var logger = new CapturingLogger(); using (logger.BeginScope("order-42")) @@ -141,7 +123,7 @@ private static string RunLoggingScope() return $"scope={logger.LastScope}; message={logger.LastMessage}"; } - private static string RunActivity() + public static string RunActivity() { using var activity = new Activity("PlaceOrder").Start(); activity?.SetTag("order.id", "42"); diff --git a/PersonalSharp.Topics/Modules/SecurityModule.cs b/PersonalSharp.Topics/Modules/SecurityModule.cs index 7bc5510..0909808 100644 --- a/PersonalSharp.Topics/Modules/SecurityModule.cs +++ b/PersonalSharp.Topics/Modules/SecurityModule.cs @@ -17,10 +17,6 @@ public sealed class SecurityModule : ISnippetModule Title = "Authentication vs authorization", Scope = "Core", Question = "What is the difference between who you are and what you can do?", - Code = """ - var user = Authenticate(token); - var allowed = Authorize(user, "admin"); - """, ExpectedOutput = "authenticated=True; allowed=False", Explanation = "Authentication establishes identity. Authorization checks permissions for a resource or action.", Takeaways = ["Do not confuse login with permission.", "Authorization should be checked near protected operations."], @@ -32,10 +28,6 @@ public sealed class SecurityModule : ISnippetModule Title = "Claims policy", Scope = "Applied", Question = "How do claims become authorization decisions?", - Code = """ - var policy = new Policy("role", "manager"); - var allowed = policy.Allows(user); - """, ExpectedOutput = "allowed=True", Explanation = "Policies map claims to application permissions. The policy name should express business intent.", Takeaways = ["Claims are facts, not always permissions.", "Centralize authorization policy rules."], @@ -47,12 +39,6 @@ public sealed class SecurityModule : ISnippetModule Title = "JWT validation sketch", Scope = "Detail", Question = "What must be validated before trusting a JWT?", - Code = """ - ValidateIssuer(token); - ValidateAudience(token); - ValidateLifetime(token); - ValidateSignature(token); - """, ExpectedOutput = "issuer=True; audience=False", Explanation = "A JWT is not trusted because it parses. Validate issuer, audience, lifetime, and signature with expected keys.", Takeaways = ["Never accept alg=none.", "Reject tokens for the wrong audience."], @@ -64,10 +50,6 @@ public sealed class SecurityModule : ISnippetModule Title = "CORS origin policy", Scope = "Applied", Question = "What does CORS protect?", - Code = """ - policy.Allows("https://app.example.com"); - policy.Allows("https://evil.example.net"); - """, ExpectedOutput = "allowed=True; denied=False", Explanation = "CORS is a browser policy for cross-origin reads. It is not server-side authorization.", Takeaways = ["Allow only expected origins.", "Do not use wildcard origins with credentials."], @@ -79,9 +61,6 @@ public sealed class SecurityModule : ISnippetModule Title = "Secrets and configuration", Scope = "Core", Question = "Where should secrets come from in a backend service?", - Code = """ - var secret = configuration["ConnectionStrings:Postgres"]; - """, ExpectedOutput = "source=env", Explanation = "Secrets should be injected from environment or secret stores, not committed in source code.", Takeaways = ["Keep examples in .env.example without real values.", "Rotate secrets that may have leaked."], @@ -93,10 +72,6 @@ public sealed class SecurityModule : ISnippetModule Title = "Password hashing vs encryption", Scope = "Applied", Question = "Why do we hash passwords instead of encrypting them?", - Code = """ - var hash = PBKDF2(password, salt); - var verified = FixedTimeEquals(hash, PBKDF2(input, salt)); - """, ExpectedOutput = "verified=True; encrypted=False", Explanation = "Password storage should be one-way and slow. Encryption is reversible and should not be used as password storage.", Takeaways = ["Use a password hashing algorithm with salt and work factor.", "Compare hashes in fixed time."], @@ -108,9 +83,6 @@ public sealed class SecurityModule : ISnippetModule Title = "Input validation boundary", Scope = "Core", Question = "Where should input validation happen?", - Code = """ - var safe = validator.IsSafeDisplayName("Ada Lovelace"); - """, ExpectedOutput = "safe=True", Explanation = "Validate input at boundaries and again enforce invariants in the domain where correctness matters.", Takeaways = ["Validation is not a substitute for output encoding.", "Domain invariants should not depend only on controllers."], @@ -118,21 +90,21 @@ public sealed class SecurityModule : ISnippetModule } ]; - private static string RunAuthnAuthz() + public static string RunAuthnAuthz() { var user = new User("Ada", ["reader"]); var allowed = user.Roles.Contains("admin"); return $"authenticated={user is not null}; allowed={allowed}"; } - private static string RunClaimsPolicy() + public static string RunClaimsPolicy() { var user = new ClaimsUser(new Dictionary { ["role"] = "manager" }); var policy = new Policy("role", "manager"); return $"allowed={policy.Allows(user)}"; } - private static string RunJwtValidation() + public static string RunJwtValidation() { var token = new Token("https://issuer.example.com", "mobile-client"); var issuer = token.Issuer == "https://issuer.example.com"; @@ -140,19 +112,19 @@ private static string RunJwtValidation() return $"issuer={issuer}; audience={audience}"; } - private static string RunCors() + public static string RunCors() { var policy = new CorsPolicy(new HashSet { "https://app.example.com" }); return $"allowed={policy.Allows("https://app.example.com")}; denied={policy.Allows("https://evil.example.net")}"; } - private static string RunSecretsConfig() + public static string RunSecretsConfig() { var config = new Dictionary { ["ConnectionStrings:Postgres"] = "env" }; return $"source={config["ConnectionStrings:Postgres"]}"; } - private static string RunPasswordHashing() + public static string RunPasswordHashing() { var salt = Encoding.UTF8.GetBytes("fixed-salt-for-demo"); var hash = Rfc2898DeriveBytes.Pbkdf2("correct horse", salt, 10_000, HashAlgorithmName.SHA256, 32); @@ -160,7 +132,7 @@ private static string RunPasswordHashing() return $"verified={CryptographicOperations.FixedTimeEquals(hash, input)}; encrypted=False"; } - private static string RunInputValidation() + public static string RunInputValidation() { var validator = new DisplayNameValidator(); return $"safe={validator.IsSafeDisplayName("Ada Lovelace")}"; diff --git a/PersonalSharp.Topics/Modules/SolidPrinciplesModule.cs b/PersonalSharp.Topics/Modules/SolidPrinciplesModule.cs index 73bade5..82649f6 100644 --- a/PersonalSharp.Topics/Modules/SolidPrinciplesModule.cs +++ b/PersonalSharp.Topics/Modules/SolidPrinciplesModule.cs @@ -15,10 +15,6 @@ public sealed class SolidPrinciplesModule : ISnippetModule Title = "Single Responsibility Principle", Scope = "Core", Question = "How do you separate business calculation from presentation?", - Code = """ - var total = calculator.Total(order); - var receipt = formatter.Format(total); - """, ExpectedOutput = "total=30; receipt=Order:30", Explanation = "SRP does not mean one method per class. It means one reason to change: pricing logic and receipt formatting change for different reasons.", Takeaways = ["Separate policies that change for different reasons.", "Small services are easier to test when responsibilities are clear."], @@ -30,10 +26,6 @@ public sealed class SolidPrinciplesModule : ISnippetModule Title = "Open/Closed Principle", Scope = "Applied", Question = "How do you add a new rule without modifying the calculator?", - Code = """ - IDiscountRule[] rules = [new SilverDiscount(), new GoldDiscount()]; - var prices = rules.Select(rule => rule.Apply(100)); - """, ExpectedOutput = "silver=90; gold=80", Explanation = "The calculator is closed for modification because it calls an abstraction. New rules are added as new implementations.", Takeaways = ["OCP works best around stable abstractions.", "Avoid speculative extension points before variation is real."], @@ -45,10 +37,6 @@ public sealed class SolidPrinciplesModule : ISnippetModule Title = "Liskov Substitution Principle", Scope = "Applied", Question = "What does substitutable behavior look like?", - Code = """ - IShape[] shapes = [new Rectangle(3, 4), new Square(3)]; - var area = shapes.Sum(shape => shape.Area); - """, ExpectedOutput = "area=21", Explanation = "Both implementations satisfy the same contract: expose an area. The caller does not depend on stronger assumptions about width or height setters.", Takeaways = ["Subtypes must preserve the expectations of the base contract.", "Prefer narrow contracts over fragile inheritance hierarchies."], @@ -60,10 +48,6 @@ public sealed class SolidPrinciplesModule : ISnippetModule Title = "Interface Segregation Principle", Scope = "Applied", Question = "Why split a large interface into focused capabilities?", - Code = """ - IPrinter printer = new SimplePrinter(); - var output = printer.Print("invoice"); - """, ExpectedOutput = "print=invoice", Explanation = "Clients should not implement or depend on methods they do not use. A printer does not need scanner or fax members.", Takeaways = ["Prefer role interfaces over broad device interfaces.", "Small interfaces make tests and adapters lighter."], @@ -75,11 +59,6 @@ public sealed class SolidPrinciplesModule : ISnippetModule Title = "Dependency Inversion Principle", Scope = "Applied", Question = "How do high-level services avoid depending on infrastructure details?", - Code = """ - var port = new CapturingPaymentPort(); - var service = new CheckoutService(port); - service.Checkout(42); - """, ExpectedOutput = "charged=42", Explanation = "The checkout service depends on a payment port abstraction. The concrete gateway can change without changing the checkout policy.", Takeaways = ["High-level policy should depend on abstractions.", "Constructor injection makes dependencies visible."], @@ -87,7 +66,7 @@ public sealed class SolidPrinciplesModule : ISnippetModule } ]; - private static string RunSrp() + public static string RunSrp() { var order = new Order([10, 20]); var calculator = new OrderTotalCalculator(); @@ -97,26 +76,26 @@ private static string RunSrp() return $"total={total}; receipt={formatter.Format(total)}"; } - private static string RunOcp() + public static string RunOcp() { IDiscountRule[] rules = [new SilverDiscount(), new GoldDiscount()]; var prices = rules.Select(rule => $"{rule.Name}={rule.Apply(100)}"); return string.Join("; ", prices); } - private static string RunLsp() + public static string RunLsp() { IShape[] shapes = [new Rectangle(3, 4), new Square(3)]; return $"area={shapes.Sum(shape => shape.Area)}"; } - private static string RunIsp() + public static string RunIsp() { IPrinter printer = new SimplePrinter(); return printer.Print("invoice"); } - private static string RunDip() + public static string RunDip() { var port = new CapturingPaymentPort(); var service = new CheckoutService(port); diff --git a/PersonalSharp.Topics/Modules/TestingModule.cs b/PersonalSharp.Topics/Modules/TestingModule.cs index d1b7434..b6cbf1e 100644 --- a/PersonalSharp.Topics/Modules/TestingModule.cs +++ b/PersonalSharp.Topics/Modules/TestingModule.cs @@ -15,10 +15,6 @@ public sealed class TestingModule : ISnippetModule Title = "Fixture lifecycle", Scope = "Core", Question = "Why should test setup and cleanup be explicit?", - Code = """ - var fixture = new DbFixture(); - fixture.Dispose(); - """, ExpectedOutput = "created=1; disposed=1", Explanation = "Fixtures make expensive setup reusable and cleanup visible. This matters for databases, files, and ports.", Takeaways = ["Keep fixtures deterministic.", "Avoid hidden shared mutable state across tests."], @@ -30,10 +26,6 @@ public sealed class TestingModule : ISnippetModule Title = "Fake vs mock", Scope = "Applied", Question = "When is a fake better than a mock?", - Code = """ - var bus = new FakeEmailBus(); - service.Register("ada@example.com"); - """, ExpectedOutput = "sent=1", Explanation = "A fake has useful behavior and state. A mock usually verifies interactions. Fakes are often clearer for domain and integration-style tests.", Takeaways = ["Prefer state assertions when possible.", "Mocks are useful at hard external boundaries."], @@ -45,12 +37,6 @@ public sealed class TestingModule : ISnippetModule Title = "Test data builder", Scope = "Applied", Question = "How do builders keep tests readable?", - Code = """ - var order = new OrderBuilder() - .ForCustomer("Ada") - .WithQuantity(2) - .Build(); - """, ExpectedOutput = "customer=Ada; quantity=2", Explanation = "Builders hide irrelevant defaults and make each test name only the data that matters.", Takeaways = ["Keep defaults valid.", "Avoid builders that become alternate production constructors."], @@ -62,11 +48,6 @@ public sealed class TestingModule : ISnippetModule Title = "Deterministic async test", Scope = "Detail", Question = "How do you test async code without timing sleeps?", - Code = """ - var completion = new TaskCompletionSource(); - worker.Start(completion.Task); - completion.SetResult(); - """, ExpectedOutput = "completed=True", Explanation = "Use signals you control instead of arbitrary delays. This makes async tests fast and reliable.", Takeaways = ["Avoid Thread.Sleep in tests.", "Prefer TaskCompletionSource or fake clocks."], @@ -78,12 +59,6 @@ public sealed class TestingModule : ISnippetModule Title = "Contract test", Scope = "Detail", Question = "How do you test that multiple implementations obey the same behavior?", - Code = """ - foreach (var repository in repositories) - { - Contract.SaveAndLoad(repository); - } - """, ExpectedOutput = "cases=2", Explanation = "Contract tests define expected behavior once and run it against every implementation.", Takeaways = ["Use contract tests for adapters.", "They are valuable when fake and real repositories must agree."], @@ -91,14 +66,14 @@ public sealed class TestingModule : ISnippetModule } ]; - private static string RunFixtureLifecycle() + public static string RunFixtureLifecycle() { var fixture = new DbFixture(); fixture.Dispose(); return $"created={DbFixture.Created}; disposed={DbFixture.Disposed}"; } - private static string RunFakeVsMock() + public static string RunFakeVsMock() { var bus = new FakeEmailBus(); var service = new RegistrationService(bus); @@ -106,13 +81,13 @@ private static string RunFakeVsMock() return $"sent={bus.Sent.Count}"; } - private static string RunBuilder() + public static string RunBuilder() { var order = new OrderBuilder().ForCustomer("Ada").WithQuantity(2).Build(); return $"customer={order.Customer}; quantity={order.Quantity}"; } - private static string RunAsyncDeterministic() + public static string RunAsyncDeterministic() { var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var worker = new Worker(); @@ -122,7 +97,7 @@ private static string RunAsyncDeterministic() return $"completed={task.IsCompletedSuccessfully}"; } - private static string RunContract() + public static string RunContract() { IRepository[] repositories = [new InMemoryRepository(), new SecondInMemoryRepository()]; var cases = repositories.Count(RepositoryContract.SaveAndLoad); diff --git a/PersonalSharp.Topics/Modules/WebApiModule.cs b/PersonalSharp.Topics/Modules/WebApiModule.cs index e6fff92..ef19908 100644 --- a/PersonalSharp.Topics/Modules/WebApiModule.cs +++ b/PersonalSharp.Topics/Modules/WebApiModule.cs @@ -15,11 +15,6 @@ public sealed class WebApiModule : ISnippetModule Title = "Middleware order", Scope = "Applied", Question = "Why does ASP.NET Core middleware order matter?", - Code = """ - Use("correlation"); - Use("auth"); - MapEndpoint("endpoint"); - """, ExpectedOutput = "order=correlation>auth>endpoint", Explanation = "Middleware forms a pipeline. Earlier middleware can add context that later middleware and endpoints depend on.", Takeaways = ["Order auth after routing when endpoint metadata matters.", "Put correlation/logging early."], @@ -31,11 +26,6 @@ public sealed class WebApiModule : ISnippetModule Title = "DI lifetimes", Scope = "Applied", Question = "What differs between singleton, scoped, and transient services?", - Code = """ - var singletonA = provider.GetSingleton(); - var singletonB = provider.GetSingleton(); - using var scope = provider.CreateScope(); - """, ExpectedOutput = "singleton=2; scoped=1; transient=False", Explanation = "Singleton lives for the container, scoped lives for a request/scope, and transient creates a new instance every resolution.", Takeaways = ["Do not capture scoped services inside singletons.", "Use scoped for request-bound units of work."], @@ -47,9 +37,6 @@ public sealed class WebApiModule : ISnippetModule Title = "Options pattern", Scope = "Core", Question = "Why bind configuration to options objects?", - Code = """ - var options = new OrderOptions { MaxPageSize = 25 }; - """, ExpectedOutput = "limit=25", Explanation = "Options give configuration a typed shape and make defaults, validation, and dependency injection easier.", Takeaways = ["Avoid scattering raw configuration keys.", "Validate important options at startup."], @@ -61,10 +48,6 @@ public sealed class WebApiModule : ISnippetModule Title = "Typed endpoint results", Scope = "Applied", Question = "How do endpoints make success and failure shapes explicit?", - Code = """ - var ok = Results.Ok(order); - var invalid = Results.ValidationProblem(errors); - """, ExpectedOutput = "ok=200; invalid=400", Explanation = "Typed results document the possible HTTP outcomes and make endpoint tests more direct.", Takeaways = ["Return the narrowest result shape practical.", "Validation failures should use a consistent problem shape."], @@ -76,9 +59,6 @@ public sealed class WebApiModule : ISnippetModule Title = "Problem details validation", Scope = "Applied", Question = "What should a validation failure expose?", - Code = """ - var problem = ValidationProblem("quantity", "Must be positive"); - """, ExpectedOutput = "status=422; errors=quantity", Explanation = "Problem details provide a standard error envelope. Field-level validation errors help clients fix requests.", Takeaways = ["Use stable error codes for clients.", "Do not leak internal exception text."], @@ -90,9 +70,6 @@ public sealed class WebApiModule : ISnippetModule Title = "Request cancellation", Scope = "Applied", Question = "How should a request cancellation token flow through a backend?", - Code = """ - await repository.SaveAsync(order, httpContext.RequestAborted); - """, ExpectedOutput = "canceled=True", Explanation = "ASP.NET Core exposes request cancellation. Pass the token to async dependencies so abandoned requests stop wasting work.", Takeaways = ["Cancellation is cooperative.", "Do not swallow OperationCanceledException as a server error."], @@ -100,7 +77,7 @@ public sealed class WebApiModule : ISnippetModule } ]; - private static string RunMiddlewareOrder() + public static string RunMiddlewareOrder() { var order = new List(); Use("correlation"); @@ -112,7 +89,7 @@ private static string RunMiddlewareOrder() void MapEndpoint(string name) => order.Add(name); } - private static string RunDiLifetimes() + public static string RunDiLifetimes() { var provider = new TinyServiceProvider(); var singletonA = provider.GetSingleton(); @@ -127,26 +104,26 @@ private static string RunDiLifetimes() return $"singleton={singletonA.Count}; scoped={scopedA.Count}; transient={transientSame}"; } - private static string RunOptionsPattern() + public static string RunOptionsPattern() { var options = new OrderOptions { MaxPageSize = 25 }; return $"limit={options.MaxPageSize}"; } - private static string RunTypedResults() + public static string RunTypedResults() { var ok = new HttpResult(200); var invalid = new HttpResult(400); return $"ok={ok.StatusCode}; invalid={invalid.StatusCode}"; } - private static string RunProblemDetails() + public static string RunProblemDetails() { var problem = new ValidationProblem(422, ["quantity"]); return $"status={problem.Status}; errors={string.Join(",", problem.Errors)}"; } - private static string RunCancellation() + public static string RunCancellation() { using var cts = new CancellationTokenSource(); cts.Cancel(); diff --git a/PersonalSharp.Topics/SnippetCatalog.cs b/PersonalSharp.Topics/SnippetCatalog.cs index 9170cc3..810ceb3 100644 --- a/PersonalSharp.Topics/SnippetCatalog.cs +++ b/PersonalSharp.Topics/SnippetCatalog.cs @@ -7,6 +7,7 @@ public static class SnippetCatalog { private static readonly Lazy> ModulesLazy = new(() => [ + new ExamplesModule(), new LanguageCoreModule(), new LinqCollectionsModule(), new GenericsDelegatesModule(), diff --git a/PersonalSharp.UnsafeLab/Snippets/UnsafeSnippetCase.cs b/PersonalSharp.UnsafeLab/Snippets/UnsafeSnippetCase.cs index 5c36d6a..6bfc7f1 100644 --- a/PersonalSharp.UnsafeLab/Snippets/UnsafeSnippetCase.cs +++ b/PersonalSharp.UnsafeLab/Snippets/UnsafeSnippetCase.cs @@ -5,8 +5,8 @@ public sealed record UnsafeSnippetCase public required string Key { get; init; } public required string Title { get; init; } public required string Question { get; init; } - public required string Code { get; init; } public required string ExpectedOutput { get; init; } public required string Explanation { get; init; } public required Func Run { get; init; } + public UnsafeSnippetSource Source => UnsafeSnippetSource.From(Run); } diff --git a/PersonalSharp.UnsafeLab/Snippets/UnsafeSnippetCatalog.cs b/PersonalSharp.UnsafeLab/Snippets/UnsafeSnippetCatalog.cs index 8d04772..4da6a12 100644 --- a/PersonalSharp.UnsafeLab/Snippets/UnsafeSnippetCatalog.cs +++ b/PersonalSharp.UnsafeLab/Snippets/UnsafeSnippetCatalog.cs @@ -11,14 +11,6 @@ public static class UnsafeSnippetCatalog Key = "unsafe.pointer-basics", Title = "Pointer basics", Question = "What does unsafe pointer code bypass in normal C#?", - Code = """ - unsafe - { - int value = 7; - int* pointer = &value; - *pointer = 9; - } - """, ExpectedOutput = "value=9", Explanation = "Unsafe code can address stack locals directly and write through pointers. The compiler stops enforcing normal reference safety inside this block.", Run = RunPointerBasics @@ -28,12 +20,6 @@ public static class UnsafeSnippetCatalog Key = "unsafe.fixed-pinning", Title = "Pinning managed arrays", Question = "Why does fixed exist when taking a pointer to managed memory?", - Code = """ - fixed (byte* pointer = bytes) - { - pointer[1] = 9; - } - """, ExpectedOutput = "bytes=1,9,3", Explanation = "The GC can move managed objects. fixed pins the array for the block so a native pointer remains valid.", Run = RunFixedPinning @@ -43,15 +29,6 @@ public static class UnsafeSnippetCatalog Key = "unsafe.layout", Title = "Explicit struct layout", Question = "How can C# model a binary layout for interop?", - Code = """ - [StructLayout(LayoutKind.Explicit, Size = 8)] - private struct PacketHeader - { - [FieldOffset(0)] public ushort Version; - [FieldOffset(2)] public ushort Flags; - [FieldOffset(4)] public int Length; - } - """, ExpectedOutput = "size=8", Explanation = "Explicit layout fixes field offsets and size, which is useful when matching a wire format or native ABI.", Run = RunExplicitLayout @@ -63,7 +40,7 @@ private struct PacketHeader return All.SingleOrDefault(snippet => string.Equals(snippet.Key, key, StringComparison.OrdinalIgnoreCase)); } - private static unsafe string RunPointerBasics() + public static unsafe string RunPointerBasics() { var value = 7; var pointer = &value; @@ -71,7 +48,7 @@ private static unsafe string RunPointerBasics() return $"value={value}"; } - private static unsafe string RunFixedPinning() + public static unsafe string RunFixedPinning() { var bytes = new byte[] { 1, 2, 3 }; fixed (byte* pointer = bytes) @@ -82,7 +59,7 @@ private static unsafe string RunFixedPinning() return $"bytes={string.Join(",", bytes)}"; } - private static string RunExplicitLayout() + public static string RunExplicitLayout() { return $"size={Marshal.SizeOf()}"; } diff --git a/PersonalSharp.UnsafeLab/Snippets/UnsafeSnippetSource.cs b/PersonalSharp.UnsafeLab/Snippets/UnsafeSnippetSource.cs new file mode 100644 index 0000000..a5565bb --- /dev/null +++ b/PersonalSharp.UnsafeLab/Snippets/UnsafeSnippetSource.cs @@ -0,0 +1,15 @@ +using System.Reflection; + +namespace PersonalSharp.UnsafeLab.Snippets; + +public sealed record UnsafeSnippetSource(string TypeName, string MemberName) +{ + public static UnsafeSnippetSource From(Delegate callback) + { + var method = callback.GetMethodInfo(); + var typeName = method.DeclaringType?.FullName ?? ""; + return new UnsafeSnippetSource(typeName, method.Name); + } + + public override string ToString() => $"{TypeName}.{MemberName}()"; +} diff --git a/PersonalSharp.WebApiLab/WebApiLabApiMarker.cs b/PersonalSharp.WebApiLab/WebApiLabApiMarker.cs new file mode 100644 index 0000000..2b705f7 --- /dev/null +++ b/PersonalSharp.WebApiLab/WebApiLabApiMarker.cs @@ -0,0 +1,3 @@ +namespace PersonalSharp.WebApiLab; + +public sealed class WebApiLabApiMarker; diff --git a/PersonalSharp.slnx b/PersonalSharp.slnx index c7776f4..dec7757 100644 --- a/PersonalSharp.slnx +++ b/PersonalSharp.slnx @@ -1,6 +1,8 @@ + + diff --git a/README.md b/README.md index 1c844a0..159c88c 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,10 @@ DOTNET_CLI_HOME=/tmp HOME=/tmp dotnet run --project PersonalSharp.Cli -- linq.de Run representative topics: ```bash +DOTNET_CLI_HOME=/tmp HOME=/tmp dotnet run --project PersonalSharp.Cli -- example.acumatica-wms-pick-pack-ship +DOTNET_CLI_HOME=/tmp HOME=/tmp dotnet run --project PersonalSharp.Cli -- example.acumatica-wms-receiving +DOTNET_CLI_HOME=/tmp HOME=/tmp dotnet run --project PersonalSharp.Cli -- example.acumatica-idempotent-sync +DOTNET_CLI_HOME=/tmp HOME=/tmp dotnet run --project PersonalSharp.PostalLogisticsCenter DOTNET_CLI_HOME=/tmp HOME=/tmp dotnet run --project PersonalSharp.Cli -- paradigm.event-driven DOTNET_CLI_HOME=/tmp HOME=/tmp dotnet run --project PersonalSharp.Cli -- functional.railway-bind DOTNET_CLI_HOME=/tmp HOME=/tmp dotnet run --project PersonalSharp.Cli -- pattern.strategy @@ -32,6 +36,8 @@ DOTNET_CLI_HOME=/tmp HOME=/tmp dotnet run --project PersonalSharp.Cli -- concurr DOTNET_CLI_HOME=/tmp HOME=/tmp dotnet run --project PersonalSharp.Cli -- trap.async-void ``` +See `topics/examples/postal-logistics-center.md` for the postal logistics center Akka.NET/Cosmos DB walkthrough. + Run all snippets: ```bash @@ -64,6 +70,7 @@ See `docs/ci.md` for the full CI contract. ## Shape - `PersonalSharp.Cli`: console runner. +- `PersonalSharp.PostalLogisticsCenter`: Akka.NET postal logistics center example with line-level CN22 customs processing and optional Cosmos DB event persistence. - `PersonalSharp.Topics`: C#/.NET snippets and bug hunts. - `PersonalSharp.UnsafeLab`: isolated unsafe and low-level snippets. - `PersonalSharp.WebApiLab`: Minimal API lab with functional core and in-memory default infrastructure. @@ -75,6 +82,7 @@ See `docs/ci.md` for the full CI contract. ## Topic Map +- `example.*` - `language.*`, `collections.*`, `linq.*`, `generics.*`, `delegates.*` - `paradigm.*`, `functional.*`, `solid.*`, `pattern.*` - `webapi.*`, `data.*`, `postgres.*`, `cosmos.*`, `testing.*` diff --git a/topics/examples/postal-logistics-center.md b/topics/examples/postal-logistics-center.md new file mode 100644 index 0000000..59088d6 --- /dev/null +++ b/topics/examples/postal-logistics-center.md @@ -0,0 +1,86 @@ +# Postal Logistics Center + +This mini app models a mixed postal logistics hub with Akka.NET actors, CN22 customs processing, and Cosmos DB-style event persistence. + +Run the deterministic in-memory demo: + +```bash +DOTNET_CLI_HOME=/tmp HOME=/tmp dotnet run --project PersonalSharp.PostalLogisticsCenter +``` + +Run with real Cosmos DB event appends when a local emulator or account is already available: + +```bash +POSTAL_LOGISTICS_USE_COSMOS=true \ +COSMOS_CONNECTION_STRING='AccountEndpoint=https://localhost:8081/;AccountKey=;' \ +COSMOS_DATABASE=personalsharp \ +COSMOS_CONTAINER=postal-logistics-events \ +COSMOS_ALLOW_INSECURE_EMULATOR_CERT=true \ +DOTNET_CLI_HOME=/tmp HOME=/tmp dotnet run --project PersonalSharp.PostalLogisticsCenter +``` + +## Actor Map + +- `CenterSupervisor` creates the station actors and parcel state actors. +- `AcceptanceActor` records physical acceptance. +- `SortingActor` assigns export or import lane and preserves EMS priority. +- `Cn22CustomsActor` applies the risk-first CN22 decision. +- `DispatchActor` creates the manifest for released parcels. +- `ParcelStateActor` keeps the current lifecycle state per tracking id. + +## CN22 Flow + +The demo now treats CN22 as a line-level customs declaration, not as a single free-text field. The model validates: + +- CN22/CN23 document fit: values above 300 SDR are routed to CN23-required review. +- Per-content-line description, quantity, net weight, value, currency, country of origin, and HS tariff code. +- HS tariff code shape: 6, 8, or 10 digits; 7/9 digit codes and alphabetic prefixes are not accepted. +- ISO-style country of origin shape: two uppercase letters. +- Declaration totals: declared line values must add up to the declaration total, and net/gross weights must be consistent with the parcel gross weight. +- Sender signature, date, and certification that the item does not contain prohibited or dangerous goods. +- Electronic advance data presence for goods-bearing items under this hub policy. +- Commercial invoice presence as a warning for commercial items, not a release blocker by itself. + +The deterministic scenario uses two parcels: + +- `EU123456789DE`: export packet with a specific goods description, commercial invoice reference, HS code `732393`, German country of origin, sender signature, and ITMATT-like EAD id. It is released and dispatched. +- `US987654321US`: import EMS item with description `parts`, no tariff code, and no EAD id. It is held for manual customs review. + +The Akka.NET `Cn22CustomsActor` persists a release/hold event with the document type, EAD state, and blocking reasons. The decision object keeps both the compact blocking `Reasons` and the full `Issues` list with severity, rule id, field, and message. + +## Public Documentation Basis + +This example is based on public, non-account documentation: + +- UPU Convention Manual, CN22 form instructions: CN22 requires applicable fields, CN23 when content value is more than 300 SDR, detailed descriptions instead of generic descriptions, weight/value/currency per article, HS tariff number, country of origin, and sender signature/date. +- WCO-UPU EAD and Data Quality Guidelines 2025: HS tariff numbers are six-digit Harmonized System codes, codes shorter than 6 digits are not allowed, 8 or 10 digit extensions may be used, alphabetic prefixes are unsupported, and country of origin uses ISO 3166-1 alpha-2. +- UPU customs/CDS page: posts and customs exchange advance data, and CDS supports EDI messaging before the package is sent. +- WCO Harmonized System overview: HS is a WCO-developed product nomenclature with six-digit commodity groups used by more than 200 countries/economies. +- USPS public customs forms guidance: every item needs a detailed, clear, specific description, and vague categories such as `electronics`, `tools`, or `parts` are not enough for customs processing. +- EU ICS2 page: EU-bound or EU-transiting goods require advance safety/security data in Entry Summary Declarations before arrival; this sample models that kind of EAD gate as a configurable hub policy. + +References: + +- +- +- +- +- +- + +## Production Boundaries + +The processor is production-shaped, but it is still an example. It deliberately avoids pretending to be a legal customs engine. + +Production hardening would add: + +- Destination-specific prohibitions and restrictions, including dangerous goods and quarantine rules. +- Real HS lookup/classification workflow with operator override, evidence, and audit logs. +- SDR conversion by posting date and postal operator currency policy. +- UPU/ITMATT/CUSITM/CUSRSP schema validation at message boundary. +- Jurisdiction-specific rules for IOSS/VAT, duties, de minimis thresholds, return flows, licences, certificates, and sanctions screening. +- Operator SLA workflows for customs responses, inspections, tax collection, return to sender, seizure, and destruction. + +## Persistence + +The default event store is in-memory so local runs do not need Docker, Podman, a Cosmos emulator, or secrets. Real Cosmos mode appends lifecycle events only; read models and snapshots stay out of scope for this example.