From f7e1f4a7363b1548bcef13bf77537002a10fb0e7 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sun, 13 Sep 2026 03:49:07 +0100 Subject: [PATCH] list only my billing codes --- src/Yavsc.Api.Test/BillingControllerTests.cs | 69 +++++++ .../Fixtures/ApiWebServerFixture.cs | 169 +++++++++++------- .../Controllers/Business/BillingController.cs | 77 +++++--- 3 files changed, 225 insertions(+), 90 deletions(-) diff --git a/src/Yavsc.Api.Test/BillingControllerTests.cs b/src/Yavsc.Api.Test/BillingControllerTests.cs index d06ae7dba..43c2df729 100644 --- a/src/Yavsc.Api.Test/BillingControllerTests.cs +++ b/src/Yavsc.Api.Test/BillingControllerTests.cs @@ -7,6 +7,7 @@ using Yavsc.Api.Test.Fixtures; using Yavsc.Helpers; using Yavsc.Models; using Yavsc.Models.Billing; +using Yavsc.Models.Haircut; using Yavsc.Models.Workflow; using Yavsc.Tests.Shared; @@ -154,6 +155,74 @@ VALUES Assert.DoesNotContain(payload!, item => string.IsNullOrWhiteSpace(item.BillingCode)); } + [Fact] + public async Task GetProviderOngoingCommands_returns_haircut_and_grouped_haircut_requests() + { + WorkflowHelpers.ConfigureBillingService(); + _fixture.ResetAndSeedHaircutGraph(); + + using (var scope = _fixture.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.UserActivities.Add(new UserActivity + { + UserId = "alice", + DoesCode = "brush", + Weight = 50, + }); + db.UserActivities.Add(new UserActivity + { + UserId = "alice", + DoesCode = "mbrush", + Weight = 50, + }); + db.CommandForm.Add(new CommandForm + { + ActivityCode = "brush", + ActionName = BillingCodes.Brush, + Title = "Brush", + }); + db.CommandForm.Add(new CommandForm + { + ActivityCode = "mbrush", + ActionName = BillingCodes.MBrush, + Title = "MBrush", + }); + db.SaveChanges(); + } + + using var http = NewClient(); + + var response = await http.GetAsync("/api/v1/bill/provider/ongoing", TestContext.Current.CancellationToken); + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + + Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}"); + + var payload = await response.Content.ReadFromJsonAsync>(TestContext.Current.CancellationToken); + Assert.NotNull(payload); + Assert.Contains(payload!, item => item.BillingCode == BillingCodes.Brush && item.PerformerId == "alice"); + Assert.Contains(payload!, item => item.BillingCode == BillingCodes.MBrush && item.PerformerId == "alice"); + } + + [Fact] + public async Task GetProviderOngoingCommands_excludes_requests_outside_performer_declared_activities() + { + WorkflowHelpers.ConfigureBillingService(); + _fixture.ResetAndSeedHaircutGraph(); + + using var http = NewClient(); + + var response = await http.GetAsync("/api/v1/bill/provider/ongoing", TestContext.Current.CancellationToken); + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + + Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}"); + + var payload = await response.Content.ReadFromJsonAsync>(TestContext.Current.CancellationToken); + Assert.NotNull(payload); + Assert.DoesNotContain(payload!, item => item.BillingCode == BillingCodes.Brush); + Assert.DoesNotContain(payload!, item => item.BillingCode == BillingCodes.MBrush); + } + private sealed class ProviderOngoingCommandDto { public long Id { get; set; } diff --git a/src/Yavsc.Api.Test/Fixtures/ApiWebServerFixture.cs b/src/Yavsc.Api.Test/Fixtures/ApiWebServerFixture.cs index 3079e48c5..29d282a32 100644 --- a/src/Yavsc.Api.Test/Fixtures/ApiWebServerFixture.cs +++ b/src/Yavsc.Api.Test/Fixtures/ApiWebServerFixture.cs @@ -1,10 +1,10 @@ using Microsoft.AspNetCore.Builder; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.IdentityModel.Tokens; using Npgsql; -using System.Runtime.Loader; using Yavsc.Controllers; using Yavsc.Interfaces.Workflow; using Yavsc.Models; @@ -22,6 +22,7 @@ public sealed class ApiWebServerFixture : WebHostFixture { private const string DbProviderEnvVar = "YAVSC_API_TEST_DB_PROVIDER"; private const string NpgsqlAdminConnectionEnvVar = "YAVSC_API_TEST_NPGSQL_ADMIN_CONNECTION"; + private const string DedicatedNpgsqlDatabaseName = "yavscTestDb"; private const string DefaultDevelopmentConnectionString = "Server=localhost;Port=5432;Database=yavscdev;Username=yavscdev;Password=8*5idas;Include Error Detail=true"; protected override int HttpsPort => 5104; @@ -30,9 +31,6 @@ public sealed class ApiWebServerFixture : WebHostFixture private static readonly object _sqliteLock = new(); private static readonly object _npgsqlLock = new(); private static string? _sharedNpgsqlConnectionString; - private static string? _sharedNpgsqlAdminConnectionString; - private static string? _sharedNpgsqlDatabaseName; - private static bool _npgsqlCleanupRegistered; protected override WebApplication BuildApp(WebApplicationBuilder builder) { @@ -126,14 +124,21 @@ public sealed class ApiWebServerFixture : WebHostFixture } var adminConnectionString = BuildAdminConnectionString(); - var databaseName = $"yavsc_api_test_{Guid.NewGuid():N}"; + var databaseName = DedicatedNpgsqlDatabaseName; using (var adminConnection = new NpgsqlConnection(adminConnectionString)) { adminConnection.Open(); - using var createCommand = adminConnection.CreateCommand(); - createCommand.CommandText = $"CREATE DATABASE \"{databaseName}\""; - createCommand.ExecuteNonQuery(); + using var existsCommand = adminConnection.CreateCommand(); + existsCommand.CommandText = "SELECT 1 FROM pg_database WHERE datname = @databaseName"; + existsCommand.Parameters.AddWithValue("databaseName", databaseName); + + if (existsCommand.ExecuteScalar() is null) + { + using var createCommand = adminConnection.CreateCommand(); + createCommand.CommandText = $"CREATE DATABASE \"{databaseName}\""; + createCommand.ExecuteNonQuery(); + } } var testConnectionBuilder = new NpgsqlConnectionStringBuilder(adminConnectionString) @@ -143,10 +148,7 @@ public sealed class ApiWebServerFixture : WebHostFixture IncludeErrorDetail = true }; - _sharedNpgsqlAdminConnectionString = adminConnectionString; - _sharedNpgsqlDatabaseName = databaseName; _sharedNpgsqlConnectionString = testConnectionBuilder.ToString(); - RegisterNpgsqlCleanup(); return _sharedNpgsqlConnectionString; } } @@ -176,61 +178,6 @@ public sealed class ApiWebServerFixture : WebHostFixture return builder.ToString(); } - private static void RegisterNpgsqlCleanup() - { - if (_npgsqlCleanupRegistered) - { - return; - } - - AppDomain.CurrentDomain.ProcessExit += (_, __) => DropTemporaryNpgsqlDatabase(); - AssemblyLoadContext.Default.Unloading += _ => DropTemporaryNpgsqlDatabase(); - _npgsqlCleanupRegistered = true; - } - - private static void DropTemporaryNpgsqlDatabase() - { - lock (_npgsqlLock) - { - if (string.IsNullOrWhiteSpace(_sharedNpgsqlDatabaseName) - || string.IsNullOrWhiteSpace(_sharedNpgsqlAdminConnectionString)) - { - return; - } - - try - { - using var adminConnection = new NpgsqlConnection(_sharedNpgsqlAdminConnectionString); - adminConnection.Open(); - - using (var terminateCommand = adminConnection.CreateCommand()) - { - terminateCommand.CommandText = @" -SELECT pg_terminate_backend(pid) -FROM pg_stat_activity -WHERE datname = @databaseName - AND pid <> pg_backend_pid();"; - terminateCommand.Parameters.AddWithValue("databaseName", _sharedNpgsqlDatabaseName); - terminateCommand.ExecuteNonQuery(); - } - - using var dropCommand = adminConnection.CreateCommand(); - dropCommand.CommandText = $"DROP DATABASE IF EXISTS \"{_sharedNpgsqlDatabaseName}\""; - dropCommand.ExecuteNonQuery(); - } - catch - { - // Best-effort cleanup only. - } - finally - { - _sharedNpgsqlConnectionString = null; - _sharedNpgsqlAdminConnectionString = null; - _sharedNpgsqlDatabaseName = null; - } - } - } - private sealed class NoopMessageSender : IYavscMessageSender { public Task NotifyBookQueryAsync(IEnumerable connectionIds, RdvQueryEvent ev) @@ -251,7 +198,7 @@ WHERE datname = @databaseName using var scope = Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - db.Database.EnsureDeleted(); + ResetDatabase(db); db.Database.EnsureCreated(); var user = new ApplicationUser @@ -352,6 +299,94 @@ WHERE datname = @databaseName db.SaveChanges(); } + private static void ResetDatabase(ApplicationDbContext db) + { + if (UseNpgsqlProvider()) + { + db.Set().RemoveRange(db.Set()); + db.Set().RemoveRange(db.Set()); + db.Set().RemoveRange(db.Set()); + db.Set().RemoveRange(db.Set()); + db.Set().RemoveRange(db.Set()); + db.Set().RemoveRange(db.Set()); + db.Set().RemoveRange(db.Set()); + db.Set().RemoveRange(db.Set()); + db.Set().RemoveRange(db.Set()); + db.Set().RemoveRange(db.Set()); + + db.SaveChanges(); + return; + } + + db.Database.EnsureDeleted(); + } + + + + private static IReadOnlyList GetDeletionOrder(IModel model) + { + var entityTypes = model + .GetEntityTypes() + .Where(et => + et.ClrType is not null && + !et.IsOwned() && + et.FindPrimaryKey() is not null) + .ToArray(); + + var included = new HashSet(entityTypes); + var dependencies = new Dictionary>(); + + foreach (var entityType in entityTypes) + { + var principals = entityType + .GetForeignKeys() + .Where(fk => !fk.IsOwnership) + .Select(fk => fk.PrincipalEntityType) + .Where(included.Contains) + .ToHashSet(); + + dependencies[entityType] = principals; + } + + var queue = new Queue( + dependencies.Where(kvp => kvp.Value.Count == 0).Select(kvp => kvp.Key)); + + var order = new List(entityTypes.Length); + + while (queue.Count > 0) + { + var current = queue.Dequeue(); + if (!order.Contains(current)) + { + order.Add(current); + } + + foreach (var kvp in dependencies) + { + if (!kvp.Value.Remove(current) || kvp.Value.Count != 0) + { + continue; + } + + if (!order.Contains(kvp.Key) && !queue.Contains(kvp.Key)) + { + queue.Enqueue(kvp.Key); + } + } + } + + // If cycles remain (rare), append unresolved types last and rely on DB cascades. + foreach (var entityType in entityTypes) + { + if (!order.Contains(entityType)) + { + order.Add(entityType); + } + } + + return order; + } + public void ResetAndSeedRdvQueryGraph() { ResetAndSeedActivityGraph(); diff --git a/src/Yavsc.Api/Controllers/Business/BillingController.cs b/src/Yavsc.Api/Controllers/Business/BillingController.cs index 219431b7e..d10e166e3 100644 --- a/src/Yavsc.Api/Controllers/Business/BillingController.cs +++ b/src/Yavsc.Api/Controllers/Business/BillingController.cs @@ -123,34 +123,65 @@ namespace Yavsc.ApiControllers WorkflowHelpers.ConfigureBillingService(); } - // Query known derived types explicitly so legacy rows with - // invalid/empty discriminator values are naturally ignored. - var rdvCommands = dbContext.Set() + var allowedActivityCodes = dbContext.UserActivities .AsNoTracking() - .Where(q => q.PerformerId == uid) - .Where(q => q.Status == QueryStatus.Inserted - || q.Status == QueryStatus.Accepted - || q.Status == QueryStatus.InProgress) - .Cast() + .Where(a => a.UserId == uid) + .Select(a => a.DoesCode) + .Distinct() .ToList(); - var hairCommands = dbContext.Set() - .AsNoTracking() - .Where(q => q.PerformerId == uid) - .Where(q => q.Status == QueryStatus.Inserted - || q.Status == QueryStatus.Accepted - || q.Status == QueryStatus.InProgress) - .Cast() - .ToList(); + if (allowedActivityCodes.Count == 0) + { + return Ok(Array.Empty()); + } - var hairMultiCommands = dbContext.Set() + var allowedBillingCodes = dbContext.CommandForm .AsNoTracking() - .Where(q => q.PerformerId == uid) - .Where(q => q.Status == QueryStatus.Inserted - || q.Status == QueryStatus.Accepted - || q.Status == QueryStatus.InProgress) - .Cast() - .ToList(); + .Where(form => allowedActivityCodes.Contains(form.ActivityCode)) + .Select(form => form.ActionName) + .Where(actionName => !string.IsNullOrWhiteSpace(actionName)) + .Distinct() + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var fallbackToActivityFilteringOnly = allowedBillingCodes.Count == 0; + + // Query only the command types allowed by the performer's declared + // activities; this avoids touching unrelated legacy slices. + var rdvCommands = fallbackToActivityFilteringOnly || allowedBillingCodes.Contains(BillingCodes.Rdv) + ? dbContext.Set() + .AsNoTracking() + .Where(q => q.PerformerId == uid) + .Where(q => allowedActivityCodes.Contains(q.ActivityCode)) + .Where(q => q.Status == QueryStatus.Inserted + || q.Status == QueryStatus.Accepted + || q.Status == QueryStatus.InProgress) + .Cast() + .ToList() + : new List(); + + var hairCommands = fallbackToActivityFilteringOnly || allowedBillingCodes.Contains(BillingCodes.Brush) + ? dbContext.Set() + .AsNoTracking() + .Where(q => q.PerformerId == uid) + .Where(q => allowedActivityCodes.Contains(q.ActivityCode)) + .Where(q => q.Status == QueryStatus.Inserted + || q.Status == QueryStatus.Accepted + || q.Status == QueryStatus.InProgress) + .Cast() + .ToList() + : new List(); + + var hairMultiCommands = fallbackToActivityFilteringOnly || allowedBillingCodes.Contains(BillingCodes.MBrush) + ? dbContext.Set() + .AsNoTracking() + .Where(q => q.PerformerId == uid) + .Where(q => allowedActivityCodes.Contains(q.ActivityCode)) + .Where(q => q.Status == QueryStatus.Inserted + || q.Status == QueryStatus.Accepted + || q.Status == QueryStatus.InProgress) + .Cast() + .ToList() + : new List(); var commands = rdvCommands .Concat(hairCommands)