diff --git a/backend/src/RestaurantPOS.API/Contracts/Inventory/RawMaterialRequests.cs b/backend/src/RestaurantPOS.API/Contracts/Inventory/RawMaterialRequests.cs new file mode 100644 index 0000000..bcbd9fa --- /dev/null +++ b/backend/src/RestaurantPOS.API/Contracts/Inventory/RawMaterialRequests.cs @@ -0,0 +1,11 @@ +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.API.Contracts.Inventory; + +public sealed record CreateRawMaterialRequest( + string Name, UnitOfMeasurement UnitOfMeasurement, decimal? MainStoreReorderLevel, decimal? KitchenParLevel); + +public sealed record UpdateRawMaterialRequest( + string Name, UnitOfMeasurement UnitOfMeasurement, decimal? MainStoreReorderLevel, decimal? KitchenParLevel); + +public sealed record SetRawMaterialActiveRequest(bool IsActive); \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/Contracts/Inventory/StockRequests.cs b/backend/src/RestaurantPOS.API/Contracts/Inventory/StockRequests.cs new file mode 100644 index 0000000..3e041a7 --- /dev/null +++ b/backend/src/RestaurantPOS.API/Contracts/Inventory/StockRequests.cs @@ -0,0 +1,25 @@ +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.API.Contracts.Inventory; + +public sealed record StockLineRequest(Guid RawMaterialId, decimal Quantity); + +public sealed record CreateGoodsReceivedNoteRequest( + Guid SupplierId, + IReadOnlyCollection Lines, + string? Notes, + Guid? PurchaseOrderId = null, + int? QualityRating = null, + bool HasIssue = false); + +/// +/// Both the raw material quantities and the administrator's approval PIN travel in one request, +/// since a release is created only once it is already approved. +/// +public sealed record CreateStockReleaseRequest( + IReadOnlyCollection Lines, string Pin, string? Notes); + +public sealed record CreateStockAdjustmentRequest( + Guid RawMaterialId, StoreType Store, decimal QuantityDelta, string Reason); + +public sealed record ConsumeStockRequest(decimal QuantitySold); \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/Contracts/Recipes/MenuItemRequests.cs b/backend/src/RestaurantPOS.API/Contracts/Recipes/MenuItemRequests.cs new file mode 100644 index 0000000..03d6511 --- /dev/null +++ b/backend/src/RestaurantPOS.API/Contracts/Recipes/MenuItemRequests.cs @@ -0,0 +1,7 @@ +namespace RestaurantPOS.API.Contracts.Recipes; + +public sealed record CreateMenuItemRequest(string Name, string Category, decimal Price); + +public sealed record UpdateMenuItemRequest(string Name, string Category, decimal Price); + +public sealed record SetMenuItemActiveRequest(bool IsActive); \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/Contracts/Recipes/RecipeRequests.cs b/backend/src/RestaurantPOS.API/Contracts/Recipes/RecipeRequests.cs new file mode 100644 index 0000000..7a93fbc --- /dev/null +++ b/backend/src/RestaurantPOS.API/Contracts/Recipes/RecipeRequests.cs @@ -0,0 +1,8 @@ +namespace RestaurantPOS.API.Contracts.Recipes; + +public sealed record RecipeLineRequest(Guid RawMaterialId, decimal Quantity); + +/// Creates the menu item's recipe if it has none, or replaces its lines if it does. +public sealed record UpsertRecipeRequest(IReadOnlyCollection Lines); + +public sealed record SetRecipeEnabledRequest(bool IsEnabled); \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/Contracts/Suppliers/PurchaseOrderRequests.cs b/backend/src/RestaurantPOS.API/Contracts/Suppliers/PurchaseOrderRequests.cs new file mode 100644 index 0000000..f0ab67f --- /dev/null +++ b/backend/src/RestaurantPOS.API/Contracts/Suppliers/PurchaseOrderRequests.cs @@ -0,0 +1,14 @@ +namespace RestaurantPOS.API.Contracts.Suppliers; + +public sealed record PurchaseOrderLineRequest(Guid RawMaterialId, decimal Quantity, decimal UnitPrice); + +public sealed record CreatePurchaseOrderRequest( + Guid SupplierId, + IReadOnlyCollection Lines, + DateTime? ExpectedDeliveryDate, + string? Notes); + +public sealed record UpdatePurchaseOrderRequest( + IReadOnlyCollection Lines, + DateTime? ExpectedDeliveryDate, + string? Notes); \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/Contracts/Suppliers/SupplierPaymentRequests.cs b/backend/src/RestaurantPOS.API/Contracts/Suppliers/SupplierPaymentRequests.cs new file mode 100644 index 0000000..155b765 --- /dev/null +++ b/backend/src/RestaurantPOS.API/Contracts/Suppliers/SupplierPaymentRequests.cs @@ -0,0 +1,10 @@ +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.API.Contracts.Suppliers; + +public sealed record RecordSupplierPaymentRequest( + decimal Amount, + DateTime PaymentDateUtc, + PaymentMethod Method, + string? InvoiceReference, + string? Notes); \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/Contracts/Suppliers/SupplierPriceRequests.cs b/backend/src/RestaurantPOS.API/Contracts/Suppliers/SupplierPriceRequests.cs new file mode 100644 index 0000000..951baed --- /dev/null +++ b/backend/src/RestaurantPOS.API/Contracts/Suppliers/SupplierPriceRequests.cs @@ -0,0 +1,3 @@ +namespace RestaurantPOS.API.Contracts.Suppliers; + +public sealed record SetSupplierPriceRequest(Guid RawMaterialId, decimal Price); \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/Contracts/Suppliers/SupplierRequests.cs b/backend/src/RestaurantPOS.API/Contracts/Suppliers/SupplierRequests.cs new file mode 100644 index 0000000..4eeaf5b --- /dev/null +++ b/backend/src/RestaurantPOS.API/Contracts/Suppliers/SupplierRequests.cs @@ -0,0 +1,23 @@ +namespace RestaurantPOS.API.Contracts.Suppliers; + +public sealed record CreateSupplierRequest( + string Name, + string? ContactName, + string? Phone, + string? Email, + string? Address, + int PaymentTermsDays, + decimal? CreditLimit, + int? LeadTimeDays); + +public sealed record UpdateSupplierRequest( + string Name, + string? ContactName, + string? Phone, + string? Email, + string? Address, + int PaymentTermsDays, + decimal? CreditLimit, + int? LeadTimeDays); + +public sealed record SetSupplierActiveRequest(bool IsActive); \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/Endpoints/InventoryEndpoints.cs b/backend/src/RestaurantPOS.API/Endpoints/InventoryEndpoints.cs new file mode 100644 index 0000000..6090af9 --- /dev/null +++ b/backend/src/RestaurantPOS.API/Endpoints/InventoryEndpoints.cs @@ -0,0 +1,234 @@ +using MediatR; + +using RestaurantPOS.API.Contracts.Inventory; +using RestaurantPOS.API.Extensions; +using RestaurantPOS.API.Security; +using RestaurantPOS.Application.Inventory.Commands.ConsumeStockForSale; +using RestaurantPOS.Application.Inventory.Commands.CreateGoodsReceivedNote; +using RestaurantPOS.Application.Inventory.Commands.CreateRawMaterial; +using RestaurantPOS.Application.Inventory.Commands.CreateStockAdjustment; +using RestaurantPOS.Application.Inventory.Commands.CreateStockRelease; +using RestaurantPOS.Application.Inventory.Commands.SetRawMaterialActive; +using RestaurantPOS.Application.Inventory.Commands.UpdateRawMaterial; +using RestaurantPOS.Application.Inventory.Queries.GetGoodsReceivedNoteById; +using RestaurantPOS.Application.Inventory.Queries.GetGoodsReceivedNotes; +using RestaurantPOS.Application.Inventory.Queries.GetRawMaterials; +using RestaurantPOS.Application.Inventory.Queries.GetStockLevels; +using RestaurantPOS.Application.Inventory.Queries.GetStockMovements; +using RestaurantPOS.Application.Inventory.Queries.GetStockReleaseById; +using RestaurantPOS.Application.Inventory.Queries.GetStockReleases; +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.API.Endpoints; + +/// Raw materials, and the two-store stock ledger (Main Store and Kitchen). +public static class InventoryEndpoints +{ + public static IEndpointRouteBuilder MapInventoryEndpoints(this IEndpointRouteBuilder routes) + { + ArgumentNullException.ThrowIfNull(routes); + + MapRawMaterialEndpoints(routes); + MapMainStoreEndpoints(routes); + MapKitchenEndpoints(routes); + MapReleaseEndpoints(routes); + + return routes; + } + + private static void MapRawMaterialEndpoints(IEndpointRouteBuilder routes) + { + var group = routes.MapGroup("/raw-materials") + .WithTags("Inventory") + .RequireAuthorization(AuthorizationPolicies.ForModule(AppModule.StoreStockManagement)); + + group.MapGet("/", async (string? search, bool? isActive, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new GetRawMaterialsQuery(search, isActive), ct); + return result.ToHttpResult(); + }) + .WithName("GetRawMaterials") + .WithSummary("Lists raw materials, optionally filtered."); + + group.MapPost("/", async (CreateRawMaterialRequest request, ISender sender, CancellationToken ct) => + { + var command = new CreateRawMaterialCommand( + request.Name, request.UnitOfMeasurement, request.MainStoreReorderLevel, request.KitchenParLevel); + var result = await sender.Send(command, ct); + return result.ToCreatedResult(r => $"/api/v1/raw-materials/{r.Id}"); + }) + .WithName("CreateRawMaterial") + .WithSummary("Creates a raw material."); + + group.MapPut("/{id:guid}", async ( + Guid id, UpdateRawMaterialRequest request, ISender sender, CancellationToken ct) => + { + var command = new UpdateRawMaterialCommand( + id, request.Name, request.UnitOfMeasurement, request.MainStoreReorderLevel, request.KitchenParLevel); + var result = await sender.Send(command, ct); + return result.ToHttpResult(); + }) + .WithName("UpdateRawMaterial") + .WithSummary("Updates a raw material's details."); + + group.MapPut("/{id:guid}/status", async ( + Guid id, SetRawMaterialActiveRequest request, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new SetRawMaterialActiveCommand(id, request.IsActive), ct); + return result.ToHttpResult(); + }) + .WithName("SetRawMaterialActive") + .WithSummary("Activates or deactivates a raw material."); + } + + private static void MapMainStoreEndpoints(IEndpointRouteBuilder routes) + { + var group = routes.MapGroup("/inventory/main-store") + .WithTags("Inventory") + .RequireAuthorization(AuthorizationPolicies.ForModule(AppModule.StoreStockManagement)); + + group.MapGet("/stock", async (bool? lowStockOnly, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new GetStockLevelsQuery(StoreType.MainStore, lowStockOnly ?? false), ct); + return result.ToHttpResult(); + }) + .WithName("GetMainStoreStock") + .WithSummary("The current stock level of every active raw material in the Main Store."); + + group.MapGet("/movements", async ( + Guid? rawMaterialId, DateTime? from, DateTime? to, ISender sender, CancellationToken ct) => + { + var result = await sender.Send( + new GetStockMovementsQuery(StoreType.MainStore, rawMaterialId, from, to), ct); + return result.ToHttpResult(); + }) + .WithName("GetMainStoreMovements") + .WithSummary("The Main Store's stock history, optionally filtered."); + + group.MapGet("/goods-received", async (ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new GetGoodsReceivedNotesQuery(), ct); + return result.ToHttpResult(); + }) + .WithName("GetGoodsReceivedNotes") + .WithSummary("Lists Goods Received Notes."); + + group.MapGet("/goods-received/{id:guid}", async (Guid id, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new GetGoodsReceivedNoteByIdQuery(id), ct); + return result.ToHttpResult(); + }) + .WithName("GetGoodsReceivedNoteById") + .WithSummary("Loads a single Goods Received Note with its lines."); + + group.MapPost("/goods-received", async ( + CreateGoodsReceivedNoteRequest request, ISender sender, CancellationToken ct) => + { + var command = new CreateGoodsReceivedNoteCommand( + request.SupplierId, + [.. request.Lines.Select(l => new GrnLineInput(l.RawMaterialId, l.Quantity))], + request.Notes, + request.PurchaseOrderId, + request.QualityRating, + request.HasIssue); + var result = await sender.Send(command, ct); + return result.ToCreatedResult(n => $"/api/v1/inventory/main-store/goods-received/{n.Id}"); + }) + .WithName("CreateGoodsReceivedNote") + .WithSummary("Records stock received from a supplier. Main Store stock increases immediately."); + + group.MapPost("/adjustments", async ( + CreateStockAdjustmentRequest request, ISender sender, CancellationToken ct) => + { + var command = new CreateStockAdjustmentCommand( + request.RawMaterialId, StoreType.MainStore, request.QuantityDelta, request.Reason); + var result = await sender.Send(command, ct); + return result.ToHttpResult(); + }) + .WithName("CreateMainStoreAdjustment") + .WithSummary("Corrects a raw material's Main Store balance to match a physical count."); + } + + private static void MapKitchenEndpoints(IEndpointRouteBuilder routes) + { + var group = routes.MapGroup("/inventory/kitchen") + .WithTags("Inventory") + .RequireAuthorization(AuthorizationPolicies.ForModule(AppModule.KitchenStockTracking)); + + group.MapGet("/stock", async (bool? lowStockOnly, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new GetStockLevelsQuery(StoreType.Kitchen, lowStockOnly ?? false), ct); + return result.ToHttpResult(); + }) + .WithName("GetKitchenStock") + .WithSummary("The current stock level of every active raw material in the Kitchen."); + + group.MapGet("/movements", async ( + Guid? rawMaterialId, DateTime? from, DateTime? to, ISender sender, CancellationToken ct) => + { + var result = await sender.Send( + new GetStockMovementsQuery(StoreType.Kitchen, rawMaterialId, from, to), ct); + return result.ToHttpResult(); + }) + .WithName("GetKitchenMovements") + .WithSummary("The Kitchen's stock history, optionally filtered."); + + group.MapPost("/adjustments", async ( + CreateStockAdjustmentRequest request, ISender sender, CancellationToken ct) => + { + var command = new CreateStockAdjustmentCommand( + request.RawMaterialId, StoreType.Kitchen, request.QuantityDelta, request.Reason); + var result = await sender.Send(command, ct); + return result.ToHttpResult(); + }) + .WithName("CreateKitchenAdjustment") + .WithSummary("Corrects a raw material's Kitchen balance to match a physical count."); + + // Ahead of Point of Sale existing: the "complete order" flow will call this to deduct + // Kitchen stock per the sold item's recipe. Kept under Kitchen Stock Tracking rather than + // exposed to every module, since only the checkout flow is meant to call it. + group.MapPost("/consumption/{menuItemId:guid}", async ( + Guid menuItemId, ConsumeStockRequest request, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new ConsumeStockForSaleCommand(menuItemId, request.QuantitySold), ct); + return result.ToHttpResult(); + }) + .WithName("ConsumeStockForSale") + .WithSummary("Deducts Kitchen stock per the menu item's recipe for a completed sale."); + } + + private static void MapReleaseEndpoints(IEndpointRouteBuilder routes) + { + var group = routes.MapGroup("/inventory/releases") + .WithTags("Inventory") + .RequireAuthorization(AuthorizationPolicies.ForModule(AppModule.KitchenStockRelease)); + + group.MapGet("/", async (ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new GetStockReleasesQuery(), ct); + return result.ToHttpResult(); + }) + .WithName("GetStockReleases") + .WithSummary("Lists Main Store → Kitchen stock releases."); + + group.MapGet("/{id:guid}", async (Guid id, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new GetStockReleaseByIdQuery(id), ct); + return result.ToHttpResult(); + }) + .WithName("GetStockReleaseById") + .WithSummary("Loads a single stock release with its lines."); + + group.MapPost("/", async (CreateStockReleaseRequest request, ISender sender, CancellationToken ct) => + { + var command = new CreateStockReleaseCommand( + [.. request.Lines.Select(l => new StockReleaseLineInput(l.RawMaterialId, l.Quantity))], + request.Pin, + request.Notes); + var result = await sender.Send(command, ct); + return result.ToCreatedResult(r => $"/api/v1/inventory/releases/{r.Id}"); + }) + .WithName("CreateStockRelease") + .WithSummary("Releases stock from the Main Store to the Kitchen, authorised by an administrator's PIN."); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/Endpoints/RecipeEndpoints.cs b/backend/src/RestaurantPOS.API/Endpoints/RecipeEndpoints.cs new file mode 100644 index 0000000..84ea4a7 --- /dev/null +++ b/backend/src/RestaurantPOS.API/Endpoints/RecipeEndpoints.cs @@ -0,0 +1,113 @@ +using MediatR; + +using RestaurantPOS.API.Contracts.Recipes; +using RestaurantPOS.API.Extensions; +using RestaurantPOS.API.Security; +using RestaurantPOS.Application.Recipes.Commands.CreateMenuItem; +using RestaurantPOS.Application.Recipes.Commands.DeleteRecipe; +using RestaurantPOS.Application.Recipes.Commands.SetMenuItemActive; +using RestaurantPOS.Application.Recipes.Commands.SetRecipeEnabled; +using RestaurantPOS.Application.Recipes.Commands.UpdateMenuItem; +using RestaurantPOS.Application.Recipes.Commands.UpsertRecipe; +using RestaurantPOS.Application.Recipes.Queries.GetMenuItemById; +using RestaurantPOS.Application.Recipes.Queries.GetMenuItems; +using RestaurantPOS.Application.Recipes.Queries.GetRecipeByMenuItem; +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.API.Endpoints; + +/// Menu items and the recipe (bill of materials) attached to each one. +public static class RecipeEndpoints +{ + public static IEndpointRouteBuilder MapRecipeEndpoints(this IEndpointRouteBuilder routes) + { + ArgumentNullException.ThrowIfNull(routes); + + var group = routes.MapGroup("/menu-items") + .WithTags("Recipes") + .RequireAuthorization(AuthorizationPolicies.ForModule(AppModule.RecipeManagement)); + + group.MapGet("/", async ( + string? search, string? category, bool? isActive, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new GetMenuItemsQuery(search, category, isActive), ct); + return result.ToHttpResult(); + }) + .WithName("GetMenuItems") + .WithSummary("Lists menu items, optionally filtered."); + + group.MapGet("/{id:guid}", async (Guid id, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new GetMenuItemByIdQuery(id), ct); + return result.ToHttpResult(); + }) + .WithName("GetMenuItemById") + .WithSummary("Loads a single menu item."); + + group.MapPost("/", async (CreateMenuItemRequest request, ISender sender, CancellationToken ct) => + { + var command = new CreateMenuItemCommand(request.Name, request.Category, request.Price); + var result = await sender.Send(command, ct); + return result.ToCreatedResult(item => $"/api/v1/menu-items/{item.Id}"); + }) + .WithName("CreateMenuItem") + .WithSummary("Creates a menu item."); + + group.MapPut("/{id:guid}", async ( + Guid id, UpdateMenuItemRequest request, ISender sender, CancellationToken ct) => + { + var command = new UpdateMenuItemCommand(id, request.Name, request.Category, request.Price); + var result = await sender.Send(command, ct); + return result.ToHttpResult(); + }) + .WithName("UpdateMenuItem") + .WithSummary("Updates a menu item's name, category and price."); + + group.MapPut("/{id:guid}/status", async ( + Guid id, SetMenuItemActiveRequest request, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new SetMenuItemActiveCommand(id, request.IsActive), ct); + return result.ToHttpResult(); + }) + .WithName("SetMenuItemActive") + .WithSummary("Activates or deactivates a menu item."); + + group.MapGet("/{id:guid}/recipe", async (Guid id, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new GetRecipeByMenuItemQuery(id), ct); + return result.ToHttpResult(); + }) + .WithName("GetRecipeByMenuItem") + .WithSummary("Displays every ingredient associated with a menu item, or null if it has no recipe."); + + group.MapPut("/{id:guid}/recipe", async ( + Guid id, UpsertRecipeRequest request, ISender sender, CancellationToken ct) => + { + var command = new UpsertRecipeCommand( + id, [.. request.Lines.Select(l => new RecipeLineInput(l.RawMaterialId, l.Quantity))]); + var result = await sender.Send(command, ct); + return result.ToHttpResult(); + }) + .WithName("UpsertRecipe") + .WithSummary("Creates the menu item's recipe, or replaces its lines if one already exists."); + + group.MapPut("/{id:guid}/recipe/status", async ( + Guid id, SetRecipeEnabledRequest request, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new SetRecipeEnabledCommand(id, request.IsEnabled), ct); + return result.ToHttpResult(); + }) + .WithName("SetRecipeEnabled") + .WithSummary("Enables or disables a recipe. A disabled recipe cannot be used for a new sale."); + + group.MapDelete("/{id:guid}/recipe", async (Guid id, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new DeleteRecipeCommand(id), ct); + return result.ToHttpResult(); + }) + .WithName("DeleteRecipe") + .WithSummary("Removes a menu item's recipe. The change is kept in the audit log."); + + return routes; + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/Endpoints/SupplierEndpoints.cs b/backend/src/RestaurantPOS.API/Endpoints/SupplierEndpoints.cs new file mode 100644 index 0000000..98b2328 --- /dev/null +++ b/backend/src/RestaurantPOS.API/Endpoints/SupplierEndpoints.cs @@ -0,0 +1,230 @@ +using MediatR; + +using RestaurantPOS.API.Contracts.Suppliers; +using RestaurantPOS.API.Extensions; +using RestaurantPOS.API.Security; +using RestaurantPOS.Application.Suppliers.Commands.CancelPurchaseOrder; +using RestaurantPOS.Application.Suppliers.Commands.ConfirmPurchaseOrder; +using RestaurantPOS.Application.Suppliers.Commands.CreatePurchaseOrder; +using RestaurantPOS.Application.Suppliers.Commands.CreateSupplier; +using RestaurantPOS.Application.Suppliers.Commands.RecordSupplierPayment; +using RestaurantPOS.Application.Suppliers.Commands.SetSupplierActive; +using RestaurantPOS.Application.Suppliers.Commands.SetSupplierPrice; +using RestaurantPOS.Application.Suppliers.Commands.SubmitPurchaseOrder; +using RestaurantPOS.Application.Suppliers.Commands.UpdatePurchaseOrder; +using RestaurantPOS.Application.Suppliers.Commands.UpdateSupplier; +using RestaurantPOS.Application.Suppliers.Queries.GetPurchaseOrderById; +using RestaurantPOS.Application.Suppliers.Queries.GetPurchaseOrders; +using RestaurantPOS.Application.Suppliers.Queries.GetSupplierPayments; +using RestaurantPOS.Application.Suppliers.Queries.GetSupplierPerformance; +using RestaurantPOS.Application.Suppliers.Queries.GetSupplierPriceHistory; +using RestaurantPOS.Application.Suppliers.Queries.GetSupplierPrices; +using RestaurantPOS.Application.Suppliers.Queries.GetSuppliers; +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.API.Endpoints; + +/// Supplier master data, purchase orders, pricing, payments and performance. +public static class SupplierEndpoints +{ + public static IEndpointRouteBuilder MapSupplierEndpoints(this IEndpointRouteBuilder routes) + { + ArgumentNullException.ThrowIfNull(routes); + + // Reading the supplier list is also needed by Main Store staff picking a supplier on a + // GRN, so it is mapped separately under the more permissive SupplierLookup policy rather + // than the SupplierManagement-only group below. + routes.MapGet("/suppliers", async (string? search, bool? isActive, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new GetSuppliersQuery(search, isActive), ct); + return result.ToHttpResult(); + }) + .WithTags("Suppliers") + .RequireAuthorization(AuthorizationPolicies.SupplierLookup) + .WithName("GetSuppliers") + .WithSummary("Lists suppliers, optionally filtered."); + + var group = routes.MapGroup("/suppliers") + .WithTags("Suppliers") + .RequireAuthorization(AuthorizationPolicies.ForModule(AppModule.SupplierManagement)); + + MapSupplierCrud(group); + MapPricing(group); + MapPerformance(group); + + var orders = routes.MapGroup("/purchase-orders") + .WithTags("Suppliers") + .RequireAuthorization(AuthorizationPolicies.ForModule(AppModule.SupplierManagement)); + + MapPurchaseOrders(orders); + MapPayments(orders); + + return routes; + } + + private static void MapSupplierCrud(RouteGroupBuilder group) + { + group.MapPost("/", async (CreateSupplierRequest request, ISender sender, CancellationToken ct) => + { + var command = new CreateSupplierCommand( + request.Name, request.ContactName, request.Phone, request.Email, request.Address, + request.PaymentTermsDays, request.CreditLimit, request.LeadTimeDays); + var result = await sender.Send(command, ct); + return result.ToCreatedResult(s => $"/api/v1/suppliers/{s.Id}"); + }) + .WithName("CreateSupplier") + .WithSummary("Creates a supplier."); + + group.MapPut("/{id:guid}", async (Guid id, UpdateSupplierRequest request, ISender sender, CancellationToken ct) => + { + var command = new UpdateSupplierCommand( + id, request.Name, request.ContactName, request.Phone, request.Email, request.Address, + request.PaymentTermsDays, request.CreditLimit, request.LeadTimeDays); + var result = await sender.Send(command, ct); + return result.ToHttpResult(); + }) + .WithName("UpdateSupplier") + .WithSummary("Updates a supplier's details, contact and terms."); + + group.MapPut("/{id:guid}/status", async ( + Guid id, SetSupplierActiveRequest request, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new SetSupplierActiveCommand(id, request.IsActive), ct); + return result.ToHttpResult(); + }) + .WithName("SetSupplierActive") + .WithSummary("Activates or deactivates a supplier."); + } + + private static void MapPricing(RouteGroupBuilder group) + { + group.MapGet("/prices", async (Guid? supplierId, Guid? rawMaterialId, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new GetSupplierPricesQuery(supplierId, rawMaterialId), ct); + return result.ToHttpResult(); + }) + .WithName("GetSupplierPrices") + .WithSummary("Current prices, filterable by supplier (a price list) or raw material (a comparison)."); + + group.MapPut("/{id:guid}/prices", async ( + Guid id, SetSupplierPriceRequest request, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new SetSupplierPriceCommand(id, request.RawMaterialId, request.Price), ct); + return result.ToHttpResult(); + }) + .WithName("SetSupplierPrice") + .WithSummary("Records the current price a supplier charges for a raw material, keeping the change in history."); + + group.MapGet("/{id:guid}/prices/{rawMaterialId:guid}/history", async ( + Guid id, Guid rawMaterialId, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new GetSupplierPriceHistoryQuery(id, rawMaterialId), ct); + return result.ToHttpResult(); + }) + .WithName("GetSupplierPriceHistory") + .WithSummary("Every price a supplier has been recorded as charging for a raw material, newest first."); + } + + private static void MapPerformance(RouteGroupBuilder group) + { + group.MapGet("/{id:guid}/performance", async (Guid id, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new GetSupplierPerformanceQuery(id), ct); + return result.ToHttpResult(); + }) + .WithName("GetSupplierPerformance") + .WithSummary("On-time delivery, average lead time, quality rating and issue count for a supplier."); + } + + private static void MapPurchaseOrders(RouteGroupBuilder group) + { + group.MapGet("/", async ( + Guid? supplierId, PurchaseOrderStatus? status, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new GetPurchaseOrdersQuery(supplierId, status), ct); + return result.ToHttpResult(); + }) + .WithName("GetPurchaseOrders") + .WithSummary("Lists purchase orders, optionally filtered by supplier or status."); + + group.MapGet("/{id:guid}", async (Guid id, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new GetPurchaseOrderByIdQuery(id), ct); + return result.ToHttpResult(); + }) + .WithName("GetPurchaseOrderById") + .WithSummary("Loads a single purchase order with its lines and payment balance."); + + group.MapPost("/", async (CreatePurchaseOrderRequest request, ISender sender, CancellationToken ct) => + { + var command = new CreatePurchaseOrderCommand( + request.SupplierId, + [.. request.Lines.Select(l => new PurchaseOrderLineInput(l.RawMaterialId, l.Quantity, l.UnitPrice))], + request.ExpectedDeliveryDate, + request.Notes); + var result = await sender.Send(command, ct); + return result.ToCreatedResult(o => $"/api/v1/purchase-orders/{o.Id}"); + }) + .WithName("CreatePurchaseOrder") + .WithSummary("Creates a new purchase order in Draft."); + + group.MapPut("/{id:guid}", async (Guid id, UpdatePurchaseOrderRequest request, ISender sender, CancellationToken ct) => + { + var command = new UpdatePurchaseOrderCommand( + id, + [.. request.Lines.Select(l => new UpdatePurchaseOrderLineInput(l.RawMaterialId, l.Quantity, l.UnitPrice))], + request.ExpectedDeliveryDate, + request.Notes); + var result = await sender.Send(command, ct); + return result.ToHttpResult(); + }) + .WithName("UpdatePurchaseOrder") + .WithSummary("Replaces a draft purchase order's lines and details."); + + group.MapPost("/{id:guid}/submit", async (Guid id, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new SubmitPurchaseOrderCommand(id), ct); + return result.ToHttpResult(); + }) + .WithName("SubmitPurchaseOrder") + .WithSummary("Sends a draft purchase order to its supplier."); + + group.MapPost("/{id:guid}/confirm", async (Guid id, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new ConfirmPurchaseOrderCommand(id), ct); + return result.ToHttpResult(); + }) + .WithName("ConfirmPurchaseOrder") + .WithSummary("Records that the supplier has agreed to fulfil the order."); + + group.MapPost("/{id:guid}/cancel", async (Guid id, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new CancelPurchaseOrderCommand(id), ct); + return result.ToHttpResult(); + }) + .WithName("CancelPurchaseOrder") + .WithSummary("Cancels a purchase order that has not yet been delivered."); + } + + private static void MapPayments(RouteGroupBuilder group) + { + group.MapGet("/{id:guid}/payments", async (Guid id, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new GetSupplierPaymentsQuery(id, null), ct); + return result.ToHttpResult(); + }) + .WithName("GetPurchaseOrderPayments") + .WithSummary("Payments recorded against a purchase order."); + + group.MapPost("/{id:guid}/payments", async ( + Guid id, RecordSupplierPaymentRequest request, ISender sender, CancellationToken ct) => + { + var command = new RecordSupplierPaymentCommand( + id, request.Amount, request.PaymentDateUtc, request.Method, request.InvoiceReference, request.Notes); + var result = await sender.Send(command, ct); + return result.ToHttpResult(); + }) + .WithName("RecordSupplierPayment") + .WithSummary("Records a payment toward a purchase order."); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/Program.cs b/backend/src/RestaurantPOS.API/Program.cs index ae9dcc4..3725479 100644 --- a/backend/src/RestaurantPOS.API/Program.cs +++ b/backend/src/RestaurantPOS.API/Program.cs @@ -111,6 +111,9 @@ api.MapAuthEndpoints(); api.MapUserEndpoints(); api.MapModuleEndpoints(); + api.MapRecipeEndpoints(); + api.MapInventoryEndpoints(); + api.MapSupplierEndpoints(); app.MapGet("/health", () => Results.Ok(new { status = "Healthy", timestamp = DateTime.UtcNow })) .AllowAnonymous() diff --git a/backend/src/RestaurantPOS.API/Security/AuthorizationPolicies.cs b/backend/src/RestaurantPOS.API/Security/AuthorizationPolicies.cs index 836413d..269a741 100644 --- a/backend/src/RestaurantPOS.API/Security/AuthorizationPolicies.cs +++ b/backend/src/RestaurantPOS.API/Security/AuthorizationPolicies.cs @@ -15,6 +15,14 @@ public static class AuthorizationPolicies /// Requires the Admin role. public const string AdminOnly = "role:admin"; + /// + /// Reading the supplier list is needed by Main Store staff picking a supplier on a GRN, not + /// just by Supplier Management itself — so it accepts either grant, unlike every other + /// supplier endpoint (create/edit supplier, purchase orders, pricing, payments), which stay + /// -only. + /// + public const string SupplierLookup = "suppliers:read"; + /// Builds the policy name guarding . public static string ForModule(AppModule module) => $"module:{module}"; @@ -25,6 +33,12 @@ public static AuthorizationBuilder AddAppPolicies(this AuthorizationBuilder buil builder.AddPolicy(AdminOnly, policy => policy.RequireRole(nameof(UserRole.Admin))); + builder.AddPolicy(SupplierLookup, policy => + policy.RequireAssertion(context => + context.User.IsInRole(nameof(UserRole.Admin)) || + context.User.HasClaim(AppClaimTypes.Module, AppModule.SupplierManagement.ToString()) || + context.User.HasClaim(AppClaimTypes.Module, AppModule.StoreStockManagement.ToString()))); + foreach (var descriptor in ModuleCatalog.All) { var module = descriptor.Module; diff --git a/backend/src/RestaurantPOS.Application/Common/Audit/AuditLog.cs b/backend/src/RestaurantPOS.Application/Common/Audit/AuditLog.cs new file mode 100644 index 0000000..e120776 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Common/Audit/AuditLog.cs @@ -0,0 +1,25 @@ +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Application.Common.Audit; + +/// Thin helper so command handlers can write an in one line. +public static class AuditLog +{ + public static void Record( + IAppDbContext db, + string entityType, + Guid entityId, + string action, + Guid performedByUserId, + string performedByName, + DateTime nowUtc, + string summary, + string? detailsJson = null) + { + ArgumentNullException.ThrowIfNull(db); + + db.AuditLogEntries.Add(AuditLogEntry.Record( + entityType, entityId, action, performedByUserId, performedByName, nowUtc, summary, detailsJson)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Common/Interfaces/IAppDbContext.cs b/backend/src/RestaurantPOS.Application/Common/Interfaces/IAppDbContext.cs index 7624a47..89879d9 100644 --- a/backend/src/RestaurantPOS.Application/Common/Interfaces/IAppDbContext.cs +++ b/backend/src/RestaurantPOS.Application/Common/Interfaces/IAppDbContext.cs @@ -17,5 +17,31 @@ public interface IAppDbContext DbSet RefreshTokens { get; } + DbSet MenuItems { get; } + + DbSet RawMaterials { get; } + + DbSet Recipes { get; } + + DbSet Suppliers { get; } + + DbSet StockLevels { get; } + + DbSet StockMovements { get; } + + DbSet GoodsReceivedNotes { get; } + + DbSet StockReleases { get; } + + DbSet AuditLogEntries { get; } + + DbSet PurchaseOrders { get; } + + DbSet SupplierPrices { get; } + + DbSet SupplierPriceHistoryEntries { get; } + + DbSet SupplierPayments { get; } + Task SaveChangesAsync(CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Common/Mappings/PurchaseOrderMappings.cs b/backend/src/RestaurantPOS.Application/Common/Mappings/PurchaseOrderMappings.cs new file mode 100644 index 0000000..ea53b49 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Common/Mappings/PurchaseOrderMappings.cs @@ -0,0 +1,64 @@ +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Suppliers.Dtos; +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Application.Common.Mappings; + +/// Projects aggregates onto their read model. +public static class PurchaseOrderMappings +{ + /// + /// Builds the full DTO, denormalising the supplier's and creator's names and each line's raw + /// material details, and computing the amount paid so far from recorded payments. + /// + public static async Task ToDtoAsync( + this PurchaseOrder order, IAppDbContext db, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(order); + ArgumentNullException.ThrowIfNull(db); + + var supplierName = await db.Suppliers.AsNoTracking() + .Where(s => s.Id == order.SupplierId).Select(s => s.Name).FirstAsync(cancellationToken); + + var createdByName = await db.Users.AsNoTracking() + .Where(u => u.Id == order.CreatedByUserId).Select(u => u.FullName).FirstAsync(cancellationToken); + + var rawMaterialIds = order.Lines.Select(l => l.RawMaterialId).ToList(); + var rawMaterials = await db.RawMaterials.AsNoTracking() + .Where(r => rawMaterialIds.Contains(r.Id)) + .ToDictionaryAsync(r => r.Id, cancellationToken); + + var amountPaid = await db.SupplierPayments.AsNoTracking() + .Where(p => p.PurchaseOrderId == order.Id) + .SumAsync(p => (decimal?)p.Amount, cancellationToken) ?? 0m; + + var lines = order.Lines + .Select(l => new PurchaseOrderLineDto( + l.RawMaterialId, + rawMaterials[l.RawMaterialId].Name, + rawMaterials[l.RawMaterialId].UnitOfMeasurement, + l.Quantity, + l.UnitPrice, + l.LineTotal)) + .OrderBy(l => l.RawMaterialName) + .ToList(); + + return new PurchaseOrderDto( + order.Id, + order.SupplierId, + supplierName, + order.Status, + order.CreatedByUserId, + createdByName, + order.CreatedAtUtc, + order.ExpectedDeliveryDate, + order.SubmittedAtUtc, + order.Notes, + order.TotalAmount, + amountPaid, + order.TotalAmount - amountPaid, + lines); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Common/Mappings/RawMaterialMappings.cs b/backend/src/RestaurantPOS.Application/Common/Mappings/RawMaterialMappings.cs new file mode 100644 index 0000000..a5edb21 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Common/Mappings/RawMaterialMappings.cs @@ -0,0 +1,21 @@ +using RestaurantPOS.Application.Inventory.Dtos; +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Application.Common.Mappings; + +public static class RawMaterialMappings +{ + public static RawMaterialDto ToDto(this RawMaterial rawMaterial) + { + ArgumentNullException.ThrowIfNull(rawMaterial); + + return new RawMaterialDto( + rawMaterial.Id, + rawMaterial.Name, + rawMaterial.UnitOfMeasurement, + rawMaterial.MainStoreReorderLevel, + rawMaterial.KitchenParLevel, + rawMaterial.IsActive, + rawMaterial.CreatedAtUtc); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Common/Mappings/RecipeMappings.cs b/backend/src/RestaurantPOS.Application/Common/Mappings/RecipeMappings.cs new file mode 100644 index 0000000..61c7e1e --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Common/Mappings/RecipeMappings.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Recipes.Dtos; +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Application.Common.Mappings; + +/// Projects aggregates onto their read model. +public static class RecipeMappings +{ + /// + /// Builds the DTO, denormalising each line's raw material name and unit in — a lookup + /// rather than a plain property mapping, since that data lives on , + /// not on itself. + /// + public static async Task ToDtoAsync( + this Recipe recipe, IAppDbContext db, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(recipe); + ArgumentNullException.ThrowIfNull(db); + + var rawMaterialIds = recipe.Lines.Select(l => l.RawMaterialId).ToList(); + + var rawMaterials = await db.RawMaterials.AsNoTracking() + .Where(r => rawMaterialIds.Contains(r.Id)) + .ToDictionaryAsync(r => r.Id, cancellationToken); + + var lines = recipe.Lines + .Select(l => new RecipeLineDto( + l.RawMaterialId, + rawMaterials[l.RawMaterialId].Name, + rawMaterials[l.RawMaterialId].UnitOfMeasurement, + l.Quantity)) + .OrderBy(l => l.RawMaterialName) + .ToList(); + + return new RecipeDto(recipe.Id, recipe.MenuItemId, recipe.IsEnabled, lines, recipe.CreatedAtUtc, recipe.UpdatedAtUtc); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Common/Mappings/SupplierMappings.cs b/backend/src/RestaurantPOS.Application/Common/Mappings/SupplierMappings.cs new file mode 100644 index 0000000..4dcfb3b --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Common/Mappings/SupplierMappings.cs @@ -0,0 +1,25 @@ +using RestaurantPOS.Application.Suppliers.Dtos; +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Application.Common.Mappings; + +public static class SupplierMappings +{ + public static SupplierDto ToDto(this Supplier supplier) + { + ArgumentNullException.ThrowIfNull(supplier); + + return new SupplierDto( + supplier.Id, + supplier.Name, + supplier.ContactName, + supplier.Phone, + supplier.Email, + supplier.Address, + supplier.PaymentTermsDays, + supplier.CreditLimit, + supplier.LeadTimeDays, + supplier.IsActive, + supplier.CreatedAtUtc); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Inventory/Commands/ConsumeStockForSale/ConsumeStockForSaleCommand.cs b/backend/src/RestaurantPOS.Application/Inventory/Commands/ConsumeStockForSale/ConsumeStockForSaleCommand.cs new file mode 100644 index 0000000..a34ca1c --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Inventory/Commands/ConsumeStockForSale/ConsumeStockForSaleCommand.cs @@ -0,0 +1,93 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Inventory.Common; +using RestaurantPOS.Application.Inventory.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Inventory.Commands.ConsumeStockForSale; + +/// +/// Deducts Kitchen stock per a menu item's recipe when a sale completes (REC-007, INV-013, +/// INV-014). This is the integration point POS & Billing's "complete order" flow will call +/// once it exists; it is fully implemented and tested ahead of that. +/// +/// How many units of the menu item were sold in this transaction. +public sealed record ConsumeStockForSaleCommand(Guid MenuItemId, decimal QuantitySold) + : IRequest>; + +public sealed class ConsumeStockForSaleCommandValidator : AbstractValidator +{ + public ConsumeStockForSaleCommandValidator() + { + RuleFor(x => x.MenuItemId).NotEmpty(); + RuleFor(x => x.QuantitySold).GreaterThan(0).WithMessage("Quantity sold must be greater than zero."); + } +} + +internal sealed class ConsumeStockForSaleCommandHandler( + IAppDbContext db, ICurrentUser currentUser, IDateTimeProvider clock) + : IRequestHandler> +{ + private static readonly ConsumptionResultDto NoDeduction = new(Deducted: false, Lines: []); + + public async Task> Handle( + ConsumeStockForSaleCommand request, CancellationToken cancellationToken) + { + var menuItemExists = await db.MenuItems.AnyAsync(m => m.Id == request.MenuItemId, cancellationToken); + if (!menuItemExists) + { + return Result.Failure(RecipeErrors.MenuItemNotFound(request.MenuItemId)); + } + + var recipe = await db.Recipes.AsNoTracking() + .Include(r => r.Lines) + .FirstOrDefaultAsync(r => r.MenuItemId == request.MenuItemId, cancellationToken); + + // Not every menu item has a recipe, and a disabled one is not usable for a new sale — + // neither is a failure, since the sale itself has already happened by the time this + // runs. There is simply nothing to deduct. + if (recipe is null || !recipe.IsEnabled) + { + return Result.Success(NoDeduction); + } + + var rawMaterialIds = recipe.Lines.Select(l => l.RawMaterialId).ToList(); + var rawMaterials = await db.RawMaterials.AsNoTracking() + .Where(r => rawMaterialIds.Contains(r.Id)) + .ToDictionaryAsync(r => r.Id, cancellationToken); + + var now = clock.UtcNow; + + var movements = recipe.Lines + .Select(l => new StockMovementRequest( + l.RawMaterialId, + StoreType.Kitchen, + -(l.Quantity * request.QuantitySold), + StockMovementType.Consumption, + ReferenceId: null, + Notes: null)) + .ToList(); + + var ledgerResult = await InventoryLedger.ApplyAsync(db, movements, currentUser.UserId!.Value, now, cancellationToken); + if (ledgerResult.IsFailure) + { + return Result.Failure(ledgerResult.Error); + } + + await db.SaveChangesAsync(cancellationToken); + + var lines = movements + .Select(m => new ConsumedLineDto( + m.RawMaterialId, rawMaterials[m.RawMaterialId].Name, -m.QuantityDelta, rawMaterials[m.RawMaterialId].UnitOfMeasurement)) + .ToList(); + + return Result.Success(new ConsumptionResultDto(Deducted: true, lines)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Inventory/Commands/CreateGoodsReceivedNote/CreateGoodsReceivedNoteCommand.cs b/backend/src/RestaurantPOS.Application/Inventory/Commands/CreateGoodsReceivedNote/CreateGoodsReceivedNoteCommand.cs new file mode 100644 index 0000000..6dba9de --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Inventory/Commands/CreateGoodsReceivedNote/CreateGoodsReceivedNoteCommand.cs @@ -0,0 +1,152 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Inventory.Common; +using RestaurantPOS.Application.Inventory.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Inventory.Commands.CreateGoodsReceivedNote; + +public sealed record GrnLineInput(Guid RawMaterialId, decimal Quantity); + +/// +/// Records stock received from a supplier into the Main Store. There is no separate draft or +/// confirmation step (INV-002, INV-003) — a GRN is only ever entered once the goods have +/// actually been counted in, so recording it and confirming it are the same action. +/// +/// +/// Optional — reconciles this delivery against an order and marks it Delivered. A GRN can also +/// stand alone for a delivery that never had a formal PO raised against it. +/// +/// Optional, 1 (worst) to 5 (best). +/// Flags this delivery for the supplier performance view. +public sealed record CreateGoodsReceivedNoteCommand( + Guid SupplierId, + IReadOnlyCollection Lines, + string? Notes, + Guid? PurchaseOrderId = null, + int? QualityRating = null, + bool HasIssue = false) : IRequest>; + +public sealed class CreateGoodsReceivedNoteCommandValidator : AbstractValidator +{ + public CreateGoodsReceivedNoteCommandValidator() + { + RuleFor(x => x.SupplierId).NotEmpty(); + + RuleFor(x => x.Lines).NotEmpty().WithMessage("At least one raw material line is required."); + + RuleForEach(x => x.Lines).ChildRules(line => + line.RuleFor(l => l.Quantity).GreaterThan(0).WithMessage("Quantity must be greater than zero.")); + + RuleFor(x => x.Lines) + .Must(lines => lines.Select(l => l.RawMaterialId).Distinct().Count() == lines.Count) + .WithMessage("A raw material cannot appear more than once on the same GRN.") + .When(x => x.Lines.Count > 0); + + RuleFor(x => x.Notes).MaximumLength(GoodsReceivedNote.NotesMaxLength); + + RuleFor(x => x.QualityRating) + .InclusiveBetween(GoodsReceivedNote.MinQualityRating, GoodsReceivedNote.MaxQualityRating) + .WithMessage($"Quality rating must be between {GoodsReceivedNote.MinQualityRating} and {GoodsReceivedNote.MaxQualityRating}.") + .When(x => x.QualityRating is not null); + } +} + +internal sealed class CreateGoodsReceivedNoteCommandHandler( + IAppDbContext db, ICurrentUser currentUser, IDateTimeProvider clock) + : IRequestHandler> +{ + public async Task> Handle( + CreateGoodsReceivedNoteCommand request, CancellationToken cancellationToken) + { + var supplier = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == request.SupplierId, cancellationToken); + if (supplier is null) + { + return Result.Failure(SupplierErrors.NotFound(request.SupplierId)); + } + + if (!supplier.IsActive) + { + return Result.Failure(SupplierErrors.Inactive); + } + + PurchaseOrder? purchaseOrder = null; + + if (request.PurchaseOrderId is not null) + { + purchaseOrder = await db.PurchaseOrders + .FirstOrDefaultAsync(p => p.Id == request.PurchaseOrderId.Value, cancellationToken); + + if (purchaseOrder is null) + { + return Result.Failure(SupplierErrors.PurchaseOrderNotFound(request.PurchaseOrderId.Value)); + } + + if (purchaseOrder.SupplierId != request.SupplierId) + { + return Result.Failure(SupplierErrors.SupplierMismatch); + } + + if (purchaseOrder.Status == PurchaseOrderStatus.Cancelled) + { + return Result.Failure(SupplierErrors.PurchaseOrderCancelled); + } + } + + var rawMaterialIds = request.Lines.Select(l => l.RawMaterialId).ToList(); + var rawMaterials = await db.RawMaterials + .Where(r => rawMaterialIds.Contains(r.Id)) + .ToDictionaryAsync(r => r.Id, cancellationToken); + + if (rawMaterials.Count != rawMaterialIds.Distinct().Count()) + { + return Result.Failure(InventoryErrors.RawMaterialNotFound(rawMaterialIds.First(id => !rawMaterials.ContainsKey(id)))); + } + + if (rawMaterials.Values.Any(r => !r.IsActive)) + { + return Result.Failure(InventoryErrors.RawMaterialInactive); + } + + var now = clock.UtcNow; + var grn = GoodsReceivedNote.Create( + request.SupplierId, currentUser.UserId!.Value, now, request.Notes, + request.PurchaseOrderId, request.QualityRating, request.HasIssue); + db.GoodsReceivedNotes.Add(grn); + + var movements = request.Lines + .Select(l => new StockMovementRequest( + l.RawMaterialId, StoreType.MainStore, l.Quantity, StockMovementType.GoodsReceived, grn.Id, null)) + .ToList(); + + var ledgerResult = await InventoryLedger.ApplyAsync(db, movements, currentUser.UserId!.Value, now, cancellationToken); + if (ledgerResult.IsFailure) + { + return Result.Failure(ledgerResult.Error); + } + + purchaseOrder?.MarkDelivered(); + + await db.SaveChangesAsync(cancellationToken); + + var lines = request.Lines + .Select(l => new StockMovementLineDto(l.RawMaterialId, rawMaterials[l.RawMaterialId].Name, rawMaterials[l.RawMaterialId].UnitOfMeasurement, l.Quantity)) + .OrderBy(l => l.RawMaterialName) + .ToList(); + + var receivedByName = await db.Users.Where(u => u.Id == grn.ReceivedByUserId) + .Select(u => u.FullName).FirstAsync(cancellationToken); + + return Result.Success(new GoodsReceivedNoteDto( + grn.Id, supplier.Id, supplier.Name, grn.ReceivedByUserId, receivedByName, + grn.ReceivedAtUtc, grn.Notes, grn.PurchaseOrderId, grn.QualityRating, grn.HasIssue, lines)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Inventory/Commands/CreateRawMaterial/CreateRawMaterialCommand.cs b/backend/src/RestaurantPOS.Application/Inventory/Commands/CreateRawMaterial/CreateRawMaterialCommand.cs new file mode 100644 index 0000000..64e84ce --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Inventory/Commands/CreateRawMaterial/CreateRawMaterialCommand.cs @@ -0,0 +1,65 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Inventory.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Inventory.Commands.CreateRawMaterial; + +public sealed record CreateRawMaterialCommand( + string Name, + UnitOfMeasurement UnitOfMeasurement, + decimal? MainStoreReorderLevel, + decimal? KitchenParLevel) : IRequest>; + +public sealed class CreateRawMaterialCommandValidator : AbstractValidator +{ + public CreateRawMaterialCommandValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Name is required.") + .MaximumLength(RawMaterial.NameMaxLength); + + RuleFor(x => x.UnitOfMeasurement).IsInEnum().WithMessage("Select a valid unit of measurement."); + + RuleFor(x => x.MainStoreReorderLevel).GreaterThanOrEqualTo(0) + .WithMessage("Reorder level cannot be negative.") + .When(x => x.MainStoreReorderLevel is not null); + + RuleFor(x => x.KitchenParLevel).GreaterThanOrEqualTo(0) + .WithMessage("Par level cannot be negative.") + .When(x => x.KitchenParLevel is not null); + } +} + +internal sealed class CreateRawMaterialCommandHandler(IAppDbContext db) + : IRequestHandler> +{ + public async Task> Handle(CreateRawMaterialCommand request, CancellationToken cancellationToken) + { + var name = request.Name.Trim(); + var normalised = name.ToLowerInvariant(); + + var exists = await db.RawMaterials.AnyAsync(r => r.Name.ToLower() == normalised, cancellationToken); + if (exists) + { + return Result.Failure(InventoryErrors.RawMaterialNameTaken); + } + + var rawMaterial = RawMaterial.Create( + name, request.UnitOfMeasurement, request.MainStoreReorderLevel, request.KitchenParLevel); + + db.RawMaterials.Add(rawMaterial); + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(rawMaterial.ToDto()); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Inventory/Commands/CreateStockAdjustment/CreateStockAdjustmentCommand.cs b/backend/src/RestaurantPOS.Application/Inventory/Commands/CreateStockAdjustment/CreateStockAdjustmentCommand.cs new file mode 100644 index 0000000..de273f1 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Inventory/Commands/CreateStockAdjustment/CreateStockAdjustmentCommand.cs @@ -0,0 +1,83 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Inventory.Common; +using RestaurantPOS.Application.Inventory.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Inventory.Commands.CreateStockAdjustment; + +/// +/// Corrects a raw material's balance in one store to match a physical count (INV-004). A reason +/// is mandatory — an adjustment with no explanation defeats the point of keeping a ledger. +/// +public sealed record CreateStockAdjustmentCommand( + Guid RawMaterialId, StoreType Store, decimal QuantityDelta, string Reason) + : IRequest>; + +public sealed class CreateStockAdjustmentCommandValidator : AbstractValidator +{ + public CreateStockAdjustmentCommandValidator() + { + RuleFor(x => x.RawMaterialId).NotEmpty(); + + RuleFor(x => x.Store).IsInEnum().WithMessage("Select a valid store."); + + RuleFor(x => x.QuantityDelta).NotEqual(0).WithMessage("The adjustment must change the balance."); + + RuleFor(x => x.Reason) + .NotEmpty().WithMessage("A reason is required.") + .MaximumLength(StockMovement.NotesMaxLength); + } +} + +internal sealed class CreateStockAdjustmentCommandHandler( + IAppDbContext db, ICurrentUser currentUser, IDateTimeProvider clock) + : IRequestHandler> +{ + public async Task> Handle( + CreateStockAdjustmentCommand request, CancellationToken cancellationToken) + { + var rawMaterial = await db.RawMaterials.FirstOrDefaultAsync(r => r.Id == request.RawMaterialId, cancellationToken); + if (rawMaterial is null) + { + return Result.Failure(InventoryErrors.RawMaterialNotFound(request.RawMaterialId)); + } + + var now = clock.UtcNow; + + var movement = new StockMovementRequest( + request.RawMaterialId, request.Store, request.QuantityDelta, StockMovementType.Adjustment, + ReferenceId: null, request.Reason); + + var ledgerResult = await InventoryLedger.ApplyAsync( + db, [movement], currentUser.UserId!.Value, now, cancellationToken); + + if (ledgerResult.IsFailure) + { + return Result.Failure(ledgerResult.Error); + } + + await db.SaveChangesAsync(cancellationToken); + + var recorded = await db.StockMovements.AsNoTracking() + .Where(m => m.RawMaterialId == request.RawMaterialId && m.Type == StockMovementType.Adjustment) + .OrderByDescending(m => m.OccurredAtUtc) + .FirstAsync(cancellationToken); + + var performedByName = await db.Users.Where(u => u.Id == recorded.PerformedByUserId) + .Select(u => u.FullName).FirstAsync(cancellationToken); + + return Result.Success(new StockMovementDto( + recorded.Id, rawMaterial.Id, rawMaterial.Name, rawMaterial.UnitOfMeasurement, recorded.Store, + recorded.QuantityDelta, recorded.Type, recorded.ReferenceId, recorded.PerformedByUserId, + performedByName, recorded.OccurredAtUtc, recorded.Notes)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Inventory/Commands/CreateStockRelease/CreateStockReleaseCommand.cs b/backend/src/RestaurantPOS.Application/Inventory/Commands/CreateStockRelease/CreateStockReleaseCommand.cs new file mode 100644 index 0000000..4d0b7e2 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Inventory/Commands/CreateStockRelease/CreateStockReleaseCommand.cs @@ -0,0 +1,118 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Authentication.Commands.VerifyApprovalPin; +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Inventory.Common; +using RestaurantPOS.Application.Inventory.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Inventory.Commands.CreateStockRelease; + +public sealed record StockReleaseLineInput(Guid RawMaterialId, decimal Quantity); + +/// +/// Transfers stock from the Main Store to the Kitchen (INV-008 through INV-011). Approval is not +/// a separate pending step: the requesting user's action and an administrator's approval PIN +/// arrive in the same request, verified here before anything moves, so a release only ever +/// exists already approved. +/// +public sealed record CreateStockReleaseCommand( + IReadOnlyCollection Lines, string Pin, string? Notes) + : IRequest>; + +public sealed class CreateStockReleaseCommandValidator : AbstractValidator +{ + public CreateStockReleaseCommandValidator() + { + RuleFor(x => x.Lines).NotEmpty().WithMessage("At least one raw material line is required."); + + RuleForEach(x => x.Lines).ChildRules(line => + line.RuleFor(l => l.Quantity).GreaterThan(0).WithMessage("Quantity must be greater than zero.")); + + RuleFor(x => x.Lines) + .Must(lines => lines.Select(l => l.RawMaterialId).Distinct().Count() == lines.Count) + .WithMessage("A raw material cannot appear more than once on the same release.") + .When(x => x.Lines.Count > 0); + + RuleFor(x => x.Pin).NotEmpty().WithMessage("An administrator's approval PIN is required."); + + RuleFor(x => x.Notes).MaximumLength(StockRelease.NotesMaxLength); + } +} + +internal sealed class CreateStockReleaseCommandHandler( + IAppDbContext db, ICurrentUser currentUser, IDateTimeProvider clock, ISender sender) + : IRequestHandler> +{ + public async Task> Handle( + CreateStockReleaseCommand request, CancellationToken cancellationToken) + { + var approval = await sender.Send( + new VerifyApprovalPinCommand(request.Pin, request.Notes), cancellationToken); + + if (approval.IsFailure) + { + return Result.Failure(approval.Error); + } + + var rawMaterialIds = request.Lines.Select(l => l.RawMaterialId).ToList(); + var rawMaterials = await db.RawMaterials + .Where(r => rawMaterialIds.Contains(r.Id)) + .ToDictionaryAsync(r => r.Id, cancellationToken); + + if (rawMaterials.Count != rawMaterialIds.Distinct().Count()) + { + return Result.Failure( + InventoryErrors.RawMaterialNotFound(rawMaterialIds.First(id => !rawMaterials.ContainsKey(id)))); + } + + if (rawMaterials.Values.Any(r => !r.IsActive)) + { + return Result.Failure(InventoryErrors.RawMaterialInactive); + } + + var now = clock.UtcNow; + + var release = StockRelease.Create( + currentUser.UserId!.Value, now, approval.Value.ApprovedByUserId, approval.Value.ApprovedAtUtc, request.Notes); + db.StockReleases.Add(release); + + var movements = request.Lines + .SelectMany(l => new[] + { + new StockMovementRequest( + l.RawMaterialId, StoreType.MainStore, -l.Quantity, StockMovementType.StockReleaseOut, release.Id, null), + new StockMovementRequest( + l.RawMaterialId, StoreType.Kitchen, l.Quantity, StockMovementType.StockReleaseIn, release.Id, null), + }) + .ToList(); + + var ledgerResult = await InventoryLedger.ApplyAsync(db, movements, currentUser.UserId!.Value, now, cancellationToken); + if (ledgerResult.IsFailure) + { + return Result.Failure(ledgerResult.Error); + } + + await db.SaveChangesAsync(cancellationToken); + + var lines = request.Lines + .Select(l => new StockMovementLineDto( + l.RawMaterialId, rawMaterials[l.RawMaterialId].Name, rawMaterials[l.RawMaterialId].UnitOfMeasurement, l.Quantity)) + .OrderBy(l => l.RawMaterialName) + .ToList(); + + var requestedByName = await db.Users.Where(u => u.Id == release.RequestedByUserId) + .Select(u => u.FullName).FirstAsync(cancellationToken); + + return Result.Success(new StockReleaseDto( + release.Id, release.RequestedByUserId, requestedByName, release.RequestedAtUtc, + release.ApprovedByUserId, approval.Value.ApprovedByName, release.ApprovedAtUtc, release.Notes, lines)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Inventory/Commands/SetRawMaterialActive/SetRawMaterialActiveCommand.cs b/backend/src/RestaurantPOS.Application/Inventory/Commands/SetRawMaterialActive/SetRawMaterialActiveCommand.cs new file mode 100644 index 0000000..1c17651 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Inventory/Commands/SetRawMaterialActive/SetRawMaterialActiveCommand.cs @@ -0,0 +1,40 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Inventory.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Inventory.Commands.SetRawMaterialActive; + +public sealed record SetRawMaterialActiveCommand(Guid RawMaterialId, bool IsActive) : IRequest>; + +internal sealed class SetRawMaterialActiveCommandHandler(IAppDbContext db) + : IRequestHandler> +{ + public async Task> Handle(SetRawMaterialActiveCommand request, CancellationToken cancellationToken) + { + var rawMaterial = await db.RawMaterials.FirstOrDefaultAsync(r => r.Id == request.RawMaterialId, cancellationToken); + + if (rawMaterial is null) + { + return Result.Failure(InventoryErrors.RawMaterialNotFound(request.RawMaterialId)); + } + + if (request.IsActive) + { + rawMaterial.Activate(); + } + else + { + rawMaterial.Deactivate(); + } + + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(rawMaterial.ToDto()); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Inventory/Commands/UpdateRawMaterial/UpdateRawMaterialCommand.cs b/backend/src/RestaurantPOS.Application/Inventory/Commands/UpdateRawMaterial/UpdateRawMaterialCommand.cs new file mode 100644 index 0000000..a0a972f --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Inventory/Commands/UpdateRawMaterial/UpdateRawMaterialCommand.cs @@ -0,0 +1,75 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Inventory.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Inventory.Commands.UpdateRawMaterial; + +public sealed record UpdateRawMaterialCommand( + Guid RawMaterialId, + string Name, + UnitOfMeasurement UnitOfMeasurement, + decimal? MainStoreReorderLevel, + decimal? KitchenParLevel) : IRequest>; + +public sealed class UpdateRawMaterialCommandValidator : AbstractValidator +{ + public UpdateRawMaterialCommandValidator() + { + RuleFor(x => x.RawMaterialId).NotEmpty(); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Name is required.") + .MaximumLength(RawMaterial.NameMaxLength); + + RuleFor(x => x.UnitOfMeasurement).IsInEnum().WithMessage("Select a valid unit of measurement."); + + RuleFor(x => x.MainStoreReorderLevel).GreaterThanOrEqualTo(0) + .WithMessage("Reorder level cannot be negative.") + .When(x => x.MainStoreReorderLevel is not null); + + RuleFor(x => x.KitchenParLevel).GreaterThanOrEqualTo(0) + .WithMessage("Par level cannot be negative.") + .When(x => x.KitchenParLevel is not null); + } +} + +internal sealed class UpdateRawMaterialCommandHandler(IAppDbContext db) + : IRequestHandler> +{ + public async Task> Handle(UpdateRawMaterialCommand request, CancellationToken cancellationToken) + { + var rawMaterial = await db.RawMaterials.FirstOrDefaultAsync(r => r.Id == request.RawMaterialId, cancellationToken); + + if (rawMaterial is null) + { + return Result.Failure(InventoryErrors.RawMaterialNotFound(request.RawMaterialId)); + } + + var name = request.Name.Trim(); + var normalised = name.ToLowerInvariant(); + + var nameTaken = await db.RawMaterials.AnyAsync( + r => r.Id != request.RawMaterialId && r.Name.ToLower() == normalised, cancellationToken); + + if (nameTaken) + { + return Result.Failure(InventoryErrors.RawMaterialNameTaken); + } + + rawMaterial.UpdateDetails(name, request.UnitOfMeasurement, request.MainStoreReorderLevel, request.KitchenParLevel); + + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(rawMaterial.ToDto()); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Inventory/Common/InventoryLedger.cs b/backend/src/RestaurantPOS.Application/Inventory/Common/InventoryLedger.cs new file mode 100644 index 0000000..19e87ef --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Inventory/Common/InventoryLedger.cs @@ -0,0 +1,100 @@ +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Inventory.Common; + +/// One requested change to one raw material's balance in one store. +public sealed record StockMovementRequest( + Guid RawMaterialId, + StoreType Store, + decimal QuantityDelta, + StockMovementType Type, + Guid? ReferenceId, + string? Notes); + +/// +/// The single place every stock-affecting use case (GRN, release, adjustment, consumption) goes +/// through. Every quantity change is a recorded here as a +/// permanent row, with the corresponding +/// balance kept in step — this is the only code path allowed to touch either (BR-INV-006). +/// +public static class InventoryLedger +{ + /// + /// Applies every movement in as a single all-or-nothing batch: + /// if any resulting balance would go negative, nothing is applied and the failure names the + /// offending raw material. + /// + public static async Task ApplyAsync( + IAppDbContext db, + IReadOnlyCollection movements, + Guid performedByUserId, + DateTime nowUtc, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(db); + ArgumentNullException.ThrowIfNull(movements); + + if (movements.Count == 0) + { + return Result.Failure(InventoryErrors.EmptyLines); + } + + var keys = movements.Select(m => (m.RawMaterialId, m.Store)).Distinct().ToList(); + + var levels = new Dictionary<(Guid, StoreType), StockLevel>(); + + foreach (var (rawMaterialId, store) in keys) + { + var level = await db.StockLevels + .FirstOrDefaultAsync(l => l.RawMaterialId == rawMaterialId && l.Store == store, cancellationToken); + + if (level is null) + { + level = new StockLevel(rawMaterialId, store); + db.StockLevels.Add(level); + } + + levels[(rawMaterialId, store)] = level; + } + + // Checked as one batch before anything is written, so a multi-line transaction never + // applies some of its lines and then fails partway through the rest. + var projectedBalances = levels.ToDictionary(kv => kv.Key, kv => kv.Value.QuantityOnHand); + + foreach (var movement in movements) + { + var key = (movement.RawMaterialId, movement.Store); + var projected = projectedBalances[key] + movement.QuantityDelta; + + if (projected < 0) + { + return Result.Failure(InventoryErrors.InsufficientStock); + } + + projectedBalances[key] = projected; + } + + foreach (var movement in movements) + { + levels[(movement.RawMaterialId, movement.Store)].ApplyDelta(movement.QuantityDelta, nowUtc); + + db.StockMovements.Add(new StockMovement( + movement.RawMaterialId, + movement.Store, + movement.QuantityDelta, + movement.Type, + movement.ReferenceId, + performedByUserId, + nowUtc, + movement.Notes)); + } + + return Result.Success(); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Inventory/Dtos/RawMaterialDto.cs b/backend/src/RestaurantPOS.Application/Inventory/Dtos/RawMaterialDto.cs new file mode 100644 index 0000000..fb12a27 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Inventory/Dtos/RawMaterialDto.cs @@ -0,0 +1,12 @@ +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Application.Inventory.Dtos; + +public sealed record RawMaterialDto( + Guid Id, + string Name, + UnitOfMeasurement UnitOfMeasurement, + decimal? MainStoreReorderLevel, + decimal? KitchenParLevel, + bool IsActive, + DateTime CreatedAtUtc); \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Inventory/Dtos/StockDtos.cs b/backend/src/RestaurantPOS.Application/Inventory/Dtos/StockDtos.cs new file mode 100644 index 0000000..f39c286 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Inventory/Dtos/StockDtos.cs @@ -0,0 +1,93 @@ +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Application.Inventory.Dtos; + +/// A raw material's current balance in one store. +public sealed record StockLevelDto( + Guid RawMaterialId, + string RawMaterialName, + UnitOfMeasurement UnitOfMeasurement, + StoreType Store, + decimal QuantityOnHand, + bool IsLowStock); + +/// One row in the permanent stock ledger, with display details denormalised in. +public sealed record StockMovementDto( + Guid Id, + Guid RawMaterialId, + string RawMaterialName, + UnitOfMeasurement UnitOfMeasurement, + StoreType Store, + decimal QuantityDelta, + StockMovementType Type, + Guid? ReferenceId, + Guid PerformedByUserId, + string PerformedByName, + DateTime OccurredAtUtc, + string? Notes); + +/// A Goods Received Note with its lines reconstructed from the stock ledger. +public sealed record GoodsReceivedNoteDto( + Guid Id, + Guid SupplierId, + string SupplierName, + Guid ReceivedByUserId, + string ReceivedByName, + DateTime ReceivedAtUtc, + string? Notes, + Guid? PurchaseOrderId, + int? QualityRating, + bool HasIssue, + IReadOnlyCollection Lines); + +/// A Main Store → Kitchen stock release with its lines reconstructed from the stock ledger. +public sealed record StockReleaseDto( + Guid Id, + Guid RequestedByUserId, + string RequestedByName, + DateTime RequestedAtUtc, + Guid ApprovedByUserId, + string ApprovedByName, + DateTime ApprovedAtUtc, + string? Notes, + IReadOnlyCollection Lines); + +/// One ingredient quantity within a GRN or release, without the ledger bookkeeping fields. +public sealed record StockMovementLineDto( + Guid RawMaterialId, string RawMaterialName, UnitOfMeasurement UnitOfMeasurement, decimal Quantity); + +/// A GRN's header for a list screen, without its lines. +public sealed record GoodsReceivedNoteSummaryDto( + Guid Id, + Guid SupplierId, + string SupplierName, + Guid ReceivedByUserId, + string ReceivedByName, + DateTime ReceivedAtUtc, + string? Notes, + Guid? PurchaseOrderId, + int? QualityRating, + bool HasIssue, + int LineCount); + +/// A stock release's header for a list screen, without its lines. +public sealed record StockReleaseSummaryDto( + Guid Id, + Guid RequestedByUserId, + string RequestedByName, + DateTime RequestedAtUtc, + Guid ApprovedByUserId, + string ApprovedByName, + DateTime ApprovedAtUtc, + string? Notes, + int LineCount); + +/// One raw material and quantity deducted when a menu item's recipe was applied to a sale. +public sealed record ConsumedLineDto( + Guid RawMaterialId, string RawMaterialName, decimal QuantityDeducted, UnitOfMeasurement UnitOfMeasurement); + +/// +/// The outcome of applying a menu item's recipe to a completed sale. is +/// false — not a failure — when the item simply has no recipe or its recipe is disabled. +/// +public sealed record ConsumptionResultDto(bool Deducted, IReadOnlyCollection Lines); \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Inventory/Queries/GetGoodsReceivedNoteById/GetGoodsReceivedNoteByIdQuery.cs b/backend/src/RestaurantPOS.Application/Inventory/Queries/GetGoodsReceivedNoteById/GetGoodsReceivedNoteByIdQuery.cs new file mode 100644 index 0000000..aa9dedf --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Inventory/Queries/GetGoodsReceivedNoteById/GetGoodsReceivedNoteByIdQuery.cs @@ -0,0 +1,52 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Inventory.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Inventory.Queries.GetGoodsReceivedNoteById; + +public sealed record GetGoodsReceivedNoteByIdQuery(Guid GoodsReceivedNoteId) : IRequest>; + +internal sealed class GetGoodsReceivedNoteByIdQueryHandler(IAppDbContext db) + : IRequestHandler> +{ + public async Task> Handle( + GetGoodsReceivedNoteByIdQuery request, CancellationToken cancellationToken) + { + var note = await db.GoodsReceivedNotes.AsNoTracking() + .FirstOrDefaultAsync(n => n.Id == request.GoodsReceivedNoteId, cancellationToken); + + if (note is null) + { + return Result.Failure(InventoryErrors.GoodsReceivedNoteNotFound(request.GoodsReceivedNoteId)); + } + + var supplier = await db.Suppliers.AsNoTracking().FirstAsync(s => s.Id == note.SupplierId, cancellationToken); + var receivedByName = await db.Users.AsNoTracking().Where(u => u.Id == note.ReceivedByUserId) + .Select(u => u.FullName).FirstAsync(cancellationToken); + + var movements = await db.StockMovements.AsNoTracking() + .Where(m => m.ReferenceId == note.Id && m.Type == StockMovementType.GoodsReceived) + .ToListAsync(cancellationToken); + + var rawMaterialIds = movements.Select(m => m.RawMaterialId).ToList(); + var rawMaterials = await db.RawMaterials.AsNoTracking() + .Where(r => rawMaterialIds.Contains(r.Id)) + .ToDictionaryAsync(r => r.Id, cancellationToken); + + var lines = movements + .Select(m => new StockMovementLineDto( + m.RawMaterialId, rawMaterials[m.RawMaterialId].Name, rawMaterials[m.RawMaterialId].UnitOfMeasurement, m.QuantityDelta)) + .OrderBy(l => l.RawMaterialName) + .ToList(); + + return Result.Success(new GoodsReceivedNoteDto( + note.Id, note.SupplierId, supplier.Name, note.ReceivedByUserId, receivedByName, + note.ReceivedAtUtc, note.Notes, note.PurchaseOrderId, note.QualityRating, note.HasIssue, lines)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Inventory/Queries/GetGoodsReceivedNotes/GetGoodsReceivedNotesQuery.cs b/backend/src/RestaurantPOS.Application/Inventory/Queries/GetGoodsReceivedNotes/GetGoodsReceivedNotesQuery.cs new file mode 100644 index 0000000..44d148a --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Inventory/Queries/GetGoodsReceivedNotes/GetGoodsReceivedNotesQuery.cs @@ -0,0 +1,59 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Inventory.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Application.Inventory.Queries.GetGoodsReceivedNotes; + +public sealed record GetGoodsReceivedNotesQuery : IRequest>>; + +internal sealed class GetGoodsReceivedNotesQueryHandler(IAppDbContext db) + : IRequestHandler>> +{ + private const int MaxRows = 200; + + public async Task>> Handle( + GetGoodsReceivedNotesQuery request, CancellationToken cancellationToken) + { + var notes = await db.GoodsReceivedNotes.AsNoTracking() + .OrderByDescending(n => n.ReceivedAtUtc) + .Take(MaxRows) + .ToListAsync(cancellationToken); + + var supplierNames = await db.Suppliers.AsNoTracking() + .ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken); + + var userNames = await db.Users.AsNoTracking() + .ToDictionaryAsync(u => u.Id, u => u.FullName, cancellationToken); + + // Every GoodsReceived movement carries a non-null ReferenceId (the GRN's own id), so the + // group key can be safely unwrapped once nulls are excluded. + var lineCounts = await db.StockMovements.AsNoTracking() + .Where(m => m.Type == StockMovementType.GoodsReceived && m.ReferenceId != null) + .GroupBy(m => m.ReferenceId!.Value) + .Select(g => new { ReferenceId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(g => g.ReferenceId, g => g.Count, cancellationToken); + + IReadOnlyCollection result = + [ + .. notes.Select(n => new GoodsReceivedNoteSummaryDto( + n.Id, + n.SupplierId, + supplierNames.GetValueOrDefault(n.SupplierId, "(unknown)"), + n.ReceivedByUserId, + userNames.GetValueOrDefault(n.ReceivedByUserId, "(unknown)"), + n.ReceivedAtUtc, + n.Notes, + n.PurchaseOrderId, + n.QualityRating, + n.HasIssue, + lineCounts.GetValueOrDefault(n.Id, 0))), + ]; + + return Result.Success(result); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Inventory/Queries/GetRawMaterials/GetRawMaterialsQuery.cs b/backend/src/RestaurantPOS.Application/Inventory/Queries/GetRawMaterials/GetRawMaterialsQuery.cs new file mode 100644 index 0000000..89c13c7 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Inventory/Queries/GetRawMaterials/GetRawMaterialsQuery.cs @@ -0,0 +1,44 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Inventory.Dtos; +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Application.Inventory.Queries.GetRawMaterials; + +public sealed record GetRawMaterialsQuery(string? Search, bool? IsActive) + : IRequest>>; + +internal sealed class GetRawMaterialsQueryHandler(IAppDbContext db) + : IRequestHandler>> +{ + public async Task>> Handle( + GetRawMaterialsQuery request, + CancellationToken cancellationToken) + { + var query = db.RawMaterials.AsNoTracking().AsQueryable(); + + if (!string.IsNullOrWhiteSpace(request.Search)) + { + var term = request.Search.Trim().ToLowerInvariant(); + query = query.Where(r => r.Name.ToLower().Contains(term)); + } + + if (request.IsActive is not null) + { + query = query.Where(r => r.IsActive == request.IsActive.Value); + } + + var rawMaterials = await query + .OrderByDescending(r => r.IsActive) + .ThenBy(r => r.Name) + .ToListAsync(cancellationToken); + + IReadOnlyCollection result = [.. rawMaterials.Select(r => r.ToDto())]; + + return Result.Success(result); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Inventory/Queries/GetStockLevels/GetStockLevelsQuery.cs b/backend/src/RestaurantPOS.Application/Inventory/Queries/GetStockLevels/GetStockLevelsQuery.cs new file mode 100644 index 0000000..39e7599 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Inventory/Queries/GetStockLevels/GetStockLevelsQuery.cs @@ -0,0 +1,53 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Inventory.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Application.Inventory.Queries.GetStockLevels; + +/// +/// The current stock view for one store. Every active raw material appears, even ones with no +/// movements yet (balance 0) — this is a snapshot of what should be on the shelf, not just a +/// list of things that have happened. +/// +public sealed record GetStockLevelsQuery(StoreType Store, bool LowStockOnly = false) + : IRequest>>; + +internal sealed class GetStockLevelsQueryHandler(IAppDbContext db) + : IRequestHandler>> +{ + public async Task>> Handle( + GetStockLevelsQuery request, + CancellationToken cancellationToken) + { + var rawMaterials = await db.RawMaterials.AsNoTracking() + .Where(r => r.IsActive) + .ToListAsync(cancellationToken); + + var balances = await db.StockLevels.AsNoTracking() + .Where(l => l.Store == request.Store) + .ToDictionaryAsync(l => l.RawMaterialId, l => l.QuantityOnHand, cancellationToken); + + var rows = rawMaterials.Select(r => + { + var quantity = balances.GetValueOrDefault(r.Id, 0m); + var threshold = request.Store == StoreType.MainStore ? r.MainStoreReorderLevel : r.KitchenParLevel; + var isLow = threshold is not null && quantity <= threshold.Value; + + return new StockLevelDto(r.Id, r.Name, r.UnitOfMeasurement, request.Store, quantity, isLow); + }); + + if (request.LowStockOnly) + { + rows = rows.Where(r => r.IsLowStock); + } + + IReadOnlyCollection result = [.. rows.OrderBy(r => r.RawMaterialName)]; + + return Result.Success(result); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Inventory/Queries/GetStockMovements/GetStockMovementsQuery.cs b/backend/src/RestaurantPOS.Application/Inventory/Queries/GetStockMovements/GetStockMovementsQuery.cs new file mode 100644 index 0000000..dd20c76 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Inventory/Queries/GetStockMovements/GetStockMovementsQuery.cs @@ -0,0 +1,79 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Inventory.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Application.Inventory.Queries.GetStockMovements; + +/// The stock history for a store, optionally narrowed to one raw material or date range. +public sealed record GetStockMovementsQuery( + StoreType Store, + Guid? RawMaterialId, + DateTime? FromUtc, + DateTime? ToUtc) : IRequest>>; + +internal sealed class GetStockMovementsQueryHandler(IAppDbContext db) + : IRequestHandler>> +{ + private const int MaxRows = 500; + + public async Task>> Handle( + GetStockMovementsQuery request, + CancellationToken cancellationToken) + { + var query = db.StockMovements.AsNoTracking().Where(m => m.Store == request.Store); + + if (request.RawMaterialId is not null) + { + query = query.Where(m => m.RawMaterialId == request.RawMaterialId.Value); + } + + if (request.FromUtc is not null) + { + query = query.Where(m => m.OccurredAtUtc >= request.FromUtc.Value); + } + + if (request.ToUtc is not null) + { + query = query.Where(m => m.OccurredAtUtc <= request.ToUtc.Value); + } + + var movements = await query + .OrderByDescending(m => m.OccurredAtUtc) + .Take(MaxRows) + .ToListAsync(cancellationToken); + + var rawMaterialIds = movements.Select(m => m.RawMaterialId).Distinct().ToList(); + var rawMaterials = await db.RawMaterials.AsNoTracking() + .Where(r => rawMaterialIds.Contains(r.Id)) + .ToDictionaryAsync(r => r.Id, cancellationToken); + + var userIds = movements.Select(m => m.PerformedByUserId).Distinct().ToList(); + var userNames = await db.Users.AsNoTracking() + .Where(u => userIds.Contains(u.Id)) + .ToDictionaryAsync(u => u.Id, u => u.FullName, cancellationToken); + + IReadOnlyCollection result = + [ + .. movements.Select(m => new StockMovementDto( + m.Id, + m.RawMaterialId, + rawMaterials.TryGetValue(m.RawMaterialId, out var rawMaterial) ? rawMaterial.Name : "(deleted)", + rawMaterial?.UnitOfMeasurement ?? default, + m.Store, + m.QuantityDelta, + m.Type, + m.ReferenceId, + m.PerformedByUserId, + userNames.GetValueOrDefault(m.PerformedByUserId, "(unknown)"), + m.OccurredAtUtc, + m.Notes)), + ]; + + return Result.Success(result); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Inventory/Queries/GetStockReleaseById/GetStockReleaseByIdQuery.cs b/backend/src/RestaurantPOS.Application/Inventory/Queries/GetStockReleaseById/GetStockReleaseByIdQuery.cs new file mode 100644 index 0000000..091b082 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Inventory/Queries/GetStockReleaseById/GetStockReleaseByIdQuery.cs @@ -0,0 +1,55 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Inventory.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Inventory.Queries.GetStockReleaseById; + +public sealed record GetStockReleaseByIdQuery(Guid StockReleaseId) : IRequest>; + +internal sealed class GetStockReleaseByIdQueryHandler(IAppDbContext db) + : IRequestHandler> +{ + public async Task> Handle( + GetStockReleaseByIdQuery request, CancellationToken cancellationToken) + { + var release = await db.StockReleases.AsNoTracking() + .FirstOrDefaultAsync(r => r.Id == request.StockReleaseId, cancellationToken); + + if (release is null) + { + return Result.Failure(InventoryErrors.StockReleaseNotFound(request.StockReleaseId)); + } + + var requestedByName = await db.Users.AsNoTracking().Where(u => u.Id == release.RequestedByUserId) + .Select(u => u.FullName).FirstAsync(cancellationToken); + var approvedByName = await db.Users.AsNoTracking().Where(u => u.Id == release.ApprovedByUserId) + .Select(u => u.FullName).FirstAsync(cancellationToken); + + // Only the Main Store side is read: it carries the same raw material set and magnitude + // as the Kitchen side, just with the opposite sign, so one side is enough to rebuild the lines. + var movements = await db.StockMovements.AsNoTracking() + .Where(m => m.ReferenceId == release.Id && m.Type == StockMovementType.StockReleaseOut) + .ToListAsync(cancellationToken); + + var rawMaterialIds = movements.Select(m => m.RawMaterialId).ToList(); + var rawMaterials = await db.RawMaterials.AsNoTracking() + .Where(r => rawMaterialIds.Contains(r.Id)) + .ToDictionaryAsync(r => r.Id, cancellationToken); + + var lines = movements + .Select(m => new StockMovementLineDto( + m.RawMaterialId, rawMaterials[m.RawMaterialId].Name, rawMaterials[m.RawMaterialId].UnitOfMeasurement, -m.QuantityDelta)) + .OrderBy(l => l.RawMaterialName) + .ToList(); + + return Result.Success(new StockReleaseDto( + release.Id, release.RequestedByUserId, requestedByName, release.RequestedAtUtc, + release.ApprovedByUserId, approvedByName, release.ApprovedAtUtc, release.Notes, lines)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Inventory/Queries/GetStockReleases/GetStockReleasesQuery.cs b/backend/src/RestaurantPOS.Application/Inventory/Queries/GetStockReleases/GetStockReleasesQuery.cs new file mode 100644 index 0000000..67e479e --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Inventory/Queries/GetStockReleases/GetStockReleasesQuery.cs @@ -0,0 +1,54 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Inventory.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Application.Inventory.Queries.GetStockReleases; + +public sealed record GetStockReleasesQuery : IRequest>>; + +internal sealed class GetStockReleasesQueryHandler(IAppDbContext db) + : IRequestHandler>> +{ + private const int MaxRows = 200; + + public async Task>> Handle( + GetStockReleasesQuery request, CancellationToken cancellationToken) + { + var releases = await db.StockReleases.AsNoTracking() + .OrderByDescending(r => r.RequestedAtUtc) + .Take(MaxRows) + .ToListAsync(cancellationToken); + + var userNames = await db.Users.AsNoTracking() + .ToDictionaryAsync(u => u.Id, u => u.FullName, cancellationToken); + + // Two movement rows (out + in) are written per raw material line, so halve the count to + // report the number of raw materials released, not the number of ledger rows. + var lineCounts = await db.StockMovements.AsNoTracking() + .Where(m => m.Type == StockMovementType.StockReleaseOut && m.ReferenceId != null) + .GroupBy(m => m.ReferenceId!.Value) + .Select(g => new { ReferenceId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(g => g.ReferenceId, g => g.Count, cancellationToken); + + IReadOnlyCollection result = + [ + .. releases.Select(r => new StockReleaseSummaryDto( + r.Id, + r.RequestedByUserId, + userNames.GetValueOrDefault(r.RequestedByUserId, "(unknown)"), + r.RequestedAtUtc, + r.ApprovedByUserId, + userNames.GetValueOrDefault(r.ApprovedByUserId, "(unknown)"), + r.ApprovedAtUtc, + r.Notes, + lineCounts.GetValueOrDefault(r.Id, 0))), + ]; + + return Result.Success(result); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Recipes/Commands/CreateMenuItem/CreateMenuItemCommand.cs b/backend/src/RestaurantPOS.Application/Recipes/Commands/CreateMenuItem/CreateMenuItemCommand.cs new file mode 100644 index 0000000..be1e3a2 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Recipes/Commands/CreateMenuItem/CreateMenuItemCommand.cs @@ -0,0 +1,57 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Recipes.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Recipes.Commands.CreateMenuItem; + +public sealed record CreateMenuItemCommand(string Name, string Category, decimal Price) + : IRequest>; + +public sealed class CreateMenuItemCommandValidator : AbstractValidator +{ + public CreateMenuItemCommandValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Name is required.") + .MaximumLength(MenuItem.NameMaxLength); + + RuleFor(x => x.Category) + .NotEmpty().WithMessage("Category is required.") + .MaximumLength(MenuItem.CategoryMaxLength); + + RuleFor(x => x.Price).GreaterThanOrEqualTo(0).WithMessage("Price cannot be negative."); + } +} + +internal sealed class CreateMenuItemCommandHandler(IAppDbContext db) + : IRequestHandler> +{ + public async Task> Handle(CreateMenuItemCommand request, CancellationToken cancellationToken) + { + var name = request.Name.Trim(); + var normalised = name.ToLowerInvariant(); + + var exists = await db.MenuItems.AnyAsync(m => m.Name.ToLower() == normalised, cancellationToken); + if (exists) + { + return Result.Failure(RecipeErrors.MenuItemNameTaken); + } + + var menuItem = MenuItem.Create(name, request.Category, request.Price); + + db.MenuItems.Add(menuItem); + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(new MenuItemDto( + menuItem.Id, menuItem.Name, menuItem.Category, menuItem.Price, menuItem.IsActive, + HasRecipe: false, menuItem.CreatedAtUtc)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Recipes/Commands/DeleteRecipe/DeleteRecipeCommand.cs b/backend/src/RestaurantPOS.Application/Recipes/Commands/DeleteRecipe/DeleteRecipeCommand.cs new file mode 100644 index 0000000..3e8443c --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Recipes/Commands/DeleteRecipe/DeleteRecipeCommand.cs @@ -0,0 +1,52 @@ +using System.Text.Json; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Audit; +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Recipes.Commands.DeleteRecipe; + +/// +/// Removes a menu item's recipe. The audit log keeps a full snapshot of what was deleted +/// (BR-REC-007), even though the live row is gone — a fresh recipe can be created afterwards. +/// +public sealed record DeleteRecipeCommand(Guid MenuItemId) : IRequest; + +internal sealed class DeleteRecipeCommandHandler(IAppDbContext db, ICurrentUser currentUser, IDateTimeProvider clock) + : IRequestHandler +{ + public async Task Handle(DeleteRecipeCommand request, CancellationToken cancellationToken) + { + var recipe = await db.Recipes + .Include(r => r.Lines) + .FirstOrDefaultAsync(r => r.MenuItemId == request.MenuItemId, cancellationToken); + + if (recipe is null) + { + return Result.Failure(RecipeErrors.RecipeNotFound(request.MenuItemId)); + } + + var snapshot = recipe.Lines.Select(l => new { l.RawMaterialId, l.Quantity }).ToList(); + + AuditLog.Record( + db, + entityType: "Recipe", + entityId: recipe.Id, + action: "Deleted", + performedByUserId: currentUser.UserId!.Value, + performedByName: currentUser.Username ?? "unknown", + nowUtc: clock.UtcNow, + summary: $"Deleted recipe that had {snapshot.Count} ingredient(s).", + detailsJson: JsonSerializer.Serialize(snapshot)); + + db.Recipes.Remove(recipe); + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Recipes/Commands/SetMenuItemActive/SetMenuItemActiveCommand.cs b/backend/src/RestaurantPOS.Application/Recipes/Commands/SetMenuItemActive/SetMenuItemActiveCommand.cs new file mode 100644 index 0000000..0248de0 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Recipes/Commands/SetMenuItemActive/SetMenuItemActiveCommand.cs @@ -0,0 +1,47 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Recipes.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Recipes.Commands.SetMenuItemActive; + +/// +/// Activates or deactivates a menu item. There is no delete: past orders (once Point of Sale +/// exists) will reference this row, so history must not disappear from under them. +/// +public sealed record SetMenuItemActiveCommand(Guid MenuItemId, bool IsActive) : IRequest>; + +internal sealed class SetMenuItemActiveCommandHandler(IAppDbContext db) + : IRequestHandler> +{ + public async Task> Handle(SetMenuItemActiveCommand request, CancellationToken cancellationToken) + { + var menuItem = await db.MenuItems.FirstOrDefaultAsync(m => m.Id == request.MenuItemId, cancellationToken); + + if (menuItem is null) + { + return Result.Failure(RecipeErrors.MenuItemNotFound(request.MenuItemId)); + } + + if (request.IsActive) + { + menuItem.Activate(); + } + else + { + menuItem.Deactivate(); + } + + var hasRecipe = await db.Recipes.AnyAsync(r => r.MenuItemId == menuItem.Id, cancellationToken); + + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(new MenuItemDto( + menuItem.Id, menuItem.Name, menuItem.Category, menuItem.Price, menuItem.IsActive, + hasRecipe, menuItem.CreatedAtUtc)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Recipes/Commands/SetRecipeEnabled/SetRecipeEnabledCommand.cs b/backend/src/RestaurantPOS.Application/Recipes/Commands/SetRecipeEnabled/SetRecipeEnabledCommand.cs new file mode 100644 index 0000000..16f9648 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Recipes/Commands/SetRecipeEnabled/SetRecipeEnabledCommand.cs @@ -0,0 +1,54 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Audit; +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Recipes.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Recipes.Commands.SetRecipeEnabled; + +/// Enables or disables a recipe (BR-REC-006). A disabled recipe cannot be used for a new sale. +public sealed record SetRecipeEnabledCommand(Guid MenuItemId, bool IsEnabled) : IRequest>; + +internal sealed class SetRecipeEnabledCommandHandler(IAppDbContext db, ICurrentUser currentUser, IDateTimeProvider clock) + : IRequestHandler> +{ + public async Task> Handle(SetRecipeEnabledCommand request, CancellationToken cancellationToken) + { + var recipe = await db.Recipes + .Include(r => r.Lines) + .FirstOrDefaultAsync(r => r.MenuItemId == request.MenuItemId, cancellationToken); + + if (recipe is null) + { + return Result.Failure(RecipeErrors.RecipeNotFound(request.MenuItemId)); + } + + if (request.IsEnabled) + { + recipe.Enable(); + } + else + { + recipe.Disable(); + } + + AuditLog.Record( + db, + entityType: "Recipe", + entityId: recipe.Id, + action: request.IsEnabled ? "Enabled" : "Disabled", + performedByUserId: currentUser.UserId!.Value, + performedByName: currentUser.Username ?? "unknown", + nowUtc: clock.UtcNow, + summary: request.IsEnabled ? "Recipe enabled." : "Recipe disabled."); + + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(await recipe.ToDtoAsync(db, cancellationToken)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Recipes/Commands/UpdateMenuItem/UpdateMenuItemCommand.cs b/backend/src/RestaurantPOS.Application/Recipes/Commands/UpdateMenuItem/UpdateMenuItemCommand.cs new file mode 100644 index 0000000..180f97f --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Recipes/Commands/UpdateMenuItem/UpdateMenuItemCommand.cs @@ -0,0 +1,69 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Recipes.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Recipes.Commands.UpdateMenuItem; + +public sealed record UpdateMenuItemCommand(Guid MenuItemId, string Name, string Category, decimal Price) + : IRequest>; + +public sealed class UpdateMenuItemCommandValidator : AbstractValidator +{ + public UpdateMenuItemCommandValidator() + { + RuleFor(x => x.MenuItemId).NotEmpty(); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Name is required.") + .MaximumLength(MenuItem.NameMaxLength); + + RuleFor(x => x.Category) + .NotEmpty().WithMessage("Category is required.") + .MaximumLength(MenuItem.CategoryMaxLength); + + RuleFor(x => x.Price).GreaterThanOrEqualTo(0).WithMessage("Price cannot be negative."); + } +} + +internal sealed class UpdateMenuItemCommandHandler(IAppDbContext db) + : IRequestHandler> +{ + public async Task> Handle(UpdateMenuItemCommand request, CancellationToken cancellationToken) + { + var menuItem = await db.MenuItems.FirstOrDefaultAsync(m => m.Id == request.MenuItemId, cancellationToken); + + if (menuItem is null) + { + return Result.Failure(RecipeErrors.MenuItemNotFound(request.MenuItemId)); + } + + var name = request.Name.Trim(); + var normalised = name.ToLowerInvariant(); + + var nameTaken = await db.MenuItems.AnyAsync( + m => m.Id != request.MenuItemId && m.Name.ToLower() == normalised, cancellationToken); + + if (nameTaken) + { + return Result.Failure(RecipeErrors.MenuItemNameTaken); + } + + menuItem.UpdateDetails(name, request.Category, request.Price); + + var hasRecipe = await db.Recipes.AnyAsync(r => r.MenuItemId == menuItem.Id, cancellationToken); + + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(new MenuItemDto( + menuItem.Id, menuItem.Name, menuItem.Category, menuItem.Price, menuItem.IsActive, + hasRecipe, menuItem.CreatedAtUtc)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Recipes/Commands/UpsertRecipe/UpsertRecipeCommand.cs b/backend/src/RestaurantPOS.Application/Recipes/Commands/UpsertRecipe/UpsertRecipeCommand.cs new file mode 100644 index 0000000..f0ef52b --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Recipes/Commands/UpsertRecipe/UpsertRecipeCommand.cs @@ -0,0 +1,110 @@ +using System.Text.Json; + +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Audit; +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Recipes.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Recipes.Commands.UpsertRecipe; + +/// One ingredient the caller wants on the recipe. +public sealed record RecipeLineInput(Guid RawMaterialId, decimal Quantity); + +/// +/// Creates a menu item's recipe if it has none, or replaces its lines wholesale if it already +/// does (REC-006). One endpoint for both, since "one recipe per menu item" (BR-REC-001) makes +/// create-vs-update a distinction the caller should not need to track. +/// +public sealed record UpsertRecipeCommand(Guid MenuItemId, IReadOnlyCollection Lines) + : IRequest>; + +public sealed class UpsertRecipeCommandValidator : AbstractValidator +{ + public UpsertRecipeCommandValidator() + { + RuleFor(x => x.MenuItemId).NotEmpty(); + + RuleFor(x => x.Lines) + .NotEmpty().WithMessage("A recipe must contain at least one raw material."); + + RuleForEach(x => x.Lines).ChildRules(line => + line.RuleFor(l => l.Quantity).GreaterThan(0).WithMessage("Quantity must be greater than zero.")); + + RuleFor(x => x.Lines) + .Must(lines => lines.Select(l => l.RawMaterialId).Distinct().Count() == lines.Count) + .WithMessage("A raw material cannot appear more than once in the same recipe.") + .When(x => x.Lines.Count > 0); + } +} + +internal sealed class UpsertRecipeCommandHandler(IAppDbContext db, ICurrentUser currentUser, IDateTimeProvider clock) + : IRequestHandler> +{ + public async Task> Handle(UpsertRecipeCommand request, CancellationToken cancellationToken) + { + var menuItemExists = await db.MenuItems.AnyAsync(m => m.Id == request.MenuItemId, cancellationToken); + if (!menuItemExists) + { + return Result.Failure(RecipeErrors.MenuItemNotFound(request.MenuItemId)); + } + + var rawMaterialIds = request.Lines.Select(l => l.RawMaterialId).ToList(); + + var rawMaterials = await db.RawMaterials + .Where(r => rawMaterialIds.Contains(r.Id)) + .ToDictionaryAsync(r => r.Id, cancellationToken); + + if (rawMaterials.Count != rawMaterialIds.Distinct().Count()) + { + return Result.Failure(RecipeErrors.UnknownRawMaterial); + } + + if (rawMaterials.Values.Any(r => !r.IsActive)) + { + return Result.Failure(RecipeErrors.InactiveRawMaterial); + } + + var lines = request.Lines.Select(l => (l.RawMaterialId, l.Quantity)).ToList(); + + var recipe = await db.Recipes + .Include(r => r.Lines) + .FirstOrDefaultAsync(r => r.MenuItemId == request.MenuItemId, cancellationToken); + var isNew = recipe is null; + + if (recipe is null) + { + recipe = Recipe.Create(request.MenuItemId, lines); + db.Recipes.Add(recipe); + } + else + { + recipe.ReplaceLines(lines); + } + + var now = clock.UtcNow; + + AuditLog.Record( + db, + entityType: "Recipe", + entityId: recipe.Id, + action: isNew ? "Created" : "Updated", + performedByUserId: currentUser.UserId!.Value, + performedByName: currentUser.Username ?? "unknown", + nowUtc: now, + summary: $"{(isNew ? "Created" : "Updated")} recipe with {lines.Count} ingredient(s).", + detailsJson: JsonSerializer.Serialize(lines)); + + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(await recipe.ToDtoAsync(db, cancellationToken)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Recipes/Dtos/MenuItemDto.cs b/backend/src/RestaurantPOS.Application/Recipes/Dtos/MenuItemDto.cs new file mode 100644 index 0000000..6e5db96 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Recipes/Dtos/MenuItemDto.cs @@ -0,0 +1,11 @@ +namespace RestaurantPOS.Application.Recipes.Dtos; + +/// A menu item as presented to the client. +public sealed record MenuItemDto( + Guid Id, + string Name, + string Category, + decimal Price, + bool IsActive, + bool HasRecipe, + DateTime CreatedAtUtc); \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Recipes/Dtos/RecipeDto.cs b/backend/src/RestaurantPOS.Application/Recipes/Dtos/RecipeDto.cs new file mode 100644 index 0000000..6c118f1 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Recipes/Dtos/RecipeDto.cs @@ -0,0 +1,18 @@ +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Application.Recipes.Dtos; + +/// One ingredient line, with the raw material's own display details denormalised in. +public sealed record RecipeLineDto( + Guid RawMaterialId, + string RawMaterialName, + UnitOfMeasurement UnitOfMeasurement, + decimal Quantity); + +public sealed record RecipeDto( + Guid Id, + Guid MenuItemId, + bool IsEnabled, + IReadOnlyCollection Lines, + DateTime CreatedAtUtc, + DateTime? UpdatedAtUtc); \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Recipes/Queries/GetMenuItemById/GetMenuItemByIdQuery.cs b/backend/src/RestaurantPOS.Application/Recipes/Queries/GetMenuItemById/GetMenuItemByIdQuery.cs new file mode 100644 index 0000000..e531506 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Recipes/Queries/GetMenuItemById/GetMenuItemByIdQuery.cs @@ -0,0 +1,33 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Recipes.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Recipes.Queries.GetMenuItemById; + +public sealed record GetMenuItemByIdQuery(Guid MenuItemId) : IRequest>; + +internal sealed class GetMenuItemByIdQueryHandler(IAppDbContext db) + : IRequestHandler> +{ + public async Task> Handle(GetMenuItemByIdQuery request, CancellationToken cancellationToken) + { + var menuItem = await db.MenuItems.AsNoTracking() + .FirstOrDefaultAsync(m => m.Id == request.MenuItemId, cancellationToken); + + if (menuItem is null) + { + return Result.Failure(RecipeErrors.MenuItemNotFound(request.MenuItemId)); + } + + var hasRecipe = await db.Recipes.AsNoTracking().AnyAsync(r => r.MenuItemId == menuItem.Id, cancellationToken); + + return Result.Success(new MenuItemDto( + menuItem.Id, menuItem.Name, menuItem.Category, menuItem.Price, menuItem.IsActive, + hasRecipe, menuItem.CreatedAtUtc)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Recipes/Queries/GetMenuItems/GetMenuItemsQuery.cs b/backend/src/RestaurantPOS.Application/Recipes/Queries/GetMenuItems/GetMenuItemsQuery.cs new file mode 100644 index 0000000..dd76ce6 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Recipes/Queries/GetMenuItems/GetMenuItemsQuery.cs @@ -0,0 +1,59 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Recipes.Dtos; +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Application.Recipes.Queries.GetMenuItems; + +/// Matches against name or category. +/// Restricts to a single category when supplied. +/// Restricts to active or inactive items when supplied. +public sealed record GetMenuItemsQuery(string? Search, string? Category, bool? IsActive) + : IRequest>>; + +internal sealed class GetMenuItemsQueryHandler(IAppDbContext db) + : IRequestHandler>> +{ + public async Task>> Handle( + GetMenuItemsQuery request, + CancellationToken cancellationToken) + { + var query = db.MenuItems.AsNoTracking().AsQueryable(); + + if (!string.IsNullOrWhiteSpace(request.Search)) + { + var term = request.Search.Trim().ToLowerInvariant(); + query = query.Where(m => m.Name.ToLower().Contains(term) || m.Category.ToLower().Contains(term)); + } + + if (!string.IsNullOrWhiteSpace(request.Category)) + { + query = query.Where(m => m.Category == request.Category); + } + + if (request.IsActive is not null) + { + query = query.Where(m => m.IsActive == request.IsActive.Value); + } + + var recipeMenuItemIds = await db.Recipes.AsNoTracking().Select(r => r.MenuItemId).ToListAsync(cancellationToken); + var withRecipe = recipeMenuItemIds.ToHashSet(); + + var items = await query + .OrderByDescending(m => m.IsActive) + .ThenBy(m => m.Category) + .ThenBy(m => m.Name) + .ToListAsync(cancellationToken); + + IReadOnlyCollection result = + [ + .. items.Select(m => new MenuItemDto( + m.Id, m.Name, m.Category, m.Price, m.IsActive, withRecipe.Contains(m.Id), m.CreatedAtUtc)), + ]; + + return Result.Success(result); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Recipes/Queries/GetRecipeByMenuItem/GetRecipeByMenuItemQuery.cs b/backend/src/RestaurantPOS.Application/Recipes/Queries/GetRecipeByMenuItem/GetRecipeByMenuItemQuery.cs new file mode 100644 index 0000000..338aead --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Recipes/Queries/GetRecipeByMenuItem/GetRecipeByMenuItemQuery.cs @@ -0,0 +1,36 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Recipes.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Recipes.Queries.GetRecipeByMenuItem; + +/// +/// Displays every ingredient associated with a menu item (REC-011). Not every menu item has a +/// recipe, so a missing one is reported clearly rather than treated as an error. +/// +public sealed record GetRecipeByMenuItemQuery(Guid MenuItemId) : IRequest>; + +internal sealed class GetRecipeByMenuItemQueryHandler(IAppDbContext db) + : IRequestHandler> +{ + public async Task> Handle(GetRecipeByMenuItemQuery request, CancellationToken cancellationToken) + { + var menuItemExists = await db.MenuItems.AnyAsync(m => m.Id == request.MenuItemId, cancellationToken); + if (!menuItemExists) + { + return Result.Failure(RecipeErrors.MenuItemNotFound(request.MenuItemId)); + } + + var recipe = await db.Recipes.AsNoTracking() + .Include(r => r.Lines) + .FirstOrDefaultAsync(r => r.MenuItemId == request.MenuItemId, cancellationToken); + + return Result.Success(recipe is null ? null : await recipe.ToDtoAsync(db, cancellationToken)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Suppliers/Commands/CancelPurchaseOrder/CancelPurchaseOrderCommand.cs b/backend/src/RestaurantPOS.Application/Suppliers/Commands/CancelPurchaseOrder/CancelPurchaseOrderCommand.cs new file mode 100644 index 0000000..a739df9 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Suppliers/Commands/CancelPurchaseOrder/CancelPurchaseOrderCommand.cs @@ -0,0 +1,40 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Suppliers.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Suppliers.Commands.CancelPurchaseOrder; + +public sealed record CancelPurchaseOrderCommand(Guid PurchaseOrderId) : IRequest>; + +internal sealed class CancelPurchaseOrderCommandHandler(IAppDbContext db) + : IRequestHandler> +{ + public async Task> Handle( + CancelPurchaseOrderCommand request, CancellationToken cancellationToken) + { + var order = await db.PurchaseOrders.Include(o => o.Lines) + .FirstOrDefaultAsync(o => o.Id == request.PurchaseOrderId, cancellationToken); + + if (order is null) + { + return Result.Failure(SupplierErrors.PurchaseOrderNotFound(request.PurchaseOrderId)); + } + + if (order.Status is PurchaseOrderStatus.Delivered or PurchaseOrderStatus.Cancelled) + { + return Result.Failure(SupplierErrors.NotCancellable); + } + + order.Cancel(); + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(await order.ToDtoAsync(db, cancellationToken)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Suppliers/Commands/ConfirmPurchaseOrder/ConfirmPurchaseOrderCommand.cs b/backend/src/RestaurantPOS.Application/Suppliers/Commands/ConfirmPurchaseOrder/ConfirmPurchaseOrderCommand.cs new file mode 100644 index 0000000..b911fe5 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Suppliers/Commands/ConfirmPurchaseOrder/ConfirmPurchaseOrderCommand.cs @@ -0,0 +1,41 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Suppliers.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Suppliers.Commands.ConfirmPurchaseOrder; + +/// Records that the supplier has agreed to fulfil the order as sent. +public sealed record ConfirmPurchaseOrderCommand(Guid PurchaseOrderId) : IRequest>; + +internal sealed class ConfirmPurchaseOrderCommandHandler(IAppDbContext db) + : IRequestHandler> +{ + public async Task> Handle( + ConfirmPurchaseOrderCommand request, CancellationToken cancellationToken) + { + var order = await db.PurchaseOrders.Include(o => o.Lines) + .FirstOrDefaultAsync(o => o.Id == request.PurchaseOrderId, cancellationToken); + + if (order is null) + { + return Result.Failure(SupplierErrors.PurchaseOrderNotFound(request.PurchaseOrderId)); + } + + if (order.Status != PurchaseOrderStatus.Submitted) + { + return Result.Failure(SupplierErrors.NotConfirmable); + } + + order.Confirm(); + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(await order.ToDtoAsync(db, cancellationToken)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Suppliers/Commands/CreatePurchaseOrder/CreatePurchaseOrderCommand.cs b/backend/src/RestaurantPOS.Application/Suppliers/Commands/CreatePurchaseOrder/CreatePurchaseOrderCommand.cs new file mode 100644 index 0000000..e32af60 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Suppliers/Commands/CreatePurchaseOrder/CreatePurchaseOrderCommand.cs @@ -0,0 +1,84 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Suppliers.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Suppliers.Commands.CreatePurchaseOrder; + +public sealed record PurchaseOrderLineInput(Guid RawMaterialId, decimal Quantity, decimal UnitPrice); + +/// Creates a new purchase order in Draft, ready to be edited further before being sent (INV/PO-001). +public sealed record CreatePurchaseOrderCommand( + Guid SupplierId, + IReadOnlyCollection Lines, + DateTime? ExpectedDeliveryDate, + string? Notes) : IRequest>; + +public sealed class CreatePurchaseOrderCommandValidator : AbstractValidator +{ + public CreatePurchaseOrderCommandValidator() + { + RuleFor(x => x.SupplierId).NotEmpty(); + + RuleFor(x => x.Lines).NotEmpty().WithMessage("At least one line is required."); + + RuleForEach(x => x.Lines).ChildRules(line => + { + line.RuleFor(l => l.Quantity).GreaterThan(0).WithMessage("Quantity must be greater than zero."); + line.RuleFor(l => l.UnitPrice).GreaterThanOrEqualTo(0).WithMessage("Unit price cannot be negative."); + }); + + RuleFor(x => x.Lines) + .Must(lines => lines.Select(l => l.RawMaterialId).Distinct().Count() == lines.Count) + .WithMessage("A raw material cannot appear more than once on the same purchase order.") + .When(x => x.Lines.Count > 0); + + RuleFor(x => x.Notes).MaximumLength(PurchaseOrder.NotesMaxLength); + } +} + +internal sealed class CreatePurchaseOrderCommandHandler(IAppDbContext db, ICurrentUser currentUser) + : IRequestHandler> +{ + public async Task> Handle( + CreatePurchaseOrderCommand request, CancellationToken cancellationToken) + { + var supplier = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == request.SupplierId, cancellationToken); + if (supplier is null) + { + return Result.Failure(SupplierErrors.NotFound(request.SupplierId)); + } + + if (!supplier.IsActive) + { + return Result.Failure(SupplierErrors.Inactive); + } + + var rawMaterialIds = request.Lines.Select(l => l.RawMaterialId).ToList(); + var rawMaterialCount = await db.RawMaterials.CountAsync(r => rawMaterialIds.Contains(r.Id), cancellationToken); + if (rawMaterialCount != rawMaterialIds.Distinct().Count()) + { + return Result.Failure(InventoryErrors.RawMaterialNotFound(rawMaterialIds[0])); + } + + var order = PurchaseOrder.Create( + request.SupplierId, + currentUser.UserId!.Value, + request.Lines.Select(l => (l.RawMaterialId, l.Quantity, l.UnitPrice)), + request.ExpectedDeliveryDate, + request.Notes); + + db.PurchaseOrders.Add(order); + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(await order.ToDtoAsync(db, cancellationToken)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Suppliers/Commands/CreateSupplier/CreateSupplierCommand.cs b/backend/src/RestaurantPOS.Application/Suppliers/Commands/CreateSupplier/CreateSupplierCommand.cs new file mode 100644 index 0000000..3577e9f --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Suppliers/Commands/CreateSupplier/CreateSupplierCommand.cs @@ -0,0 +1,86 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Suppliers.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Suppliers.Commands.CreateSupplier; + +public sealed record CreateSupplierCommand( + string Name, + string? ContactName, + string? Phone, + string? Email, + string? Address, + int PaymentTermsDays, + decimal? CreditLimit, + int? LeadTimeDays) : IRequest>; + +public sealed class CreateSupplierCommandValidator : AbstractValidator +{ + public CreateSupplierCommandValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Name is required.") + .MaximumLength(Supplier.NameMaxLength); + + RuleFor(x => x.ContactName).MaximumLength(Supplier.ContactNameMaxLength); + RuleFor(x => x.Phone).MaximumLength(Supplier.PhoneMaxLength); + + RuleFor(x => x.Email) + .EmailAddress().WithMessage("Enter a valid email address.") + .MaximumLength(Supplier.EmailMaxLength) + .When(x => !string.IsNullOrWhiteSpace(x.Email)); + + RuleFor(x => x.Address).MaximumLength(Supplier.AddressMaxLength); + + RuleFor(x => x.PaymentTermsDays).GreaterThanOrEqualTo(0) + .WithMessage("Payment terms cannot be a negative number of days."); + + RuleFor(x => x.CreditLimit).GreaterThanOrEqualTo(0) + .WithMessage("Credit limit cannot be negative.") + .When(x => x.CreditLimit is not null); + + RuleFor(x => x.LeadTimeDays).GreaterThanOrEqualTo(0) + .WithMessage("Lead time cannot be a negative number of days.") + .When(x => x.LeadTimeDays is not null); + } +} + +internal sealed class CreateSupplierCommandHandler(IAppDbContext db) + : IRequestHandler> +{ + public async Task> Handle(CreateSupplierCommand request, CancellationToken cancellationToken) + { + var name = request.Name.Trim(); + var normalised = name.ToLowerInvariant(); + + var exists = await db.Suppliers.AnyAsync(s => s.Name.ToLower() == normalised, cancellationToken); + if (exists) + { + return Result.Failure(SupplierErrors.NameTaken); + } + + var supplier = Supplier.Create( + name, + request.ContactName, + request.Phone, + request.Email, + request.Address, + request.PaymentTermsDays, + request.CreditLimit, + request.LeadTimeDays); + + db.Suppliers.Add(supplier); + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(supplier.ToDto()); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Suppliers/Commands/RecordSupplierPayment/RecordSupplierPaymentCommand.cs b/backend/src/RestaurantPOS.Application/Suppliers/Commands/RecordSupplierPayment/RecordSupplierPaymentCommand.cs new file mode 100644 index 0000000..bf693ff --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Suppliers/Commands/RecordSupplierPayment/RecordSupplierPaymentCommand.cs @@ -0,0 +1,81 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Suppliers.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Suppliers.Commands.RecordSupplierPayment; + +public sealed record RecordSupplierPaymentCommand( + Guid PurchaseOrderId, + decimal Amount, + DateTime PaymentDateUtc, + PaymentMethod Method, + string? InvoiceReference, + string? Notes) : IRequest>; + +public sealed class RecordSupplierPaymentCommandValidator : AbstractValidator +{ + public RecordSupplierPaymentCommandValidator() + { + RuleFor(x => x.PurchaseOrderId).NotEmpty(); + RuleFor(x => x.Amount).GreaterThan(0).WithMessage("Payment amount must be greater than zero."); + RuleFor(x => x.Method).IsInEnum().WithMessage("Select a valid payment method."); + RuleFor(x => x.InvoiceReference).MaximumLength(SupplierPayment.InvoiceReferenceMaxLength); + RuleFor(x => x.Notes).MaximumLength(SupplierPayment.NotesMaxLength); + } +} + +internal sealed class RecordSupplierPaymentCommandHandler(IAppDbContext db, ICurrentUser currentUser) + : IRequestHandler> +{ + public async Task> Handle( + RecordSupplierPaymentCommand request, CancellationToken cancellationToken) + { + var order = await db.PurchaseOrders + .Include(o => o.Lines) + .FirstOrDefaultAsync(o => o.Id == request.PurchaseOrderId, cancellationToken); + + if (order is null) + { + return Result.Failure(SupplierErrors.PurchaseOrderNotFound(request.PurchaseOrderId)); + } + + var alreadyPaid = await db.SupplierPayments + .Where(p => p.PurchaseOrderId == request.PurchaseOrderId) + .SumAsync(p => (decimal?)p.Amount, cancellationToken) ?? 0m; + + var balance = order.TotalAmount - alreadyPaid; + + if (request.Amount > balance) + { + return Result.Failure(SupplierErrors.PaymentExceedsBalance(balance)); + } + + var payment = SupplierPayment.Create( + request.PurchaseOrderId, + request.Amount, + request.PaymentDateUtc, + request.Method, + currentUser.UserId!.Value, + request.InvoiceReference, + request.Notes); + + db.SupplierPayments.Add(payment); + await db.SaveChangesAsync(cancellationToken); + + var recordedByName = await db.Users.Where(u => u.Id == payment.RecordedByUserId) + .Select(u => u.FullName).FirstAsync(cancellationToken); + + return Result.Success(new SupplierPaymentDto( + payment.Id, payment.PurchaseOrderId, payment.Amount, payment.PaymentDateUtc, payment.Method, + payment.InvoiceReference, payment.RecordedByUserId, recordedByName, payment.Notes)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Suppliers/Commands/SetSupplierActive/SetSupplierActiveCommand.cs b/backend/src/RestaurantPOS.Application/Suppliers/Commands/SetSupplierActive/SetSupplierActiveCommand.cs new file mode 100644 index 0000000..8241e99 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Suppliers/Commands/SetSupplierActive/SetSupplierActiveCommand.cs @@ -0,0 +1,40 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Suppliers.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Suppliers.Commands.SetSupplierActive; + +public sealed record SetSupplierActiveCommand(Guid SupplierId, bool IsActive) : IRequest>; + +internal sealed class SetSupplierActiveCommandHandler(IAppDbContext db) + : IRequestHandler> +{ + public async Task> Handle(SetSupplierActiveCommand request, CancellationToken cancellationToken) + { + var supplier = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == request.SupplierId, cancellationToken); + + if (supplier is null) + { + return Result.Failure(SupplierErrors.NotFound(request.SupplierId)); + } + + if (request.IsActive) + { + supplier.Activate(); + } + else + { + supplier.Deactivate(); + } + + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(supplier.ToDto()); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Suppliers/Commands/SetSupplierPrice/SetSupplierPriceCommand.cs b/backend/src/RestaurantPOS.Application/Suppliers/Commands/SetSupplierPrice/SetSupplierPriceCommand.cs new file mode 100644 index 0000000..a5d3c9e --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Suppliers/Commands/SetSupplierPrice/SetSupplierPriceCommand.cs @@ -0,0 +1,74 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Suppliers.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Suppliers.Commands.SetSupplierPrice; + +/// +/// Records the current price a supplier charges for a raw material. Every call appends a +/// history entry — prices are never overwritten in place — which is what lets a supplier's +/// price for an ingredient be tracked over time. +/// +public sealed record SetSupplierPriceCommand(Guid SupplierId, Guid RawMaterialId, decimal Price) + : IRequest>; + +public sealed class SetSupplierPriceCommandValidator : AbstractValidator +{ + public SetSupplierPriceCommandValidator() + { + RuleFor(x => x.SupplierId).NotEmpty(); + RuleFor(x => x.RawMaterialId).NotEmpty(); + RuleFor(x => x.Price).GreaterThanOrEqualTo(0).WithMessage("Price cannot be negative."); + } +} + +internal sealed class SetSupplierPriceCommandHandler(IAppDbContext db, ICurrentUser currentUser, IDateTimeProvider clock) + : IRequestHandler> +{ + public async Task> Handle(SetSupplierPriceCommand request, CancellationToken cancellationToken) + { + var supplier = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == request.SupplierId, cancellationToken); + if (supplier is null) + { + return Result.Failure(SupplierErrors.NotFound(request.SupplierId)); + } + + var rawMaterial = await db.RawMaterials.FirstOrDefaultAsync(r => r.Id == request.RawMaterialId, cancellationToken); + if (rawMaterial is null) + { + return Result.Failure(InventoryErrors.RawMaterialNotFound(request.RawMaterialId)); + } + + var now = clock.UtcNow; + + var current = await db.SupplierPrices.FirstOrDefaultAsync( + p => p.SupplierId == request.SupplierId && p.RawMaterialId == request.RawMaterialId, cancellationToken); + + if (current is null) + { + current = new SupplierPrice(request.SupplierId, request.RawMaterialId, request.Price, now); + db.SupplierPrices.Add(current); + } + else + { + current.UpdatePrice(request.Price, now); + } + + db.SupplierPriceHistoryEntries.Add(SupplierPriceHistoryEntry.Record( + request.SupplierId, request.RawMaterialId, request.Price, currentUser.UserId!.Value, now)); + + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(new SupplierPriceDto( + supplier.Id, supplier.Name, rawMaterial.Id, rawMaterial.Name, rawMaterial.UnitOfMeasurement, + current.Price, current.UpdatedAtUtc)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Suppliers/Commands/SubmitPurchaseOrder/SubmitPurchaseOrderCommand.cs b/backend/src/RestaurantPOS.Application/Suppliers/Commands/SubmitPurchaseOrder/SubmitPurchaseOrderCommand.cs new file mode 100644 index 0000000..378bb4c --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Suppliers/Commands/SubmitPurchaseOrder/SubmitPurchaseOrderCommand.cs @@ -0,0 +1,41 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Suppliers.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Suppliers.Commands.SubmitPurchaseOrder; + +/// Sends a draft purchase order to its supplier. No further line edits after this point. +public sealed record SubmitPurchaseOrderCommand(Guid PurchaseOrderId) : IRequest>; + +internal sealed class SubmitPurchaseOrderCommandHandler(IAppDbContext db, IDateTimeProvider clock) + : IRequestHandler> +{ + public async Task> Handle( + SubmitPurchaseOrderCommand request, CancellationToken cancellationToken) + { + var order = await db.PurchaseOrders.Include(o => o.Lines) + .FirstOrDefaultAsync(o => o.Id == request.PurchaseOrderId, cancellationToken); + + if (order is null) + { + return Result.Failure(SupplierErrors.PurchaseOrderNotFound(request.PurchaseOrderId)); + } + + if (order.Status != PurchaseOrderStatus.Draft) + { + return Result.Failure(SupplierErrors.NotSubmittable); + } + + order.Submit(clock.UtcNow); + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(await order.ToDtoAsync(db, cancellationToken)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Suppliers/Commands/UpdatePurchaseOrder/UpdatePurchaseOrderCommand.cs b/backend/src/RestaurantPOS.Application/Suppliers/Commands/UpdatePurchaseOrder/UpdatePurchaseOrderCommand.cs new file mode 100644 index 0000000..821b12f --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Suppliers/Commands/UpdatePurchaseOrder/UpdatePurchaseOrderCommand.cs @@ -0,0 +1,83 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Suppliers.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Suppliers.Commands.UpdatePurchaseOrder; + +public sealed record UpdatePurchaseOrderLineInput(Guid RawMaterialId, decimal Quantity, decimal UnitPrice); + +/// Replaces a draft purchase order's lines and details. Only possible while still a Draft. +public sealed record UpdatePurchaseOrderCommand( + Guid PurchaseOrderId, + IReadOnlyCollection Lines, + DateTime? ExpectedDeliveryDate, + string? Notes) : IRequest>; + +public sealed class UpdatePurchaseOrderCommandValidator : AbstractValidator +{ + public UpdatePurchaseOrderCommandValidator() + { + RuleFor(x => x.PurchaseOrderId).NotEmpty(); + + RuleFor(x => x.Lines).NotEmpty().WithMessage("At least one line is required."); + + RuleForEach(x => x.Lines).ChildRules(line => + { + line.RuleFor(l => l.Quantity).GreaterThan(0).WithMessage("Quantity must be greater than zero."); + line.RuleFor(l => l.UnitPrice).GreaterThanOrEqualTo(0).WithMessage("Unit price cannot be negative."); + }); + + RuleFor(x => x.Lines) + .Must(lines => lines.Select(l => l.RawMaterialId).Distinct().Count() == lines.Count) + .WithMessage("A raw material cannot appear more than once on the same purchase order.") + .When(x => x.Lines.Count > 0); + + RuleFor(x => x.Notes).MaximumLength(PurchaseOrder.NotesMaxLength); + } +} + +internal sealed class UpdatePurchaseOrderCommandHandler(IAppDbContext db) + : IRequestHandler> +{ + public async Task> Handle( + UpdatePurchaseOrderCommand request, CancellationToken cancellationToken) + { + var order = await db.PurchaseOrders + .Include(o => o.Lines) + .FirstOrDefaultAsync(o => o.Id == request.PurchaseOrderId, cancellationToken); + + if (order is null) + { + return Result.Failure(SupplierErrors.PurchaseOrderNotFound(request.PurchaseOrderId)); + } + + if (order.Status != PurchaseOrderStatus.Draft) + { + return Result.Failure(SupplierErrors.NotDraft); + } + + var rawMaterialIds = request.Lines.Select(l => l.RawMaterialId).ToList(); + var rawMaterialCount = await db.RawMaterials.CountAsync(r => rawMaterialIds.Contains(r.Id), cancellationToken); + if (rawMaterialCount != rawMaterialIds.Distinct().Count()) + { + return Result.Failure(InventoryErrors.RawMaterialNotFound(rawMaterialIds[0])); + } + + order.ReplaceLines(request.Lines.Select(l => (l.RawMaterialId, l.Quantity, l.UnitPrice))); + order.UpdateDetails(request.ExpectedDeliveryDate, request.Notes); + + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(await order.ToDtoAsync(db, cancellationToken)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Suppliers/Commands/UpdateSupplier/UpdateSupplierCommand.cs b/backend/src/RestaurantPOS.Application/Suppliers/Commands/UpdateSupplier/UpdateSupplierCommand.cs new file mode 100644 index 0000000..c205009 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Suppliers/Commands/UpdateSupplier/UpdateSupplierCommand.cs @@ -0,0 +1,97 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Suppliers.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Suppliers.Commands.UpdateSupplier; + +public sealed record UpdateSupplierCommand( + Guid SupplierId, + string Name, + string? ContactName, + string? Phone, + string? Email, + string? Address, + int PaymentTermsDays, + decimal? CreditLimit, + int? LeadTimeDays) : IRequest>; + +public sealed class UpdateSupplierCommandValidator : AbstractValidator +{ + public UpdateSupplierCommandValidator() + { + RuleFor(x => x.SupplierId).NotEmpty(); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Name is required.") + .MaximumLength(Supplier.NameMaxLength); + + RuleFor(x => x.ContactName).MaximumLength(Supplier.ContactNameMaxLength); + RuleFor(x => x.Phone).MaximumLength(Supplier.PhoneMaxLength); + + RuleFor(x => x.Email) + .EmailAddress().WithMessage("Enter a valid email address.") + .MaximumLength(Supplier.EmailMaxLength) + .When(x => !string.IsNullOrWhiteSpace(x.Email)); + + RuleFor(x => x.Address).MaximumLength(Supplier.AddressMaxLength); + + RuleFor(x => x.PaymentTermsDays).GreaterThanOrEqualTo(0) + .WithMessage("Payment terms cannot be a negative number of days."); + + RuleFor(x => x.CreditLimit).GreaterThanOrEqualTo(0) + .WithMessage("Credit limit cannot be negative.") + .When(x => x.CreditLimit is not null); + + RuleFor(x => x.LeadTimeDays).GreaterThanOrEqualTo(0) + .WithMessage("Lead time cannot be a negative number of days.") + .When(x => x.LeadTimeDays is not null); + } +} + +internal sealed class UpdateSupplierCommandHandler(IAppDbContext db) + : IRequestHandler> +{ + public async Task> Handle(UpdateSupplierCommand request, CancellationToken cancellationToken) + { + var supplier = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == request.SupplierId, cancellationToken); + + if (supplier is null) + { + return Result.Failure(SupplierErrors.NotFound(request.SupplierId)); + } + + var name = request.Name.Trim(); + var normalised = name.ToLowerInvariant(); + + var nameTaken = await db.Suppliers.AnyAsync( + s => s.Id != request.SupplierId && s.Name.ToLower() == normalised, cancellationToken); + + if (nameTaken) + { + return Result.Failure(SupplierErrors.NameTaken); + } + + supplier.UpdateDetails( + name, + request.ContactName, + request.Phone, + request.Email, + request.Address, + request.PaymentTermsDays, + request.CreditLimit, + request.LeadTimeDays); + + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(supplier.ToDto()); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Suppliers/Dtos/PurchaseOrderDto.cs b/backend/src/RestaurantPOS.Application/Suppliers/Dtos/PurchaseOrderDto.cs new file mode 100644 index 0000000..bac754e --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Suppliers/Dtos/PurchaseOrderDto.cs @@ -0,0 +1,40 @@ +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Application.Suppliers.Dtos; + +public sealed record PurchaseOrderLineDto( + Guid RawMaterialId, + string RawMaterialName, + UnitOfMeasurement UnitOfMeasurement, + decimal Quantity, + decimal UnitPrice, + decimal LineTotal); + +/// A purchase order's header for a list screen, without its lines. +public sealed record PurchaseOrderSummaryDto( + Guid Id, + Guid SupplierId, + string SupplierName, + PurchaseOrderStatus Status, + DateTime CreatedAtUtc, + DateTime? ExpectedDeliveryDate, + decimal TotalAmount, + decimal AmountPaid, + decimal Balance, + int LineCount); + +public sealed record PurchaseOrderDto( + Guid Id, + Guid SupplierId, + string SupplierName, + PurchaseOrderStatus Status, + Guid CreatedByUserId, + string CreatedByName, + DateTime CreatedAtUtc, + DateTime? ExpectedDeliveryDate, + DateTime? SubmittedAtUtc, + string? Notes, + decimal TotalAmount, + decimal AmountPaid, + decimal Balance, + IReadOnlyCollection Lines); \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Suppliers/Dtos/SupplierDto.cs b/backend/src/RestaurantPOS.Application/Suppliers/Dtos/SupplierDto.cs new file mode 100644 index 0000000..acef700 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Suppliers/Dtos/SupplierDto.cs @@ -0,0 +1,14 @@ +namespace RestaurantPOS.Application.Suppliers.Dtos; + +public sealed record SupplierDto( + Guid Id, + string Name, + string? ContactName, + string? Phone, + string? Email, + string? Address, + int PaymentTermsDays, + decimal? CreditLimit, + int? LeadTimeDays, + bool IsActive, + DateTime CreatedAtUtc); \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Suppliers/Dtos/SupplierPaymentDto.cs b/backend/src/RestaurantPOS.Application/Suppliers/Dtos/SupplierPaymentDto.cs new file mode 100644 index 0000000..d0672a8 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Suppliers/Dtos/SupplierPaymentDto.cs @@ -0,0 +1,14 @@ +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Application.Suppliers.Dtos; + +public sealed record SupplierPaymentDto( + Guid Id, + Guid PurchaseOrderId, + decimal Amount, + DateTime PaymentDateUtc, + PaymentMethod Method, + string? InvoiceReference, + Guid RecordedByUserId, + string RecordedByName, + string? Notes); \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Suppliers/Dtos/SupplierPerformanceDto.cs b/backend/src/RestaurantPOS.Application/Suppliers/Dtos/SupplierPerformanceDto.cs new file mode 100644 index 0000000..ebc2eb6 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Suppliers/Dtos/SupplierPerformanceDto.cs @@ -0,0 +1,20 @@ +namespace RestaurantPOS.Application.Suppliers.Dtos; + +/// +/// Delivery and quality statistics for one supplier, derived entirely from their purchase +/// orders and the Goods Received Notes recorded against them — nothing here is separately +/// logged. +/// +public sealed record SupplierPerformanceDto( + Guid SupplierId, + string SupplierName, + int TotalOrders, + int DeliveredOrders, + int OnTimeDeliveries, + /// Null when no delivered order had an expected delivery date to compare against. + double? OnTimeDeliveryRate, + /// Average days from submission to the first GRN recorded against the order. Null with no data. + double? AverageDeliveryDays, + /// Average of every quality rating recorded on a GRN for this supplier. Null if none were given. + double? AverageQualityRating, + int IssueCount); \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Suppliers/Dtos/SupplierPriceDto.cs b/backend/src/RestaurantPOS.Application/Suppliers/Dtos/SupplierPriceDto.cs new file mode 100644 index 0000000..92c25b7 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Suppliers/Dtos/SupplierPriceDto.cs @@ -0,0 +1,15 @@ +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Application.Suppliers.Dtos; + +public sealed record SupplierPriceDto( + Guid SupplierId, + string SupplierName, + Guid RawMaterialId, + string RawMaterialName, + UnitOfMeasurement UnitOfMeasurement, + decimal Price, + DateTime UpdatedAtUtc); + +public sealed record SupplierPriceHistoryEntryDto( + decimal Price, Guid RecordedByUserId, string RecordedByName, DateTime RecordedAtUtc); \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Suppliers/Queries/GetPurchaseOrderById/GetPurchaseOrderByIdQuery.cs b/backend/src/RestaurantPOS.Application/Suppliers/Queries/GetPurchaseOrderById/GetPurchaseOrderByIdQuery.cs new file mode 100644 index 0000000..412e5ee --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Suppliers/Queries/GetPurchaseOrderById/GetPurchaseOrderByIdQuery.cs @@ -0,0 +1,31 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Suppliers.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Suppliers.Queries.GetPurchaseOrderById; + +public sealed record GetPurchaseOrderByIdQuery(Guid PurchaseOrderId) : IRequest>; + +internal sealed class GetPurchaseOrderByIdQueryHandler(IAppDbContext db) + : IRequestHandler> +{ + public async Task> Handle( + GetPurchaseOrderByIdQuery request, CancellationToken cancellationToken) + { + var order = await db.PurchaseOrders.AsNoTracking().Include(o => o.Lines) + .FirstOrDefaultAsync(o => o.Id == request.PurchaseOrderId, cancellationToken); + + if (order is null) + { + return Result.Failure(SupplierErrors.PurchaseOrderNotFound(request.PurchaseOrderId)); + } + + return Result.Success(await order.ToDtoAsync(db, cancellationToken)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Suppliers/Queries/GetPurchaseOrders/GetPurchaseOrdersQuery.cs b/backend/src/RestaurantPOS.Application/Suppliers/Queries/GetPurchaseOrders/GetPurchaseOrdersQuery.cs new file mode 100644 index 0000000..d5132fe --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Suppliers/Queries/GetPurchaseOrders/GetPurchaseOrdersQuery.cs @@ -0,0 +1,72 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Suppliers.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Application.Suppliers.Queries.GetPurchaseOrders; + +public sealed record GetPurchaseOrdersQuery(Guid? SupplierId, PurchaseOrderStatus? Status) + : IRequest>>; + +internal sealed class GetPurchaseOrdersQueryHandler(IAppDbContext db) + : IRequestHandler>> +{ + private const int MaxRows = 300; + + public async Task>> Handle( + GetPurchaseOrdersQuery request, CancellationToken cancellationToken) + { + var query = db.PurchaseOrders.AsNoTracking().Include(o => o.Lines).AsQueryable(); + + if (request.SupplierId is not null) + { + query = query.Where(o => o.SupplierId == request.SupplierId.Value); + } + + if (request.Status is not null) + { + query = query.Where(o => o.Status == request.Status.Value); + } + + var orders = await query + .OrderByDescending(o => o.CreatedAtUtc) + .Take(MaxRows) + .ToListAsync(cancellationToken); + + var supplierNames = await db.Suppliers.AsNoTracking() + .ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken); + + var orderIds = orders.Select(o => o.Id).ToList(); + var paidByOrder = await db.SupplierPayments.AsNoTracking() + .Where(p => orderIds.Contains(p.PurchaseOrderId)) + .GroupBy(p => p.PurchaseOrderId) + .Select(g => new { PurchaseOrderId = g.Key, Paid = g.Sum(p => p.Amount) }) + .ToDictionaryAsync(g => g.PurchaseOrderId, g => g.Paid, cancellationToken); + + IReadOnlyCollection result = + [ + .. orders.Select(o => + { + var paid = paidByOrder.GetValueOrDefault(o.Id, 0m); + + return new PurchaseOrderSummaryDto( + o.Id, + o.SupplierId, + supplierNames.GetValueOrDefault(o.SupplierId, "(unknown)"), + o.Status, + o.CreatedAtUtc, + o.ExpectedDeliveryDate, + o.TotalAmount, + paid, + o.TotalAmount - paid, + o.Lines.Count); + }), + ]; + + return Result.Success(result); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Suppliers/Queries/GetSupplierPayments/GetSupplierPaymentsQuery.cs b/backend/src/RestaurantPOS.Application/Suppliers/Queries/GetSupplierPayments/GetSupplierPaymentsQuery.cs new file mode 100644 index 0000000..63535fe --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Suppliers/Queries/GetSupplierPayments/GetSupplierPaymentsQuery.cs @@ -0,0 +1,51 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Suppliers.Dtos; +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Application.Suppliers.Queries.GetSupplierPayments; + +/// Payments for one purchase order, or every payment for a supplier across all their orders. +public sealed record GetSupplierPaymentsQuery(Guid? PurchaseOrderId, Guid? SupplierId) + : IRequest>>; + +internal sealed class GetSupplierPaymentsQueryHandler(IAppDbContext db) + : IRequestHandler>> +{ + public async Task>> Handle( + GetSupplierPaymentsQuery request, CancellationToken cancellationToken) + { + var query = db.SupplierPayments.AsNoTracking().AsQueryable(); + + if (request.PurchaseOrderId is not null) + { + query = query.Where(p => p.PurchaseOrderId == request.PurchaseOrderId.Value); + } + else if (request.SupplierId is not null) + { + var orderIds = await db.PurchaseOrders.AsNoTracking() + .Where(o => o.SupplierId == request.SupplierId.Value) + .Select(o => o.Id) + .ToListAsync(cancellationToken); + + query = query.Where(p => orderIds.Contains(p.PurchaseOrderId)); + } + + var payments = await query.OrderByDescending(p => p.PaymentDateUtc).ToListAsync(cancellationToken); + + var userNames = await db.Users.AsNoTracking() + .ToDictionaryAsync(u => u.Id, u => u.FullName, cancellationToken); + + IReadOnlyCollection result = + [ + .. payments.Select(p => new SupplierPaymentDto( + p.Id, p.PurchaseOrderId, p.Amount, p.PaymentDateUtc, p.Method, p.InvoiceReference, + p.RecordedByUserId, userNames.GetValueOrDefault(p.RecordedByUserId, "(unknown)"), p.Notes)), + ]; + + return Result.Success(result); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Suppliers/Queries/GetSupplierPerformance/GetSupplierPerformanceQuery.cs b/backend/src/RestaurantPOS.Application/Suppliers/Queries/GetSupplierPerformance/GetSupplierPerformanceQuery.cs new file mode 100644 index 0000000..414e66d --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Suppliers/Queries/GetSupplierPerformance/GetSupplierPerformanceQuery.cs @@ -0,0 +1,81 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Suppliers.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Suppliers.Queries.GetSupplierPerformance; + +/// +/// Delivery and quality statistics for a supplier. Everything here is derived from purchase +/// orders and the GRNs recorded against them — on-time delivery compares a GRN's received date +/// to its order's expected date, delivery time compares it to when the order was submitted, and +/// quality/issues come straight from what was recorded on each GRN. +/// +public sealed record GetSupplierPerformanceQuery(Guid SupplierId) : IRequest>; + +internal sealed class GetSupplierPerformanceQueryHandler(IAppDbContext db) + : IRequestHandler> +{ + public async Task> Handle( + GetSupplierPerformanceQuery request, CancellationToken cancellationToken) + { + var supplier = await db.Suppliers.AsNoTracking() + .FirstOrDefaultAsync(s => s.Id == request.SupplierId, cancellationToken); + + if (supplier is null) + { + return Result.Failure(SupplierErrors.NotFound(request.SupplierId)); + } + + var orders = await db.PurchaseOrders.AsNoTracking() + .Where(o => o.SupplierId == request.SupplierId) + .Select(o => new { o.Id, o.Status, o.ExpectedDeliveryDate, o.SubmittedAtUtc }) + .ToListAsync(cancellationToken); + + var grns = await db.GoodsReceivedNotes.AsNoTracking() + .Where(g => g.SupplierId == request.SupplierId) + .Select(g => new { g.PurchaseOrderId, g.ReceivedAtUtc, g.QualityRating, g.HasIssue }) + .ToListAsync(cancellationToken); + + var deliveredOrders = orders.Where(o => o.Status == PurchaseOrderStatus.Delivered).ToList(); + + // A PO can be received across more than one GRN; the earliest is what "when did this + // order actually arrive" means for both the on-time and delivery-time calculations. + var earliestReceiptByOrder = grns + .Where(g => g.PurchaseOrderId is not null) + .GroupBy(g => g.PurchaseOrderId!.Value) + .ToDictionary(g => g.Key, g => g.Min(x => x.ReceivedAtUtc)); + + var onTimeEligible = deliveredOrders + .Where(o => o.ExpectedDeliveryDate is not null && earliestReceiptByOrder.ContainsKey(o.Id)) + .ToList(); + + var onTimeCount = onTimeEligible + .Count(o => earliestReceiptByOrder[o.Id].Date <= o.ExpectedDeliveryDate!.Value.Date); + + var deliveryDurations = deliveredOrders + .Where(o => o.SubmittedAtUtc is not null && earliestReceiptByOrder.ContainsKey(o.Id)) + .Select(o => (earliestReceiptByOrder[o.Id] - o.SubmittedAtUtc!.Value).TotalDays) + .ToList(); + + var ratings = grns.Where(g => g.QualityRating is not null).Select(g => (double)g.QualityRating!.Value).ToList(); + + var dto = new SupplierPerformanceDto( + supplier.Id, + supplier.Name, + orders.Count, + deliveredOrders.Count, + onTimeCount, + onTimeEligible.Count == 0 ? null : onTimeCount * 100.0 / onTimeEligible.Count, + deliveryDurations.Count == 0 ? null : deliveryDurations.Average(), + ratings.Count == 0 ? null : ratings.Average(), + grns.Count(g => g.HasIssue)); + + return Result.Success(dto); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Suppliers/Queries/GetSupplierPriceHistory/GetSupplierPriceHistoryQuery.cs b/backend/src/RestaurantPOS.Application/Suppliers/Queries/GetSupplierPriceHistory/GetSupplierPriceHistoryQuery.cs new file mode 100644 index 0000000..2df82ac --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Suppliers/Queries/GetSupplierPriceHistory/GetSupplierPriceHistoryQuery.cs @@ -0,0 +1,37 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Suppliers.Dtos; +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Application.Suppliers.Queries.GetSupplierPriceHistory; + +/// Every price a supplier has been recorded as charging for one raw material, newest first. +public sealed record GetSupplierPriceHistoryQuery(Guid SupplierId, Guid RawMaterialId) + : IRequest>>; + +internal sealed class GetSupplierPriceHistoryQueryHandler(IAppDbContext db) + : IRequestHandler>> +{ + public async Task>> Handle( + GetSupplierPriceHistoryQuery request, CancellationToken cancellationToken) + { + var entries = await db.SupplierPriceHistoryEntries.AsNoTracking() + .Where(e => e.SupplierId == request.SupplierId && e.RawMaterialId == request.RawMaterialId) + .OrderByDescending(e => e.RecordedAtUtc) + .ToListAsync(cancellationToken); + + var userNames = await db.Users.AsNoTracking() + .ToDictionaryAsync(u => u.Id, u => u.FullName, cancellationToken); + + IReadOnlyCollection result = + [ + .. entries.Select(e => new SupplierPriceHistoryEntryDto( + e.Price, e.RecordedByUserId, userNames.GetValueOrDefault(e.RecordedByUserId, "(unknown)"), e.RecordedAtUtc)), + ]; + + return Result.Success(result); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Suppliers/Queries/GetSupplierPrices/GetSupplierPricesQuery.cs b/backend/src/RestaurantPOS.Application/Suppliers/Queries/GetSupplierPrices/GetSupplierPricesQuery.cs new file mode 100644 index 0000000..f09c185 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Suppliers/Queries/GetSupplierPrices/GetSupplierPricesQuery.cs @@ -0,0 +1,61 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Suppliers.Dtos; +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Application.Suppliers.Queries.GetSupplierPrices; + +/// +/// Current supplier prices, filterable by supplier (a supplier's whole price list) or by raw +/// material (every supplier's price for one ingredient, for side-by-side comparison) or both. +/// +public sealed record GetSupplierPricesQuery(Guid? SupplierId, Guid? RawMaterialId) + : IRequest>>; + +internal sealed class GetSupplierPricesQueryHandler(IAppDbContext db) + : IRequestHandler>> +{ + public async Task>> Handle( + GetSupplierPricesQuery request, CancellationToken cancellationToken) + { + var query = db.SupplierPrices.AsNoTracking().AsQueryable(); + + if (request.SupplierId is not null) + { + query = query.Where(p => p.SupplierId == request.SupplierId.Value); + } + + if (request.RawMaterialId is not null) + { + query = query.Where(p => p.RawMaterialId == request.RawMaterialId.Value); + } + + var prices = await query.ToListAsync(cancellationToken); + + var supplierNames = await db.Suppliers.AsNoTracking() + .ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken); + + var rawMaterials = await db.RawMaterials.AsNoTracking() + .ToDictionaryAsync(r => r.Id, cancellationToken); + + IReadOnlyCollection result = + [ + .. prices + .Select(p => new SupplierPriceDto( + p.SupplierId, + supplierNames.GetValueOrDefault(p.SupplierId, "(unknown)"), + p.RawMaterialId, + rawMaterials.TryGetValue(p.RawMaterialId, out var material) ? material.Name : "(unknown)", + rawMaterials.TryGetValue(p.RawMaterialId, out var m2) ? m2.UnitOfMeasurement : default, + p.Price, + p.UpdatedAtUtc)) + .OrderBy(p => p.RawMaterialName) + .ThenBy(p => p.Price), + ]; + + return Result.Success(result); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Suppliers/Queries/GetSuppliers/GetSuppliersQuery.cs b/backend/src/RestaurantPOS.Application/Suppliers/Queries/GetSuppliers/GetSuppliersQuery.cs new file mode 100644 index 0000000..536a153 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Suppliers/Queries/GetSuppliers/GetSuppliersQuery.cs @@ -0,0 +1,43 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Suppliers.Dtos; +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Application.Suppliers.Queries.GetSuppliers; + +public sealed record GetSuppliersQuery(string? Search, bool? IsActive) + : IRequest>>; + +internal sealed class GetSuppliersQueryHandler(IAppDbContext db) + : IRequestHandler>> +{ + public async Task>> Handle( + GetSuppliersQuery request, CancellationToken cancellationToken) + { + var query = db.Suppliers.AsNoTracking().AsQueryable(); + + if (!string.IsNullOrWhiteSpace(request.Search)) + { + var term = request.Search.Trim().ToLowerInvariant(); + query = query.Where(s => s.Name.ToLower().Contains(term)); + } + + if (request.IsActive is not null) + { + query = query.Where(s => s.IsActive == request.IsActive.Value); + } + + var suppliers = await query + .OrderByDescending(s => s.IsActive) + .ThenBy(s => s.Name) + .ToListAsync(cancellationToken); + + IReadOnlyCollection result = [.. suppliers.Select(s => s.ToDto())]; + + return Result.Success(result); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Entities/AuditLogEntry.cs b/backend/src/RestaurantPOS.Domain/Entities/AuditLogEntry.cs new file mode 100644 index 0000000..0763aee --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Entities/AuditLogEntry.cs @@ -0,0 +1,90 @@ +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Domain.Entities; + +/// +/// A record of one change to one entity, for screens that need to show "recent changes" or +/// prove what a deleted record used to contain (BR-REC-007). Deliberately generic — keyed by +/// and — so any module can write to it, though +/// only Recipe Management does so today (REC-010). +/// +public sealed class AuditLogEntry : BaseEntity +{ + public const int EntityTypeMaxLength = 100; + public const int ActionMaxLength = 50; + public const int SummaryMaxLength = 500; + + // EF Core materialisation. + private AuditLogEntry() + { + } + + private AuditLogEntry( + string entityType, + Guid entityId, + string action, + Guid performedByUserId, + string performedByName, + DateTime occurredAtUtc, + string summary, + string? detailsJson) + { + EntityType = entityType; + EntityId = entityId; + Action = action; + PerformedByUserId = performedByUserId; + PerformedByName = performedByName; + OccurredAtUtc = occurredAtUtc; + Summary = summary; + DetailsJson = detailsJson; + } + + /// The kind of entity changed, e.g. "Recipe". + public string EntityType { get; private set; } = string.Empty; + + public Guid EntityId { get; private set; } + + /// E.g. "Created", "Updated", "Deleted", "Enabled", "Disabled". + public string Action { get; private set; } = string.Empty; + + public Guid PerformedByUserId { get; private set; } + + /// Denormalised so history displays without joining back to Users. + public string PerformedByName { get; private set; } = string.Empty; + + public DateTime OccurredAtUtc { get; private set; } + + /// One-line human-readable description of what changed. + public string Summary { get; private set; } = string.Empty; + + /// Optional JSON snapshot of the entity's state, used to recover a deleted record's content. + public string? DetailsJson { get; private set; } + + public static AuditLogEntry Record( + string entityType, + Guid entityId, + string action, + Guid performedByUserId, + string performedByName, + DateTime occurredAtUtc, + string summary, + string? detailsJson = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(entityType); + ArgumentException.ThrowIfNullOrWhiteSpace(action); + ArgumentException.ThrowIfNullOrWhiteSpace(summary); + + return new AuditLogEntry( + Truncate(entityType, EntityTypeMaxLength), + entityId, + Truncate(action, ActionMaxLength), + performedByUserId, + performedByName, + occurredAtUtc, + Truncate(summary, SummaryMaxLength), + detailsJson); + } + + private static string Truncate(string value, int maxLength) => + value.Length > maxLength ? value[..maxLength] : value; +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Entities/GoodsReceivedNote.cs b/backend/src/RestaurantPOS.Domain/Entities/GoodsReceivedNote.cs new file mode 100644 index 0000000..8d08a7b --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Entities/GoodsReceivedNote.cs @@ -0,0 +1,92 @@ +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Domain.Entities; + +/// +/// A record of stock received from a supplier into the Main Store. Recording one immediately +/// increases Main Store stock — there is no separate draft/confirm step, since a GRN is only +/// ever entered once the goods have actually arrived and been counted. +/// +/// +/// Its lines are not stored here; they are the rows sharing this +/// note's as their . +/// +public sealed class GoodsReceivedNote : BaseEntity +{ + public const int NotesMaxLength = 500; + public const int MinQualityRating = 1; + public const int MaxQualityRating = 5; + + // EF Core materialisation. + private GoodsReceivedNote() + { + } + + private GoodsReceivedNote( + Guid supplierId, + Guid receivedByUserId, + DateTime receivedAtUtc, + string? notes, + Guid? purchaseOrderId, + int? qualityRating, + bool hasIssue) + { + SupplierId = supplierId; + ReceivedByUserId = receivedByUserId; + ReceivedAtUtc = receivedAtUtc; + Notes = NormaliseNotes(notes); + PurchaseOrderId = purchaseOrderId; + QualityRating = ValidateRating(qualityRating); + HasIssue = hasIssue; + } + + public Guid SupplierId { get; private set; } + + public Guid ReceivedByUserId { get; private set; } + + public DateTime ReceivedAtUtc { get; private set; } + + public string? Notes { get; private set; } + + /// + /// The Purchase Order this delivery fulfils, if any — a GRN can also stand alone for a + /// delivery that never had a formal PO raised against it. + /// + public Guid? PurchaseOrderId { get; private set; } + + /// 1 (worst) to 5 (best), optional. + public int? QualityRating { get; private set; } + + /// Flags this delivery for the supplier performance view, e.g. a complaint was raised. + public bool HasIssue { get; private set; } + + public static GoodsReceivedNote Create( + Guid supplierId, + Guid receivedByUserId, + DateTime receivedAtUtc, + string? notes = null, + Guid? purchaseOrderId = null, + int? qualityRating = null, + bool hasIssue = false) => + new(supplierId, receivedByUserId, receivedAtUtc, notes, purchaseOrderId, qualityRating, hasIssue); + + private static string? NormaliseNotes(string? notes) + { + if (string.IsNullOrWhiteSpace(notes)) + { + return null; + } + + var trimmed = notes.Trim(); + + return trimmed.Length > NotesMaxLength + ? throw new ArgumentException($"Notes cannot exceed {NotesMaxLength} characters.", nameof(notes)) + : trimmed; + } + + private static int? ValidateRating(int? rating) => + rating is null or >= MinQualityRating and <= MaxQualityRating + ? rating + : throw new ArgumentOutOfRangeException( + nameof(rating), rating, $"Quality rating must be between {MinQualityRating} and {MaxQualityRating}."); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Entities/MenuItem.cs b/backend/src/RestaurantPOS.Domain/Entities/MenuItem.cs new file mode 100644 index 0000000..7cb39c3 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Entities/MenuItem.cs @@ -0,0 +1,77 @@ +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Domain.Entities; + +/// +/// A sellable dish or drink. Recipe Management attaches an optional to +/// this, which is how a sale eventually deducts kitchen stock — but not every menu item needs +/// one (a bottled drink with no preparation, for example). +/// +public sealed class MenuItem : BaseEntity +{ + public const int NameMaxLength = 150; + public const int CategoryMaxLength = 80; + + // EF Core materialisation. + private MenuItem() + { + } + + private MenuItem(string name, string category, decimal price) + { + Name = NormaliseName(name); + Category = NormaliseCategory(category); + Price = ValidatePrice(price); + IsActive = true; + } + + public string Name { get; private set; } = string.Empty; + + /// Free-text grouping for the menu, e.g. "Rice & Curry", "Beverages". + public string Category { get; private set; } = string.Empty; + + public decimal Price { get; private set; } + + public bool IsActive { get; private set; } + + public static MenuItem Create(string name, string category, decimal price) => + new(name, category, price); + + public void UpdateDetails(string name, string category, decimal price) + { + Name = NormaliseName(name); + Category = NormaliseCategory(category); + Price = ValidatePrice(price); + } + + public void Activate() => IsActive = true; + + public void Deactivate() => IsActive = false; + + private static string NormaliseName(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + var trimmed = name.Trim(); + + return trimmed.Length > NameMaxLength + ? throw new ArgumentException($"Name cannot exceed {NameMaxLength} characters.", nameof(name)) + : trimmed; + } + + private static string NormaliseCategory(string category) + { + ArgumentException.ThrowIfNullOrWhiteSpace(category); + + var trimmed = category.Trim(); + + return trimmed.Length > CategoryMaxLength + ? throw new ArgumentException($"Category cannot exceed {CategoryMaxLength} characters.", nameof(category)) + : trimmed; + } + + private static decimal ValidatePrice(decimal price) => + price >= 0 + ? price + : throw new ArgumentOutOfRangeException(nameof(price), price, "Price cannot be negative."); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Entities/PurchaseOrder.cs b/backend/src/RestaurantPOS.Domain/Entities/PurchaseOrder.cs new file mode 100644 index 0000000..35e459b --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Entities/PurchaseOrder.cs @@ -0,0 +1,169 @@ +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Domain.Entities; + +/// +/// An order placed with a supplier for one or more raw materials. Lines are editable only while +/// the order is still a — once submitted, it represents +/// a real commitment sent to the supplier and its lines are frozen. +/// +public sealed class PurchaseOrder : BaseEntity +{ + public const int NotesMaxLength = 500; + + private readonly List _lines = []; + + // EF Core materialisation. + private PurchaseOrder() + { + } + + private PurchaseOrder( + Guid supplierId, + Guid createdByUserId, + IEnumerable<(Guid RawMaterialId, decimal Quantity, decimal UnitPrice)> lines, + DateTime? expectedDeliveryDate, + string? notes) + { + SupplierId = supplierId; + CreatedByUserId = createdByUserId; + Status = PurchaseOrderStatus.Draft; + ExpectedDeliveryDate = expectedDeliveryDate; + Notes = NormaliseNotes(notes); + SetLines(lines); + } + + public Guid SupplierId { get; private set; } + + public Guid CreatedByUserId { get; private set; } + + public PurchaseOrderStatus Status { get; private set; } + + public DateTime? ExpectedDeliveryDate { get; private set; } + + /// When the order left Draft. Null until then; used to measure delivery time. + public DateTime? SubmittedAtUtc { get; private set; } + + public string? Notes { get; private set; } + + public IReadOnlyCollection Lines => _lines.AsReadOnly(); + + public decimal TotalAmount => _lines.Sum(l => l.LineTotal); + + public static PurchaseOrder Create( + Guid supplierId, + Guid createdByUserId, + IEnumerable<(Guid RawMaterialId, decimal Quantity, decimal UnitPrice)> lines, + DateTime? expectedDeliveryDate = null, + string? notes = null) => + new(supplierId, createdByUserId, lines, expectedDeliveryDate, notes); + + /// Replaces every line wholesale. Only while still a Draft. + public void ReplaceLines(IEnumerable<(Guid RawMaterialId, decimal Quantity, decimal UnitPrice)> lines) + { + EnsureDraft(); + SetLines(lines); + } + + public void UpdateDetails(DateTime? expectedDeliveryDate, string? notes) + { + EnsureDraft(); + ExpectedDeliveryDate = expectedDeliveryDate; + Notes = NormaliseNotes(notes); + } + + /// Sends the order to the supplier. No further line edits after this point. + public void Submit(DateTime nowUtc) + { + if (Status != PurchaseOrderStatus.Draft) + { + throw new InvalidOperationException("Only a draft purchase order can be submitted."); + } + + Status = PurchaseOrderStatus.Submitted; + SubmittedAtUtc = nowUtc; + } + + /// Records that the supplier has agreed to fulfil the order as sent. + public void Confirm() + { + if (Status != PurchaseOrderStatus.Submitted) + { + throw new InvalidOperationException("Only a submitted purchase order can be confirmed."); + } + + Status = PurchaseOrderStatus.Confirmed; + } + + /// + /// Marks the order delivered. Called when a Goods Received Note is recorded against it; + /// idempotent because a single order can be received across more than one GRN. + /// + public void MarkDelivered() + { + if (Status is PurchaseOrderStatus.Delivered) + { + return; + } + + if (Status is PurchaseOrderStatus.Cancelled) + { + throw new InvalidOperationException("A cancelled purchase order cannot be marked delivered."); + } + + Status = PurchaseOrderStatus.Delivered; + } + + public void Cancel() + { + if (Status is PurchaseOrderStatus.Delivered or PurchaseOrderStatus.Cancelled) + { + throw new InvalidOperationException("A delivered or already-cancelled purchase order cannot be cancelled."); + } + + Status = PurchaseOrderStatus.Cancelled; + } + + private void EnsureDraft() + { + if (Status != PurchaseOrderStatus.Draft) + { + throw new InvalidOperationException("Only a draft purchase order can be edited."); + } + } + + private void SetLines(IEnumerable<(Guid RawMaterialId, decimal Quantity, decimal UnitPrice)> lines) + { + ArgumentNullException.ThrowIfNull(lines); + + var materialised = lines.ToList(); + + if (materialised.Count == 0) + { + throw new ArgumentException("A purchase order must contain at least one line.", nameof(lines)); + } + + if (materialised.Select(l => l.RawMaterialId).Distinct().Count() != materialised.Count) + { + throw new ArgumentException("A raw material cannot appear more than once on the same purchase order.", nameof(lines)); + } + + _lines.Clear(); + _lines.AddRange(materialised.Select(l => new PurchaseOrderLine(Id, l.RawMaterialId, l.Quantity, l.UnitPrice))); + } + + private static string? NormaliseNotes(string? notes) + { + if (string.IsNullOrWhiteSpace(notes)) + { + return null; + } + + var trimmed = notes.Trim(); + + return trimmed.Length > NotesMaxLength + ? throw new ArgumentException($"Notes cannot exceed {NotesMaxLength} characters.", nameof(notes)) + : trimmed; + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Entities/PurchaseOrderLine.cs b/backend/src/RestaurantPOS.Domain/Entities/PurchaseOrderLine.cs new file mode 100644 index 0000000..dcb6480 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Entities/PurchaseOrderLine.cs @@ -0,0 +1,32 @@ +namespace RestaurantPOS.Domain.Entities; + +/// One raw material, quantity and unit price ordered on a Purchase Order. +public sealed class PurchaseOrderLine +{ + // EF Core materialisation. + private PurchaseOrderLine() + { + } + + internal PurchaseOrderLine(Guid purchaseOrderId, Guid rawMaterialId, decimal quantity, decimal unitPrice) + { + PurchaseOrderId = purchaseOrderId; + RawMaterialId = rawMaterialId; + Quantity = quantity > 0 + ? quantity + : throw new ArgumentOutOfRangeException(nameof(quantity), quantity, "Quantity must be greater than zero."); + UnitPrice = unitPrice >= 0 + ? unitPrice + : throw new ArgumentOutOfRangeException(nameof(unitPrice), unitPrice, "Unit price cannot be negative."); + } + + public Guid PurchaseOrderId { get; private set; } + + public Guid RawMaterialId { get; private set; } + + public decimal Quantity { get; private set; } + + public decimal UnitPrice { get; private set; } + + public decimal LineTotal => Quantity * UnitPrice; +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Entities/RawMaterial.cs b/backend/src/RestaurantPOS.Domain/Entities/RawMaterial.cs new file mode 100644 index 0000000..749c020 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Entities/RawMaterial.cs @@ -0,0 +1,89 @@ +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Domain.Entities; + +/// +/// An ingredient tracked in stock. Its is fixed for the +/// material's lifetime — recipes and every stock movement referencing it are always expressed +/// in this same unit, so the system never needs to convert between units. +/// +public sealed class RawMaterial : BaseEntity +{ + public const int NameMaxLength = 150; + + // EF Core materialisation. + private RawMaterial() + { + } + + private RawMaterial( + string name, + UnitOfMeasurement unitOfMeasurement, + decimal? mainStoreReorderLevel, + decimal? kitchenParLevel) + { + Name = NormaliseName(name); + UnitOfMeasurement = unitOfMeasurement; + MainStoreReorderLevel = ValidateThreshold(mainStoreReorderLevel, nameof(mainStoreReorderLevel)); + KitchenParLevel = ValidateThreshold(kitchenParLevel, nameof(kitchenParLevel)); + IsActive = true; + } + + public string Name { get; private set; } = string.Empty; + + public UnitOfMeasurement UnitOfMeasurement { get; private set; } + + /// + /// When Main Store stock falls to or below this, the store is flagged low so staff know to + /// call the supplier. Null means no threshold is configured for this material. + /// + public decimal? MainStoreReorderLevel { get; private set; } + + /// + /// When Kitchen stock falls to or below this, the kitchen is flagged low so staff know to + /// request a release from the Main Store. Null means no threshold is configured. + /// + public decimal? KitchenParLevel { get; private set; } + + public bool IsActive { get; private set; } + + public static RawMaterial Create( + string name, + UnitOfMeasurement unitOfMeasurement, + decimal? mainStoreReorderLevel = null, + decimal? kitchenParLevel = null) => + new(name, unitOfMeasurement, mainStoreReorderLevel, kitchenParLevel); + + public void UpdateDetails( + string name, + UnitOfMeasurement unitOfMeasurement, + decimal? mainStoreReorderLevel, + decimal? kitchenParLevel) + { + Name = NormaliseName(name); + UnitOfMeasurement = unitOfMeasurement; + MainStoreReorderLevel = ValidateThreshold(mainStoreReorderLevel, nameof(mainStoreReorderLevel)); + KitchenParLevel = ValidateThreshold(kitchenParLevel, nameof(kitchenParLevel)); + } + + public void Activate() => IsActive = true; + + public void Deactivate() => IsActive = false; + + private static string NormaliseName(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + var trimmed = name.Trim(); + + return trimmed.Length > NameMaxLength + ? throw new ArgumentException($"Name cannot exceed {NameMaxLength} characters.", nameof(name)) + : trimmed; + } + + private static decimal? ValidateThreshold(decimal? threshold, string paramName) => + threshold is null or >= 0 + ? threshold + : throw new ArgumentOutOfRangeException(paramName, threshold, "A threshold cannot be negative."); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Entities/Recipe.cs b/backend/src/RestaurantPOS.Domain/Entities/Recipe.cs new file mode 100644 index 0000000..6ca3657 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Entities/Recipe.cs @@ -0,0 +1,63 @@ +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Domain.Entities; + +/// +/// The bill of materials for one menu item: which raw materials a single sale of it consumes, +/// and how much of each. At most one recipe exists per menu item (enforced by a unique index on +/// — see BR-REC-001), and not every menu item has one at all. +/// +public sealed class Recipe : BaseEntity +{ + private readonly List _lines = []; + + // EF Core materialisation. + private Recipe() + { + } + + private Recipe(Guid menuItemId, IEnumerable<(Guid RawMaterialId, decimal Quantity)> lines) + { + MenuItemId = menuItemId; + IsEnabled = true; + SetLines(lines); + } + + public Guid MenuItemId { get; private set; } + + /// Disabled recipes cannot be used for new sales (BR-REC-006) but stay editable. + public bool IsEnabled { get; private set; } + + public IReadOnlyCollection Lines => _lines.AsReadOnly(); + + /// Creates a recipe. Requires at least one line (BR-REC-002). + public static Recipe Create(Guid menuItemId, IEnumerable<(Guid RawMaterialId, decimal Quantity)> lines) => + new(menuItemId, lines); + + /// Replaces every ingredient line wholesale (REC-006: recipes are editable at any time). + public void ReplaceLines(IEnumerable<(Guid RawMaterialId, decimal Quantity)> lines) => SetLines(lines); + + public void Enable() => IsEnabled = true; + + public void Disable() => IsEnabled = false; + + private void SetLines(IEnumerable<(Guid RawMaterialId, decimal Quantity)> lines) + { + ArgumentNullException.ThrowIfNull(lines); + + var materialised = lines.ToList(); + + if (materialised.Count == 0) + { + throw new ArgumentException("A recipe must contain at least one raw material.", nameof(lines)); + } + + if (materialised.Select(l => l.RawMaterialId).Distinct().Count() != materialised.Count) + { + throw new ArgumentException("A raw material cannot appear more than once in the same recipe.", nameof(lines)); + } + + _lines.Clear(); + _lines.AddRange(materialised.Select(l => new RecipeLine(Id, l.RawMaterialId, l.Quantity))); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Entities/RecipeLine.cs b/backend/src/RestaurantPOS.Domain/Entities/RecipeLine.cs new file mode 100644 index 0000000..85d3a9c --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Entities/RecipeLine.cs @@ -0,0 +1,29 @@ +namespace RestaurantPOS.Domain.Entities; + +/// +/// One raw material and the quantity of it a single unit of the parent recipe's menu item +/// consumes, expressed in that raw material's own unit of measurement. +/// +public sealed class RecipeLine +{ + // EF Core materialisation. + private RecipeLine() + { + } + + internal RecipeLine(Guid recipeId, Guid rawMaterialId, decimal quantity) + { + RecipeId = recipeId; + RawMaterialId = rawMaterialId; + Quantity = quantity > 0 + ? quantity + : throw new ArgumentOutOfRangeException(nameof(quantity), quantity, "Quantity must be greater than zero."); + } + + public Guid RecipeId { get; private set; } + + public Guid RawMaterialId { get; private set; } + + /// Always greater than zero (BR-REC-003); fractional amounts are allowed (REC-012). + public decimal Quantity { get; private set; } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Entities/StockLevel.cs b/backend/src/RestaurantPOS.Domain/Entities/StockLevel.cs new file mode 100644 index 0000000..93f2f49 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Entities/StockLevel.cs @@ -0,0 +1,51 @@ +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Domain.Entities; + +/// +/// The current on-hand quantity of one raw material in one store. This is a derived balance, +/// never edited directly (BR-INV-006) — it only changes through , called +/// by the application layer alongside writing the that explains why. +/// +public sealed class StockLevel +{ + // EF Core materialisation. + private StockLevel() + { + } + + public StockLevel(Guid rawMaterialId, StoreType store) + { + RawMaterialId = rawMaterialId; + Store = store; + QuantityOnHand = 0; + } + + public Guid RawMaterialId { get; private set; } + + public StoreType Store { get; private set; } + + public decimal QuantityOnHand { get; private set; } + + public DateTime? UpdatedAtUtc { get; private set; } + + /// + /// Applies a signed change to the balance. This is a last-resort guard, not the primary + /// safety check — callers are expected to have already confirmed the result would not go + /// negative (the application layer blocks that with a friendly error) before ever reaching + /// here. + /// + public void ApplyDelta(decimal delta, DateTime nowUtc) + { + var updated = QuantityOnHand + delta; + + if (updated < 0) + { + throw new InvalidOperationException( + $"Applying this change would take the balance negative ({updated})."); + } + + QuantityOnHand = updated; + UpdatedAtUtc = nowUtc; + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Entities/StockMovement.cs b/backend/src/RestaurantPOS.Domain/Entities/StockMovement.cs new file mode 100644 index 0000000..ca74168 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Entities/StockMovement.cs @@ -0,0 +1,83 @@ +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Domain.Entities; + +/// +/// One line in the permanent stock ledger: every quantity change to every raw material, in +/// either store, for any reason, is one row here (BR-INV-005, BR-INV-007). This is the single +/// source of truth movements are reconstructed from — a GRN's or release's "lines" are simply +/// the movements sharing its , rather than being duplicated into their +/// own line tables. +/// +public sealed class StockMovement : BaseEntity +{ + public const int NotesMaxLength = 500; + + // EF Core materialisation. + private StockMovement() + { + } + + public StockMovement( + Guid rawMaterialId, + StoreType store, + decimal quantityDelta, + StockMovementType type, + Guid? referenceId, + Guid performedByUserId, + DateTime occurredAtUtc, + string? notes) + { + if (quantityDelta == 0) + { + throw new ArgumentOutOfRangeException(nameof(quantityDelta), quantityDelta, "A movement must change the balance."); + } + + RawMaterialId = rawMaterialId; + Store = store; + QuantityDelta = quantityDelta; + Type = type; + ReferenceId = referenceId; + PerformedByUserId = performedByUserId; + OccurredAtUtc = occurredAtUtc; + Notes = NormaliseNotes(notes); + } + + public Guid RawMaterialId { get; private set; } + + public StoreType Store { get; private set; } + + /// Signed change to the balance: positive increases stock, negative decreases it. + public decimal QuantityDelta { get; private set; } + + public StockMovementType Type { get; private set; } + + /// + /// The id of the record this movement traces back to — a or + /// . Null for an or + /// , which have no separate header record. + /// + public Guid? ReferenceId { get; private set; } + + public Guid PerformedByUserId { get; private set; } + + public DateTime OccurredAtUtc { get; private set; } + + /// Required for an adjustment (the reason for the correction); optional otherwise. + public string? Notes { get; private set; } + + private static string? NormaliseNotes(string? notes) + { + if (string.IsNullOrWhiteSpace(notes)) + { + return null; + } + + var trimmed = notes.Trim(); + + return trimmed.Length > NotesMaxLength + ? throw new ArgumentException($"Notes cannot exceed {NotesMaxLength} characters.", nameof(notes)) + : trimmed; + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Entities/StockRelease.cs b/backend/src/RestaurantPOS.Domain/Entities/StockRelease.cs new file mode 100644 index 0000000..30e0867 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Entities/StockRelease.cs @@ -0,0 +1,72 @@ +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Domain.Entities; + +/// +/// A transfer of stock from the Main Store to the Kitchen. Approval is immediate rather than a +/// separate pending state: the releasing staff member and an administrator's approval PIN are +/// both supplied in the same request, so a release exists only once it is already approved and +/// its stock movements have already been applied (INV-008 through INV-012). +/// +/// +/// Its lines are not stored here; they are the rows (one +/// and one +/// per raw material) sharing this release's +/// as their . +/// +public sealed class StockRelease : BaseEntity +{ + public const int NotesMaxLength = 500; + + // EF Core materialisation. + private StockRelease() + { + } + + private StockRelease( + Guid requestedByUserId, + DateTime requestedAtUtc, + Guid approvedByUserId, + DateTime approvedAtUtc, + string? notes) + { + RequestedByUserId = requestedByUserId; + RequestedAtUtc = requestedAtUtc; + ApprovedByUserId = approvedByUserId; + ApprovedAtUtc = approvedAtUtc; + Notes = NormaliseNotes(notes); + } + + public Guid RequestedByUserId { get; private set; } + + public DateTime RequestedAtUtc { get; private set; } + + /// The administrator whose approval PIN authorised this release. + public Guid ApprovedByUserId { get; private set; } + + public DateTime ApprovedAtUtc { get; private set; } + + public string? Notes { get; private set; } + + public static StockRelease Create( + Guid requestedByUserId, + DateTime requestedAtUtc, + Guid approvedByUserId, + DateTime approvedAtUtc, + string? notes = null) => + new(requestedByUserId, requestedAtUtc, approvedByUserId, approvedAtUtc, notes); + + private static string? NormaliseNotes(string? notes) + { + if (string.IsNullOrWhiteSpace(notes)) + { + return null; + } + + var trimmed = notes.Trim(); + + return trimmed.Length > NotesMaxLength + ? throw new ArgumentException($"Notes cannot exceed {NotesMaxLength} characters.", nameof(notes)) + : trimmed; + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Entities/Supplier.cs b/backend/src/RestaurantPOS.Domain/Entities/Supplier.cs new file mode 100644 index 0000000..525c986 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Entities/Supplier.cs @@ -0,0 +1,129 @@ +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Domain.Entities; + +/// +/// A goods supplier: who a Goods Received Note credits stock to, who a Purchase Order is sent +/// to, and whose prices and payment history the Supplier Management module tracks. +/// +public sealed class Supplier : BaseEntity +{ + public const int NameMaxLength = 150; + public const int ContactNameMaxLength = 150; + public const int PhoneMaxLength = 30; + public const int EmailMaxLength = 200; + public const int AddressMaxLength = 300; + + // EF Core materialisation. + private Supplier() + { + } + + private Supplier( + string name, + string? contactName, + string? phone, + string? email, + string? address, + int paymentTermsDays, + decimal? creditLimit, + int? leadTimeDays) + { + Name = NormaliseName(name); + ContactName = NormaliseOptional(contactName, ContactNameMaxLength, nameof(contactName)); + Phone = NormaliseOptional(phone, PhoneMaxLength, nameof(phone)); + Email = NormaliseOptional(email, EmailMaxLength, nameof(email))?.ToLowerInvariant(); + Address = NormaliseOptional(address, AddressMaxLength, nameof(address)); + PaymentTermsDays = ValidateNonNegative(paymentTermsDays, nameof(paymentTermsDays)); + CreditLimit = creditLimit is null ? null : ValidateNonNegative(creditLimit.Value, nameof(creditLimit)); + LeadTimeDays = leadTimeDays is null ? null : ValidateNonNegative(leadTimeDays.Value, nameof(leadTimeDays)); + IsActive = true; + } + + public string Name { get; private set; } = string.Empty; + + public string? ContactName { get; private set; } + + public string? Phone { get; private set; } + + public string? Email { get; private set; } + + public string? Address { get; private set; } + + /// Days after delivery payment is due. 0 means cash on delivery. + public int PaymentTermsDays { get; private set; } + + /// Maximum outstanding balance this supplier extends. Null means no limit is tracked. + public decimal? CreditLimit { get; private set; } + + /// Typical days between placing an order and delivery. Null means not yet known. + public int? LeadTimeDays { get; private set; } + + public bool IsActive { get; private set; } + + public static Supplier Create( + string name, + string? contactName = null, + string? phone = null, + string? email = null, + string? address = null, + int paymentTermsDays = 0, + decimal? creditLimit = null, + int? leadTimeDays = null) => + new(name, contactName, phone, email, address, paymentTermsDays, creditLimit, leadTimeDays); + + public void UpdateDetails( + string name, + string? contactName, + string? phone, + string? email, + string? address, + int paymentTermsDays, + decimal? creditLimit, + int? leadTimeDays) + { + Name = NormaliseName(name); + ContactName = NormaliseOptional(contactName, ContactNameMaxLength, nameof(contactName)); + Phone = NormaliseOptional(phone, PhoneMaxLength, nameof(phone)); + Email = NormaliseOptional(email, EmailMaxLength, nameof(email))?.ToLowerInvariant(); + Address = NormaliseOptional(address, AddressMaxLength, nameof(address)); + PaymentTermsDays = ValidateNonNegative(paymentTermsDays, nameof(paymentTermsDays)); + CreditLimit = creditLimit is null ? null : ValidateNonNegative(creditLimit.Value, nameof(creditLimit)); + LeadTimeDays = leadTimeDays is null ? null : ValidateNonNegative(leadTimeDays.Value, nameof(leadTimeDays)); + } + + public void Activate() => IsActive = true; + + public void Deactivate() => IsActive = false; + + private static string NormaliseName(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + var trimmed = name.Trim(); + + return trimmed.Length > NameMaxLength + ? throw new ArgumentException($"Name cannot exceed {NameMaxLength} characters.", nameof(name)) + : trimmed; + } + + private static string? NormaliseOptional(string? value, int maxLength, string paramName) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var trimmed = value.Trim(); + + return trimmed.Length > maxLength + ? throw new ArgumentException($"'{paramName}' cannot exceed {maxLength} characters.", paramName) + : trimmed; + } + + private static int ValidateNonNegative(int value, string paramName) => + value >= 0 ? value : throw new ArgumentOutOfRangeException(paramName, value, "Cannot be negative."); + + private static decimal ValidateNonNegative(decimal value, string paramName) => + value >= 0 ? value : throw new ArgumentOutOfRangeException(paramName, value, "Cannot be negative."); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Entities/SupplierPayment.cs b/backend/src/RestaurantPOS.Domain/Entities/SupplierPayment.cs new file mode 100644 index 0000000..21b1d13 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Entities/SupplierPayment.cs @@ -0,0 +1,78 @@ +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Domain.Entities; + +/// +/// A payment made toward a Purchase Order. A PO's paid/pending status is derived by comparing +/// the sum of its payments to its total, rather than stored as its own field — so it can never +/// go stale relative to the payments actually recorded. +/// +public sealed class SupplierPayment : BaseEntity +{ + public const int InvoiceReferenceMaxLength = 100; + public const int NotesMaxLength = 500; + + // EF Core materialisation. + private SupplierPayment() + { + } + + private SupplierPayment( + Guid purchaseOrderId, + decimal amount, + DateTime paymentDateUtc, + PaymentMethod method, + string? invoiceReference, + Guid recordedByUserId, + string? notes) + { + PurchaseOrderId = purchaseOrderId; + Amount = amount > 0 + ? amount + : throw new ArgumentOutOfRangeException(nameof(amount), amount, "Payment amount must be greater than zero."); + PaymentDateUtc = paymentDateUtc; + Method = method; + InvoiceReference = NormaliseOptional(invoiceReference, InvoiceReferenceMaxLength, nameof(invoiceReference)); + RecordedByUserId = recordedByUserId; + Notes = NormaliseOptional(notes, NotesMaxLength, nameof(notes)); + } + + public Guid PurchaseOrderId { get; private set; } + + public decimal Amount { get; private set; } + + public DateTime PaymentDateUtc { get; private set; } + + public PaymentMethod Method { get; private set; } + + public string? InvoiceReference { get; private set; } + + public Guid RecordedByUserId { get; private set; } + + public string? Notes { get; private set; } + + public static SupplierPayment Create( + Guid purchaseOrderId, + decimal amount, + DateTime paymentDateUtc, + PaymentMethod method, + Guid recordedByUserId, + string? invoiceReference = null, + string? notes = null) => + new(purchaseOrderId, amount, paymentDateUtc, method, invoiceReference, recordedByUserId, notes); + + private static string? NormaliseOptional(string? value, int maxLength, string paramName) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var trimmed = value.Trim(); + + return trimmed.Length > maxLength + ? throw new ArgumentException($"'{paramName}' cannot exceed {maxLength} characters.", paramName) + : trimmed; + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Entities/SupplierPrice.cs b/backend/src/RestaurantPOS.Domain/Entities/SupplierPrice.cs new file mode 100644 index 0000000..45f3dd8 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Entities/SupplierPrice.cs @@ -0,0 +1,40 @@ +namespace RestaurantPOS.Domain.Entities; + +/// +/// The current price a supplier charges for a raw material. This is a derived "latest" view, +/// kept in step by the application layer alongside a that +/// records every change — the same current-value-plus-ledger shape as +/// /. +/// +public sealed class SupplierPrice +{ + // EF Core materialisation. + private SupplierPrice() + { + } + + public SupplierPrice(Guid supplierId, Guid rawMaterialId, decimal price, DateTime updatedAtUtc) + { + SupplierId = supplierId; + RawMaterialId = rawMaterialId; + Price = ValidatePrice(price); + UpdatedAtUtc = updatedAtUtc; + } + + public Guid SupplierId { get; private set; } + + public Guid RawMaterialId { get; private set; } + + public decimal Price { get; private set; } + + public DateTime UpdatedAtUtc { get; private set; } + + public void UpdatePrice(decimal price, DateTime nowUtc) + { + Price = ValidatePrice(price); + UpdatedAtUtc = nowUtc; + } + + private static decimal ValidatePrice(decimal price) => + price >= 0 ? price : throw new ArgumentOutOfRangeException(nameof(price), price, "Price cannot be negative."); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Entities/SupplierPriceHistoryEntry.cs b/backend/src/RestaurantPOS.Domain/Entities/SupplierPriceHistoryEntry.cs new file mode 100644 index 0000000..b9144c0 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Entities/SupplierPriceHistoryEntry.cs @@ -0,0 +1,36 @@ +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Domain.Entities; + +/// One recorded price for a raw material from a supplier, kept forever for historical tracking. +public sealed class SupplierPriceHistoryEntry : BaseEntity +{ + // EF Core materialisation. + private SupplierPriceHistoryEntry() + { + } + + private SupplierPriceHistoryEntry( + Guid supplierId, Guid rawMaterialId, decimal price, Guid recordedByUserId, DateTime recordedAtUtc) + { + SupplierId = supplierId; + RawMaterialId = rawMaterialId; + Price = price; + RecordedByUserId = recordedByUserId; + RecordedAtUtc = recordedAtUtc; + } + + public Guid SupplierId { get; private set; } + + public Guid RawMaterialId { get; private set; } + + public decimal Price { get; private set; } + + public Guid RecordedByUserId { get; private set; } + + public DateTime RecordedAtUtc { get; private set; } + + public static SupplierPriceHistoryEntry Record( + Guid supplierId, Guid rawMaterialId, decimal price, Guid recordedByUserId, DateTime recordedAtUtc) => + new(supplierId, rawMaterialId, price, recordedByUserId, recordedAtUtc); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Enums/PaymentMethod.cs b/backend/src/RestaurantPOS.Domain/Enums/PaymentMethod.cs new file mode 100644 index 0000000..605a260 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Enums/PaymentMethod.cs @@ -0,0 +1,10 @@ +namespace RestaurantPOS.Domain.Enums; + +public enum PaymentMethod +{ + Cash = 1, + BankTransfer = 2, + Cheque = 3, + Card = 4, + Other = 5, +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Enums/PurchaseOrderStatus.cs b/backend/src/RestaurantPOS.Domain/Enums/PurchaseOrderStatus.cs new file mode 100644 index 0000000..0dc4f3d --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Enums/PurchaseOrderStatus.cs @@ -0,0 +1,15 @@ +namespace RestaurantPOS.Domain.Enums; + +/// +/// Lifecycle of a Purchase Order. Lines are editable only in ; every other +/// status represents a commitment that has already left the building (sent to the supplier, +/// confirmed by them, or delivered) and so is no longer freely editable. +/// +public enum PurchaseOrderStatus +{ + Draft = 1, + Submitted = 2, + Confirmed = 3, + Delivered = 4, + Cancelled = 5, +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Enums/StockMovementType.cs b/backend/src/RestaurantPOS.Domain/Enums/StockMovementType.cs new file mode 100644 index 0000000..30849c2 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Enums/StockMovementType.cs @@ -0,0 +1,27 @@ +namespace RestaurantPOS.Domain.Enums; + +/// +/// What caused a . Doubles as the movement's implied +/// direction and the kind of record (if any) it traces back to via +/// : +/// → a , +/// / → a , +/// and stand alone. +/// +public enum StockMovementType +{ + /// Stock received from a supplier into the Main Store. Always a positive delta. + GoodsReceived = 1, + + /// The Main Store side of a release to the kitchen. Always a negative delta. + StockReleaseOut = 2, + + /// The Kitchen side of a release from the main store. Always a positive delta. + StockReleaseIn = 3, + + /// A manual correction to match a physical stock count. Delta may be either sign. + Adjustment = 4, + + /// Kitchen stock deducted per a menu item's recipe when a sale completes. + Consumption = 5, +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Enums/StoreType.cs b/backend/src/RestaurantPOS.Domain/Enums/StoreType.cs new file mode 100644 index 0000000..5779352 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Enums/StoreType.cs @@ -0,0 +1,12 @@ +namespace RestaurantPOS.Domain.Enums; + +/// +/// The two fixed stock locations the restaurant operates. There are exactly two by design — +/// suppliers replenish only , and is replenished +/// only by an approved release from the main store (see BR-INV-001 through BR-INV-003). +/// +public enum StoreType +{ + MainStore = 1, + Kitchen = 2, +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Enums/UnitOfMeasurement.cs b/backend/src/RestaurantPOS.Domain/Enums/UnitOfMeasurement.cs new file mode 100644 index 0000000..58eec1b --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Enums/UnitOfMeasurement.cs @@ -0,0 +1,17 @@ +namespace RestaurantPOS.Domain.Enums; + +/// +/// The unit a raw material's stock is counted in. A raw material has exactly one unit for its +/// entire lifetime — recipes consuming it must specify quantities in this same unit, so no +/// conversion table is needed anywhere in the system. +/// +public enum UnitOfMeasurement +{ + Kilogram = 1, + Gram = 2, + Liter = 3, + Milliliter = 4, + Piece = 5, + Bottle = 6, + Packet = 7, +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Errors/InventoryErrors.cs b/backend/src/RestaurantPOS.Domain/Errors/InventoryErrors.cs new file mode 100644 index 0000000..d46afba --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Errors/InventoryErrors.cs @@ -0,0 +1,41 @@ +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Domain.Errors; + +/// Errors raised by raw material, supplier and stock movement use cases. +public static class InventoryErrors +{ + public static Error RawMaterialNotFound(Guid id) => + Error.NotFound("RawMaterial.NotFound", $"No raw material was found with id '{id}'."); + + public static readonly Error RawMaterialNameTaken = + Error.Conflict("RawMaterial.NameTaken", "A raw material with that name already exists."); + + public static readonly Error RawMaterialInactive = + Error.Validation("RawMaterial.Inactive", "This raw material is inactive and cannot be used in a new transaction."); + + public static readonly Error EmptyLines = + Error.Validation("Inventory.EmptyLines", "At least one raw material line is required."); + + public static readonly Error DuplicateRawMaterialLine = + Error.Validation("Inventory.DuplicateRawMaterialLine", "A raw material cannot appear more than once in the same transaction."); + + public static readonly Error InsufficientStock = + Error.Conflict( + "Inventory.InsufficientStock", + "This would take a raw material's stock below zero. Check the quantities and current stock levels."); + + public static Error MenuItemHasNoRecipe(Guid menuItemId) => + Error.Conflict( + "Inventory.MenuItemHasNoRecipe", + $"Menu item '{menuItemId}' has no recipe, so no stock was deducted."); + + public static Error GoodsReceivedNoteNotFound(Guid id) => + Error.NotFound("GoodsReceivedNote.NotFound", $"No GRN was found with id '{id}'."); + + public static Error StockReleaseNotFound(Guid id) => + Error.NotFound("StockRelease.NotFound", $"No stock release was found with id '{id}'."); + + public static readonly Error RecipeDisabled = + Error.Conflict("Inventory.RecipeDisabled", "This menu item's recipe is disabled and cannot be used for a new sale."); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Errors/RecipeErrors.cs b/backend/src/RestaurantPOS.Domain/Errors/RecipeErrors.cs new file mode 100644 index 0000000..5feb742 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Errors/RecipeErrors.cs @@ -0,0 +1,28 @@ +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Domain.Errors; + +/// Errors raised by menu item and recipe use cases. +public static class RecipeErrors +{ + public static Error MenuItemNotFound(Guid id) => + Error.NotFound("MenuItem.NotFound", $"No menu item was found with id '{id}'."); + + public static readonly Error MenuItemNameTaken = + Error.Conflict("MenuItem.NameTaken", "A menu item with that name already exists."); + + public static Error RecipeNotFound(Guid menuItemId) => + Error.NotFound("Recipe.NotFound", $"Menu item '{menuItemId}' does not have a recipe."); + + public static readonly Error EmptyRecipe = + Error.Validation("Recipe.Empty", "A recipe must contain at least one raw material."); + + public static readonly Error DuplicateRawMaterial = + Error.Validation("Recipe.DuplicateRawMaterial", "A raw material cannot appear more than once in the same recipe."); + + public static readonly Error UnknownRawMaterial = + Error.Validation("Recipe.UnknownRawMaterial", "One or more selected raw materials could not be found."); + + public static readonly Error InactiveRawMaterial = + Error.Validation("Recipe.InactiveRawMaterial", "One or more selected raw materials are inactive."); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Errors/SupplierErrors.cs b/backend/src/RestaurantPOS.Domain/Errors/SupplierErrors.cs new file mode 100644 index 0000000..a34bc4f --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Errors/SupplierErrors.cs @@ -0,0 +1,45 @@ +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Domain.Errors; + +/// Errors raised by supplier, purchase order, pricing and payment use cases. +public static class SupplierErrors +{ + public static Error NotFound(Guid id) => + Error.NotFound("Supplier.NotFound", $"No supplier was found with id '{id}'."); + + public static readonly Error NameTaken = + Error.Conflict("Supplier.NameTaken", "A supplier with that name already exists."); + + public static readonly Error Inactive = + Error.Validation("Supplier.Inactive", "This supplier is inactive and cannot be used on a new transaction."); + + public static Error PurchaseOrderNotFound(Guid id) => + Error.NotFound("PurchaseOrder.NotFound", $"No purchase order was found with id '{id}'."); + + public static readonly Error NotDraft = + Error.Conflict("PurchaseOrder.NotDraft", "Only a draft purchase order can be edited."); + + public static readonly Error NotSubmittable = + Error.Conflict("PurchaseOrder.NotSubmittable", "Only a draft purchase order can be submitted."); + + public static readonly Error NotConfirmable = + Error.Conflict("PurchaseOrder.NotConfirmable", "Only a submitted purchase order can be confirmed."); + + public static readonly Error NotCancellable = + Error.Conflict( + "PurchaseOrder.NotCancellable", "A delivered or already-cancelled purchase order cannot be cancelled."); + + public static readonly Error SupplierMismatch = + Error.Validation( + "GoodsReceivedNote.SupplierMismatch", "The selected purchase order was not placed with this supplier."); + + public static readonly Error PurchaseOrderCancelled = + Error.Conflict( + "GoodsReceivedNote.PurchaseOrderCancelled", "This purchase order was cancelled and cannot receive goods."); + + public static Error PaymentExceedsBalance(decimal balance) => + Error.Validation( + "SupplierPayment.ExceedsBalance", + $"This payment exceeds the outstanding balance of {balance:0.00} on the purchase order."); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/AppDbContext.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/AppDbContext.cs index 249c9be..afd97d0 100644 --- a/backend/src/RestaurantPOS.Infrastructure/Persistence/AppDbContext.cs +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/AppDbContext.cs @@ -23,6 +23,32 @@ public AppDbContext(DbContextOptions options, IPublisher? publishe public DbSet RefreshTokens => Set(); + public DbSet MenuItems => Set(); + + public DbSet RawMaterials => Set(); + + public DbSet Recipes => Set(); + + public DbSet Suppliers => Set(); + + public DbSet StockLevels => Set(); + + public DbSet StockMovements => Set(); + + public DbSet GoodsReceivedNotes => Set(); + + public DbSet StockReleases => Set(); + + public DbSet AuditLogEntries => Set(); + + public DbSet PurchaseOrders => Set(); + + public DbSet SupplierPrices => Set(); + + public DbSet SupplierPriceHistoryEntries => Set(); + + public DbSet SupplierPayments => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/AuditLogEntryConfiguration.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/AuditLogEntryConfiguration.cs new file mode 100644 index 0000000..daee880 --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/AuditLogEntryConfiguration.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Infrastructure.Persistence.Configurations; + +internal sealed class AuditLogEntryConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("AuditLogEntries"); + + builder.HasKey(a => a.Id); + builder.Property(a => a.Id).ValueGeneratedNever(); + + builder.Property(a => a.EntityType) + .IsRequired() + .HasMaxLength(AuditLogEntry.EntityTypeMaxLength); + + builder.Property(a => a.Action) + .IsRequired() + .HasMaxLength(AuditLogEntry.ActionMaxLength); + + builder.Property(a => a.PerformedByName) + .IsRequired() + .HasMaxLength(120); + + builder.Property(a => a.Summary) + .IsRequired() + .HasMaxLength(AuditLogEntry.SummaryMaxLength); + + builder.HasIndex(a => new { a.EntityType, a.EntityId }); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/GoodsReceivedNoteConfiguration.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/GoodsReceivedNoteConfiguration.cs new file mode 100644 index 0000000..e18d0a0 --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/GoodsReceivedNoteConfiguration.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Infrastructure.Persistence.Configurations; + +internal sealed class GoodsReceivedNoteConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("GoodsReceivedNotes"); + + builder.HasKey(n => n.Id); + builder.Property(n => n.Id).ValueGeneratedNever(); + + builder.Property(n => n.ReceivedAtUtc).IsRequired(); + builder.Property(n => n.Notes).HasMaxLength(GoodsReceivedNote.NotesMaxLength); + builder.Property(n => n.HasIssue).IsRequired(); + + builder.HasIndex(n => n.ReceivedAtUtc); + builder.HasIndex(n => n.PurchaseOrderId); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/MenuItemConfiguration.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/MenuItemConfiguration.cs new file mode 100644 index 0000000..95fea12 --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/MenuItemConfiguration.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Infrastructure.Persistence.Configurations; + +internal sealed class MenuItemConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("MenuItems"); + + builder.HasKey(m => m.Id); + + // Ids are generated by the domain, not the database. + builder.Property(m => m.Id).ValueGeneratedNever(); + + builder.Property(m => m.Name) + .IsRequired() + .HasMaxLength(MenuItem.NameMaxLength); + + builder.HasIndex(m => m.Name).IsUnique(); + + builder.Property(m => m.Category) + .IsRequired() + .HasMaxLength(MenuItem.CategoryMaxLength); + + builder.Property(m => m.Price).IsRequired(); + + builder.Property(m => m.IsActive).IsRequired(); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/PurchaseOrderConfiguration.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/PurchaseOrderConfiguration.cs new file mode 100644 index 0000000..5b9efd1 --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/PurchaseOrderConfiguration.cs @@ -0,0 +1,57 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Infrastructure.Persistence.Configurations; + +internal sealed class PurchaseOrderConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("PurchaseOrders"); + + builder.HasKey(o => o.Id); + builder.Property(o => o.Id).ValueGeneratedNever(); + + builder.Property(o => o.Status) + .IsRequired() + .HasConversion(); + + builder.Property(o => o.Notes).HasMaxLength(PurchaseOrder.NotesMaxLength); + + builder.HasIndex(o => new { o.SupplierId, o.Status }); + + builder.Metadata + .FindNavigation(nameof(PurchaseOrder.Lines))! + .SetPropertyAccessMode(PropertyAccessMode.Field); + + builder.HasMany(o => o.Lines) + .WithOne() + .HasForeignKey(l => l.PurchaseOrderId) + .OnDelete(DeleteBehavior.Cascade); + + // TotalAmount is computed in memory from the lines, not persisted. + builder.Ignore(o => o.TotalAmount); + } +} + +internal sealed class PurchaseOrderLineConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("PurchaseOrderLines"); + + // A raw material cannot appear more than once on the same purchase order. + builder.HasKey(l => new { l.PurchaseOrderId, l.RawMaterialId }); + + builder.Property(l => l.Quantity).IsRequired(); + builder.Property(l => l.UnitPrice).IsRequired(); + + builder.Ignore(l => l.LineTotal); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/RawMaterialConfiguration.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/RawMaterialConfiguration.cs new file mode 100644 index 0000000..aff2ab1 --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/RawMaterialConfiguration.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Infrastructure.Persistence.Configurations; + +internal sealed class RawMaterialConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("RawMaterials"); + + builder.HasKey(r => r.Id); + builder.Property(r => r.Id).ValueGeneratedNever(); + + builder.Property(r => r.Name) + .IsRequired() + .HasMaxLength(RawMaterial.NameMaxLength); + + builder.HasIndex(r => r.Name).IsUnique(); + + builder.Property(r => r.UnitOfMeasurement) + .IsRequired() + .HasConversion(); + + builder.Property(r => r.IsActive).IsRequired(); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/RecipeConfiguration.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/RecipeConfiguration.cs new file mode 100644 index 0000000..2f05c7f --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/RecipeConfiguration.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Infrastructure.Persistence.Configurations; + +internal sealed class RecipeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("Recipes"); + + builder.HasKey(r => r.Id); + builder.Property(r => r.Id).ValueGeneratedNever(); + + // At most one recipe per menu item (BR-REC-001). + builder.HasIndex(r => r.MenuItemId).IsUnique(); + + builder.Property(r => r.IsEnabled).IsRequired(); + + builder.Metadata + .FindNavigation(nameof(Recipe.Lines))! + .SetPropertyAccessMode(PropertyAccessMode.Field); + + builder.HasMany(r => r.Lines) + .WithOne() + .HasForeignKey(l => l.RecipeId) + .OnDelete(DeleteBehavior.Cascade); + } +} + +internal sealed class RecipeLineConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("RecipeLines"); + + // A raw material cannot appear more than once in the same recipe. + builder.HasKey(l => new { l.RecipeId, l.RawMaterialId }); + + builder.Property(l => l.Quantity).IsRequired(); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/StockLevelConfiguration.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/StockLevelConfiguration.cs new file mode 100644 index 0000000..cbe0d27 --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/StockLevelConfiguration.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Infrastructure.Persistence.Configurations; + +internal sealed class StockLevelConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("StockLevels"); + + // One balance row per raw material, per store. + builder.HasKey(l => new { l.RawMaterialId, l.Store }); + + builder.Property(l => l.Store) + .IsRequired() + .HasConversion(); + + builder.Property(l => l.QuantityOnHand).IsRequired(); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/StockMovementConfiguration.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/StockMovementConfiguration.cs new file mode 100644 index 0000000..caf274c --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/StockMovementConfiguration.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Infrastructure.Persistence.Configurations; + +internal sealed class StockMovementConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("StockMovements"); + + builder.HasKey(m => m.Id); + builder.Property(m => m.Id).ValueGeneratedNever(); + + builder.Property(m => m.Store) + .IsRequired() + .HasConversion(); + + builder.Property(m => m.QuantityDelta).IsRequired(); + + builder.Property(m => m.Type) + .IsRequired() + .HasConversion(); + + builder.Property(m => m.Notes).HasMaxLength(StockMovement.NotesMaxLength); + + // The stock history screen filters by store and raw material and sorts by date, and a + // GRN/release detail screen looks its lines up by ReferenceId. + builder.HasIndex(m => new { m.Store, m.RawMaterialId, m.OccurredAtUtc }); + builder.HasIndex(m => m.ReferenceId); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/StockReleaseConfiguration.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/StockReleaseConfiguration.cs new file mode 100644 index 0000000..f0cc0c0 --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/StockReleaseConfiguration.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Infrastructure.Persistence.Configurations; + +internal sealed class StockReleaseConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("StockReleases"); + + builder.HasKey(r => r.Id); + builder.Property(r => r.Id).ValueGeneratedNever(); + + builder.Property(r => r.RequestedAtUtc).IsRequired(); + builder.Property(r => r.ApprovedAtUtc).IsRequired(); + builder.Property(r => r.Notes).HasMaxLength(StockRelease.NotesMaxLength); + + builder.HasIndex(r => r.RequestedAtUtc); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/SupplierConfiguration.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/SupplierConfiguration.cs new file mode 100644 index 0000000..2c80f8d --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/SupplierConfiguration.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Infrastructure.Persistence.Configurations; + +internal sealed class SupplierConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("Suppliers"); + + builder.HasKey(s => s.Id); + builder.Property(s => s.Id).ValueGeneratedNever(); + + builder.Property(s => s.Name) + .IsRequired() + .HasMaxLength(Supplier.NameMaxLength); + + builder.HasIndex(s => s.Name).IsUnique(); + + builder.Property(s => s.ContactName).HasMaxLength(Supplier.ContactNameMaxLength); + builder.Property(s => s.Phone).HasMaxLength(Supplier.PhoneMaxLength); + builder.Property(s => s.Email).HasMaxLength(Supplier.EmailMaxLength); + builder.Property(s => s.Address).HasMaxLength(Supplier.AddressMaxLength); + builder.Property(s => s.PaymentTermsDays).IsRequired(); + + builder.Property(s => s.IsActive).IsRequired(); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/SupplierPaymentConfiguration.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/SupplierPaymentConfiguration.cs new file mode 100644 index 0000000..0db0ce1 --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/SupplierPaymentConfiguration.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Infrastructure.Persistence.Configurations; + +internal sealed class SupplierPaymentConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("SupplierPayments"); + + builder.HasKey(p => p.Id); + builder.Property(p => p.Id).ValueGeneratedNever(); + + builder.Property(p => p.Amount).IsRequired(); + builder.Property(p => p.PaymentDateUtc).IsRequired(); + + builder.Property(p => p.Method) + .IsRequired() + .HasConversion(); + + builder.Property(p => p.InvoiceReference).HasMaxLength(SupplierPayment.InvoiceReferenceMaxLength); + builder.Property(p => p.Notes).HasMaxLength(SupplierPayment.NotesMaxLength); + + builder.HasIndex(p => p.PurchaseOrderId); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/SupplierPriceConfiguration.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/SupplierPriceConfiguration.cs new file mode 100644 index 0000000..6b97a9e --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/SupplierPriceConfiguration.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Infrastructure.Persistence.Configurations; + +internal sealed class SupplierPriceConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("SupplierPrices"); + + // One current price per supplier, per raw material. + builder.HasKey(p => new { p.SupplierId, p.RawMaterialId }); + + builder.Property(p => p.Price).IsRequired(); + builder.Property(p => p.UpdatedAtUtc).IsRequired(); + } +} + +internal sealed class SupplierPriceHistoryEntryConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("SupplierPriceHistoryEntries"); + + builder.HasKey(e => e.Id); + builder.Property(e => e.Id).ValueGeneratedNever(); + + builder.Property(e => e.Price).IsRequired(); + builder.Property(e => e.RecordedAtUtc).IsRequired(); + + builder.HasIndex(e => new { e.SupplierId, e.RawMaterialId, e.RecordedAtUtc }); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260804063947_AddRecipeAndInventoryManagement.Designer.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260804063947_AddRecipeAndInventoryManagement.Designer.cs new file mode 100644 index 0000000..55c2b80 --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260804063947_AddRecipeAndInventoryManagement.Designer.cs @@ -0,0 +1,490 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using RestaurantPOS.Infrastructure.Persistence; + +#nullable disable + +namespace RestaurantPOS.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260804063947_AddRecipeAndInventoryManagement")] + partial class AddRecipeAndInventoryManagement + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.18"); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.AuditLogEntry", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("DetailsJson") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("TEXT"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OccurredAtUtc") + .HasColumnType("TEXT"); + + b.Property("PerformedByName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("PerformedByUserId") + .HasColumnType("TEXT"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("AuditLogEntries", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.GoodsReceivedNote", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ReceivedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ReceivedByUserId") + .HasColumnType("TEXT"); + + b.Property("SupplierId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ReceivedAtUtc"); + + b.ToTable("GoodsReceivedNotes", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.MenuItem", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("MenuItems", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RawMaterial", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("KitchenParLevel") + .HasColumnType("TEXT"); + + b.Property("MainStoreReorderLevel") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("UnitOfMeasurement") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("RawMaterials", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.Recipe", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("MenuItemId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MenuItemId") + .IsUnique(); + + b.ToTable("Recipes", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RecipeLine", b => + { + b.Property("RecipeId") + .HasColumnType("TEXT"); + + b.Property("RawMaterialId") + .HasColumnType("TEXT"); + + b.Property("Quantity") + .HasColumnType("TEXT"); + + b.HasKey("RecipeId", "RawMaterialId"); + + b.ToTable("RecipeLines", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("RevokedAtUtc") + .HasColumnType("TEXT"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.StockLevel", b => + { + b.Property("RawMaterialId") + .HasColumnType("TEXT"); + + b.Property("Store") + .HasColumnType("INTEGER"); + + b.Property("QuantityOnHand") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("RawMaterialId", "Store"); + + b.ToTable("StockLevels", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.StockMovement", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("OccurredAtUtc") + .HasColumnType("TEXT"); + + b.Property("PerformedByUserId") + .HasColumnType("TEXT"); + + b.Property("QuantityDelta") + .HasColumnType("TEXT"); + + b.Property("RawMaterialId") + .HasColumnType("TEXT"); + + b.Property("ReferenceId") + .HasColumnType("TEXT"); + + b.Property("Store") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ReferenceId"); + + b.HasIndex("Store", "RawMaterialId", "OccurredAtUtc"); + + b.ToTable("StockMovements", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.StockRelease", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApprovedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ApprovedByUserId") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("RequestedAtUtc") + .HasColumnType("TEXT"); + + b.Property("RequestedByUserId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RequestedAtUtc"); + + b.ToTable("StockReleases", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.Supplier", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Suppliers", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.User", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApprovalPinHash") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ApprovalPinSetAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsSystemAdmin") + .HasColumnType("INTEGER"); + + b.Property("LastLoginAtUtc") + .HasColumnType("TEXT"); + + b.Property("MustChangePassword") + .HasColumnType("INTEGER"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.UserModulePermission", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Module") + .HasColumnType("INTEGER"); + + b.Property("GrantedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "Module"); + + b.ToTable("UserModulePermissions", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RecipeLine", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.Recipe", null) + .WithMany("Lines") + .HasForeignKey("RecipeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RefreshToken", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.User", null) + .WithMany("RefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.UserModulePermission", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.User", null) + .WithMany("ModulePermissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.Recipe", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.User", b => + { + b.Navigation("ModulePermissions"); + + b.Navigation("RefreshTokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260804063947_AddRecipeAndInventoryManagement.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260804063947_AddRecipeAndInventoryManagement.cs new file mode 100644 index 0000000..8a980da --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260804063947_AddRecipeAndInventoryManagement.cs @@ -0,0 +1,274 @@ +using System; + +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RestaurantPOS.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddRecipeAndInventoryManagement : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AuditLogEntries", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + EntityType = table.Column(type: "TEXT", maxLength: 100, nullable: false), + EntityId = table.Column(type: "TEXT", nullable: false), + Action = table.Column(type: "TEXT", maxLength: 50, nullable: false), + PerformedByUserId = table.Column(type: "TEXT", nullable: false), + PerformedByName = table.Column(type: "TEXT", maxLength: 120, nullable: false), + OccurredAtUtc = table.Column(type: "TEXT", nullable: false), + Summary = table.Column(type: "TEXT", maxLength: 500, nullable: false), + DetailsJson = table.Column(type: "TEXT", nullable: true), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AuditLogEntries", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "GoodsReceivedNotes", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + SupplierId = table.Column(type: "TEXT", nullable: false), + ReceivedByUserId = table.Column(type: "TEXT", nullable: false), + ReceivedAtUtc = table.Column(type: "TEXT", nullable: false), + Notes = table.Column(type: "TEXT", maxLength: 500, nullable: true), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_GoodsReceivedNotes", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "MenuItems", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 150, nullable: false), + Category = table.Column(type: "TEXT", maxLength: 80, nullable: false), + Price = table.Column(type: "TEXT", nullable: false), + IsActive = table.Column(type: "INTEGER", nullable: false), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_MenuItems", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "RawMaterials", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 150, nullable: false), + UnitOfMeasurement = table.Column(type: "INTEGER", nullable: false), + MainStoreReorderLevel = table.Column(type: "TEXT", nullable: true), + KitchenParLevel = table.Column(type: "TEXT", nullable: true), + IsActive = table.Column(type: "INTEGER", nullable: false), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_RawMaterials", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Recipes", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + MenuItemId = table.Column(type: "TEXT", nullable: false), + IsEnabled = table.Column(type: "INTEGER", nullable: false), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Recipes", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "StockLevels", + columns: table => new + { + RawMaterialId = table.Column(type: "TEXT", nullable: false), + Store = table.Column(type: "INTEGER", nullable: false), + QuantityOnHand = table.Column(type: "TEXT", nullable: false), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_StockLevels", x => new { x.RawMaterialId, x.Store }); + }); + + migrationBuilder.CreateTable( + name: "StockMovements", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + RawMaterialId = table.Column(type: "TEXT", nullable: false), + Store = table.Column(type: "INTEGER", nullable: false), + QuantityDelta = table.Column(type: "TEXT", nullable: false), + Type = table.Column(type: "INTEGER", nullable: false), + ReferenceId = table.Column(type: "TEXT", nullable: true), + PerformedByUserId = table.Column(type: "TEXT", nullable: false), + OccurredAtUtc = table.Column(type: "TEXT", nullable: false), + Notes = table.Column(type: "TEXT", maxLength: 500, nullable: true), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_StockMovements", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "StockReleases", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + RequestedByUserId = table.Column(type: "TEXT", nullable: false), + RequestedAtUtc = table.Column(type: "TEXT", nullable: false), + ApprovedByUserId = table.Column(type: "TEXT", nullable: false), + ApprovedAtUtc = table.Column(type: "TEXT", nullable: false), + Notes = table.Column(type: "TEXT", maxLength: 500, nullable: true), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_StockReleases", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Suppliers", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 150, nullable: false), + IsActive = table.Column(type: "INTEGER", nullable: false), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Suppliers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "RecipeLines", + columns: table => new + { + RecipeId = table.Column(type: "TEXT", nullable: false), + RawMaterialId = table.Column(type: "TEXT", nullable: false), + Quantity = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_RecipeLines", x => new { x.RecipeId, x.RawMaterialId }); + table.ForeignKey( + name: "FK_RecipeLines_Recipes_RecipeId", + column: x => x.RecipeId, + principalTable: "Recipes", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AuditLogEntries_EntityType_EntityId", + table: "AuditLogEntries", + columns: new[] { "EntityType", "EntityId" }); + + migrationBuilder.CreateIndex( + name: "IX_GoodsReceivedNotes_ReceivedAtUtc", + table: "GoodsReceivedNotes", + column: "ReceivedAtUtc"); + + migrationBuilder.CreateIndex( + name: "IX_MenuItems_Name", + table: "MenuItems", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_RawMaterials_Name", + table: "RawMaterials", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Recipes_MenuItemId", + table: "Recipes", + column: "MenuItemId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_StockMovements_ReferenceId", + table: "StockMovements", + column: "ReferenceId"); + + migrationBuilder.CreateIndex( + name: "IX_StockMovements_Store_RawMaterialId_OccurredAtUtc", + table: "StockMovements", + columns: new[] { "Store", "RawMaterialId", "OccurredAtUtc" }); + + migrationBuilder.CreateIndex( + name: "IX_StockReleases_RequestedAtUtc", + table: "StockReleases", + column: "RequestedAtUtc"); + + migrationBuilder.CreateIndex( + name: "IX_Suppliers_Name", + table: "Suppliers", + column: "Name", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AuditLogEntries"); + + migrationBuilder.DropTable( + name: "GoodsReceivedNotes"); + + migrationBuilder.DropTable( + name: "MenuItems"); + + migrationBuilder.DropTable( + name: "RawMaterials"); + + migrationBuilder.DropTable( + name: "RecipeLines"); + + migrationBuilder.DropTable( + name: "StockLevels"); + + migrationBuilder.DropTable( + name: "StockMovements"); + + migrationBuilder.DropTable( + name: "StockReleases"); + + migrationBuilder.DropTable( + name: "Suppliers"); + + migrationBuilder.DropTable( + name: "Recipes"); + } + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260804135447_AddSupplierManagement.Designer.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260804135447_AddSupplierManagement.Designer.cs new file mode 100644 index 0000000..f31e707 --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260804135447_AddSupplierManagement.Designer.cs @@ -0,0 +1,689 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using RestaurantPOS.Infrastructure.Persistence; + +#nullable disable + +namespace RestaurantPOS.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260804135447_AddSupplierManagement")] + partial class AddSupplierManagement + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.18"); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.AuditLogEntry", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("DetailsJson") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("TEXT"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OccurredAtUtc") + .HasColumnType("TEXT"); + + b.Property("PerformedByName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("PerformedByUserId") + .HasColumnType("TEXT"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("AuditLogEntries", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.GoodsReceivedNote", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("HasIssue") + .HasColumnType("INTEGER"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PurchaseOrderId") + .HasColumnType("TEXT"); + + b.Property("QualityRating") + .HasColumnType("INTEGER"); + + b.Property("ReceivedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ReceivedByUserId") + .HasColumnType("TEXT"); + + b.Property("SupplierId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PurchaseOrderId"); + + b.HasIndex("ReceivedAtUtc"); + + b.ToTable("GoodsReceivedNotes", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.MenuItem", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("MenuItems", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.PurchaseOrder", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreatedByUserId") + .HasColumnType("TEXT"); + + b.Property("ExpectedDeliveryDate") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("SubmittedAtUtc") + .HasColumnType("TEXT"); + + b.Property("SupplierId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SupplierId", "Status"); + + b.ToTable("PurchaseOrders", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.PurchaseOrderLine", b => + { + b.Property("PurchaseOrderId") + .HasColumnType("TEXT"); + + b.Property("RawMaterialId") + .HasColumnType("TEXT"); + + b.Property("Quantity") + .HasColumnType("TEXT"); + + b.Property("UnitPrice") + .HasColumnType("TEXT"); + + b.HasKey("PurchaseOrderId", "RawMaterialId"); + + b.ToTable("PurchaseOrderLines", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RawMaterial", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("KitchenParLevel") + .HasColumnType("TEXT"); + + b.Property("MainStoreReorderLevel") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("UnitOfMeasurement") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("RawMaterials", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.Recipe", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("MenuItemId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MenuItemId") + .IsUnique(); + + b.ToTable("Recipes", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RecipeLine", b => + { + b.Property("RecipeId") + .HasColumnType("TEXT"); + + b.Property("RawMaterialId") + .HasColumnType("TEXT"); + + b.Property("Quantity") + .HasColumnType("TEXT"); + + b.HasKey("RecipeId", "RawMaterialId"); + + b.ToTable("RecipeLines", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("RevokedAtUtc") + .HasColumnType("TEXT"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.StockLevel", b => + { + b.Property("RawMaterialId") + .HasColumnType("TEXT"); + + b.Property("Store") + .HasColumnType("INTEGER"); + + b.Property("QuantityOnHand") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("RawMaterialId", "Store"); + + b.ToTable("StockLevels", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.StockMovement", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("OccurredAtUtc") + .HasColumnType("TEXT"); + + b.Property("PerformedByUserId") + .HasColumnType("TEXT"); + + b.Property("QuantityDelta") + .HasColumnType("TEXT"); + + b.Property("RawMaterialId") + .HasColumnType("TEXT"); + + b.Property("ReferenceId") + .HasColumnType("TEXT"); + + b.Property("Store") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ReferenceId"); + + b.HasIndex("Store", "RawMaterialId", "OccurredAtUtc"); + + b.ToTable("StockMovements", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.StockRelease", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApprovedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ApprovedByUserId") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("RequestedAtUtc") + .HasColumnType("TEXT"); + + b.Property("RequestedByUserId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RequestedAtUtc"); + + b.ToTable("StockReleases", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.Supplier", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Address") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("ContactName") + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreditLimit") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LeadTimeDays") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("PaymentTermsDays") + .HasColumnType("INTEGER"); + + b.Property("Phone") + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Suppliers", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.SupplierPayment", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Amount") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("InvoiceReference") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Method") + .HasColumnType("INTEGER"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PaymentDateUtc") + .HasColumnType("TEXT"); + + b.Property("PurchaseOrderId") + .HasColumnType("TEXT"); + + b.Property("RecordedByUserId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PurchaseOrderId"); + + b.ToTable("SupplierPayments", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.SupplierPrice", b => + { + b.Property("SupplierId") + .HasColumnType("TEXT"); + + b.Property("RawMaterialId") + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("SupplierId", "RawMaterialId"); + + b.ToTable("SupplierPrices", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.SupplierPriceHistoryEntry", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("TEXT"); + + b.Property("RawMaterialId") + .HasColumnType("TEXT"); + + b.Property("RecordedAtUtc") + .HasColumnType("TEXT"); + + b.Property("RecordedByUserId") + .HasColumnType("TEXT"); + + b.Property("SupplierId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SupplierId", "RawMaterialId", "RecordedAtUtc"); + + b.ToTable("SupplierPriceHistoryEntries", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.User", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApprovalPinHash") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ApprovalPinSetAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsSystemAdmin") + .HasColumnType("INTEGER"); + + b.Property("LastLoginAtUtc") + .HasColumnType("TEXT"); + + b.Property("MustChangePassword") + .HasColumnType("INTEGER"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.UserModulePermission", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Module") + .HasColumnType("INTEGER"); + + b.Property("GrantedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "Module"); + + b.ToTable("UserModulePermissions", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.PurchaseOrderLine", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.PurchaseOrder", null) + .WithMany("Lines") + .HasForeignKey("PurchaseOrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RecipeLine", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.Recipe", null) + .WithMany("Lines") + .HasForeignKey("RecipeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RefreshToken", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.User", null) + .WithMany("RefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.UserModulePermission", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.User", null) + .WithMany("ModulePermissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.PurchaseOrder", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.Recipe", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.User", b => + { + b.Navigation("ModulePermissions"); + + b.Navigation("RefreshTokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260804135447_AddSupplierManagement.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260804135447_AddSupplierManagement.cs new file mode 100644 index 0000000..9247c5c --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260804135447_AddSupplierManagement.cs @@ -0,0 +1,256 @@ +using System; + +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RestaurantPOS.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddSupplierManagement : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Address", + table: "Suppliers", + type: "TEXT", + maxLength: 300, + nullable: true); + + migrationBuilder.AddColumn( + name: "ContactName", + table: "Suppliers", + type: "TEXT", + maxLength: 150, + nullable: true); + + migrationBuilder.AddColumn( + name: "CreditLimit", + table: "Suppliers", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "Email", + table: "Suppliers", + type: "TEXT", + maxLength: 200, + nullable: true); + + migrationBuilder.AddColumn( + name: "LeadTimeDays", + table: "Suppliers", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "PaymentTermsDays", + table: "Suppliers", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "Phone", + table: "Suppliers", + type: "TEXT", + maxLength: 30, + nullable: true); + + migrationBuilder.AddColumn( + name: "HasIssue", + table: "GoodsReceivedNotes", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "PurchaseOrderId", + table: "GoodsReceivedNotes", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "QualityRating", + table: "GoodsReceivedNotes", + type: "INTEGER", + nullable: true); + + migrationBuilder.CreateTable( + name: "PurchaseOrders", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + SupplierId = table.Column(type: "TEXT", nullable: false), + CreatedByUserId = table.Column(type: "TEXT", nullable: false), + Status = table.Column(type: "INTEGER", nullable: false), + ExpectedDeliveryDate = table.Column(type: "TEXT", nullable: true), + SubmittedAtUtc = table.Column(type: "TEXT", nullable: true), + Notes = table.Column(type: "TEXT", maxLength: 500, nullable: true), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PurchaseOrders", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "SupplierPayments", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + PurchaseOrderId = table.Column(type: "TEXT", nullable: false), + Amount = table.Column(type: "TEXT", nullable: false), + PaymentDateUtc = table.Column(type: "TEXT", nullable: false), + Method = table.Column(type: "INTEGER", nullable: false), + InvoiceReference = table.Column(type: "TEXT", maxLength: 100, nullable: true), + RecordedByUserId = table.Column(type: "TEXT", nullable: false), + Notes = table.Column(type: "TEXT", maxLength: 500, nullable: true), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_SupplierPayments", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "SupplierPriceHistoryEntries", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + SupplierId = table.Column(type: "TEXT", nullable: false), + RawMaterialId = table.Column(type: "TEXT", nullable: false), + Price = table.Column(type: "TEXT", nullable: false), + RecordedByUserId = table.Column(type: "TEXT", nullable: false), + RecordedAtUtc = table.Column(type: "TEXT", nullable: false), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_SupplierPriceHistoryEntries", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "SupplierPrices", + columns: table => new + { + SupplierId = table.Column(type: "TEXT", nullable: false), + RawMaterialId = table.Column(type: "TEXT", nullable: false), + Price = table.Column(type: "TEXT", nullable: false), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SupplierPrices", x => new { x.SupplierId, x.RawMaterialId }); + }); + + migrationBuilder.CreateTable( + name: "PurchaseOrderLines", + columns: table => new + { + PurchaseOrderId = table.Column(type: "TEXT", nullable: false), + RawMaterialId = table.Column(type: "TEXT", nullable: false), + Quantity = table.Column(type: "TEXT", nullable: false), + UnitPrice = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PurchaseOrderLines", x => new { x.PurchaseOrderId, x.RawMaterialId }); + table.ForeignKey( + name: "FK_PurchaseOrderLines_PurchaseOrders_PurchaseOrderId", + column: x => x.PurchaseOrderId, + principalTable: "PurchaseOrders", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_GoodsReceivedNotes_PurchaseOrderId", + table: "GoodsReceivedNotes", + column: "PurchaseOrderId"); + + migrationBuilder.CreateIndex( + name: "IX_PurchaseOrders_SupplierId_Status", + table: "PurchaseOrders", + columns: new[] { "SupplierId", "Status" }); + + migrationBuilder.CreateIndex( + name: "IX_SupplierPayments_PurchaseOrderId", + table: "SupplierPayments", + column: "PurchaseOrderId"); + + migrationBuilder.CreateIndex( + name: "IX_SupplierPriceHistoryEntries_SupplierId_RawMaterialId_RecordedAtUtc", + table: "SupplierPriceHistoryEntries", + columns: new[] { "SupplierId", "RawMaterialId", "RecordedAtUtc" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PurchaseOrderLines"); + + migrationBuilder.DropTable( + name: "SupplierPayments"); + + migrationBuilder.DropTable( + name: "SupplierPriceHistoryEntries"); + + migrationBuilder.DropTable( + name: "SupplierPrices"); + + migrationBuilder.DropTable( + name: "PurchaseOrders"); + + migrationBuilder.DropIndex( + name: "IX_GoodsReceivedNotes_PurchaseOrderId", + table: "GoodsReceivedNotes"); + + migrationBuilder.DropColumn( + name: "Address", + table: "Suppliers"); + + migrationBuilder.DropColumn( + name: "ContactName", + table: "Suppliers"); + + migrationBuilder.DropColumn( + name: "CreditLimit", + table: "Suppliers"); + + migrationBuilder.DropColumn( + name: "Email", + table: "Suppliers"); + + migrationBuilder.DropColumn( + name: "LeadTimeDays", + table: "Suppliers"); + + migrationBuilder.DropColumn( + name: "PaymentTermsDays", + table: "Suppliers"); + + migrationBuilder.DropColumn( + name: "Phone", + table: "Suppliers"); + + migrationBuilder.DropColumn( + name: "HasIssue", + table: "GoodsReceivedNotes"); + + migrationBuilder.DropColumn( + name: "PurchaseOrderId", + table: "GoodsReceivedNotes"); + + migrationBuilder.DropColumn( + name: "QualityRating", + table: "GoodsReceivedNotes"); + } + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index 46ffd44..e28515f 100644 --- a/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -17,6 +17,266 @@ protected override void BuildModel(ModelBuilder modelBuilder) #pragma warning disable 612, 618 modelBuilder.HasAnnotation("ProductVersion", "9.0.18"); + modelBuilder.Entity("RestaurantPOS.Domain.Entities.AuditLogEntry", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("DetailsJson") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("TEXT"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OccurredAtUtc") + .HasColumnType("TEXT"); + + b.Property("PerformedByName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("PerformedByUserId") + .HasColumnType("TEXT"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("AuditLogEntries", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.GoodsReceivedNote", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("HasIssue") + .HasColumnType("INTEGER"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PurchaseOrderId") + .HasColumnType("TEXT"); + + b.Property("QualityRating") + .HasColumnType("INTEGER"); + + b.Property("ReceivedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ReceivedByUserId") + .HasColumnType("TEXT"); + + b.Property("SupplierId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PurchaseOrderId"); + + b.HasIndex("ReceivedAtUtc"); + + b.ToTable("GoodsReceivedNotes", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.MenuItem", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("MenuItems", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.PurchaseOrder", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreatedByUserId") + .HasColumnType("TEXT"); + + b.Property("ExpectedDeliveryDate") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("SubmittedAtUtc") + .HasColumnType("TEXT"); + + b.Property("SupplierId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SupplierId", "Status"); + + b.ToTable("PurchaseOrders", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.PurchaseOrderLine", b => + { + b.Property("PurchaseOrderId") + .HasColumnType("TEXT"); + + b.Property("RawMaterialId") + .HasColumnType("TEXT"); + + b.Property("Quantity") + .HasColumnType("TEXT"); + + b.Property("UnitPrice") + .HasColumnType("TEXT"); + + b.HasKey("PurchaseOrderId", "RawMaterialId"); + + b.ToTable("PurchaseOrderLines", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RawMaterial", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("KitchenParLevel") + .HasColumnType("TEXT"); + + b.Property("MainStoreReorderLevel") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("UnitOfMeasurement") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("RawMaterials", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.Recipe", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("MenuItemId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MenuItemId") + .IsUnique(); + + b.ToTable("Recipes", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RecipeLine", b => + { + b.Property("RecipeId") + .HasColumnType("TEXT"); + + b.Property("RawMaterialId") + .HasColumnType("TEXT"); + + b.Property("Quantity") + .HasColumnType("TEXT"); + + b.HasKey("RecipeId", "RawMaterialId"); + + b.ToTable("RecipeLines", (string)null); + }); + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RefreshToken", b => { b.Property("Id") @@ -49,6 +309,249 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("RefreshTokens", (string)null); }); + modelBuilder.Entity("RestaurantPOS.Domain.Entities.StockLevel", b => + { + b.Property("RawMaterialId") + .HasColumnType("TEXT"); + + b.Property("Store") + .HasColumnType("INTEGER"); + + b.Property("QuantityOnHand") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("RawMaterialId", "Store"); + + b.ToTable("StockLevels", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.StockMovement", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("OccurredAtUtc") + .HasColumnType("TEXT"); + + b.Property("PerformedByUserId") + .HasColumnType("TEXT"); + + b.Property("QuantityDelta") + .HasColumnType("TEXT"); + + b.Property("RawMaterialId") + .HasColumnType("TEXT"); + + b.Property("ReferenceId") + .HasColumnType("TEXT"); + + b.Property("Store") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ReferenceId"); + + b.HasIndex("Store", "RawMaterialId", "OccurredAtUtc"); + + b.ToTable("StockMovements", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.StockRelease", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApprovedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ApprovedByUserId") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("RequestedAtUtc") + .HasColumnType("TEXT"); + + b.Property("RequestedByUserId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RequestedAtUtc"); + + b.ToTable("StockReleases", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.Supplier", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Address") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("ContactName") + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreditLimit") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LeadTimeDays") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("PaymentTermsDays") + .HasColumnType("INTEGER"); + + b.Property("Phone") + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Suppliers", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.SupplierPayment", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Amount") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("InvoiceReference") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Method") + .HasColumnType("INTEGER"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PaymentDateUtc") + .HasColumnType("TEXT"); + + b.Property("PurchaseOrderId") + .HasColumnType("TEXT"); + + b.Property("RecordedByUserId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PurchaseOrderId"); + + b.ToTable("SupplierPayments", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.SupplierPrice", b => + { + b.Property("SupplierId") + .HasColumnType("TEXT"); + + b.Property("RawMaterialId") + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("SupplierId", "RawMaterialId"); + + b.ToTable("SupplierPrices", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.SupplierPriceHistoryEntry", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("TEXT"); + + b.Property("RawMaterialId") + .HasColumnType("TEXT"); + + b.Property("RecordedAtUtc") + .HasColumnType("TEXT"); + + b.Property("RecordedByUserId") + .HasColumnType("TEXT"); + + b.Property("SupplierId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SupplierId", "RawMaterialId", "RecordedAtUtc"); + + b.ToTable("SupplierPriceHistoryEntries", (string)null); + }); + modelBuilder.Entity("RestaurantPOS.Domain.Entities.User", b => { b.Property("Id") @@ -125,6 +628,24 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("UserModulePermissions", (string)null); }); + modelBuilder.Entity("RestaurantPOS.Domain.Entities.PurchaseOrderLine", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.PurchaseOrder", null) + .WithMany("Lines") + .HasForeignKey("PurchaseOrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RecipeLine", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.Recipe", null) + .WithMany("Lines") + .HasForeignKey("RecipeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RefreshToken", b => { b.HasOne("RestaurantPOS.Domain.Entities.User", null) @@ -143,6 +664,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("RestaurantPOS.Domain.Entities.PurchaseOrder", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.Recipe", b => + { + b.Navigation("Lines"); + }); + modelBuilder.Entity("RestaurantPOS.Domain.Entities.User", b => { b.Navigation("ModulePermissions"); diff --git a/backend/tests/RestaurantPOS.IntegrationTests/Common/PosApiClient.Inventory.cs b/backend/tests/RestaurantPOS.IntegrationTests/Common/PosApiClient.Inventory.cs new file mode 100644 index 0000000..e0f641f --- /dev/null +++ b/backend/tests/RestaurantPOS.IntegrationTests/Common/PosApiClient.Inventory.cs @@ -0,0 +1,173 @@ +using System.Net.Http.Json; + +namespace RestaurantPOS.IntegrationTests.Common; + +/// A menu item as the API returns it. +public sealed record MenuItemResponse( + Guid Id, string Name, string Category, decimal Price, bool IsActive, bool HasRecipe); + +public sealed record RecipeLineResponse(Guid RawMaterialId, string RawMaterialName, string UnitOfMeasurement, decimal Quantity); + +public sealed record RecipeResponse(Guid Id, Guid MenuItemId, bool IsEnabled, IReadOnlyCollection Lines); + +public sealed record RawMaterialResponse( + Guid Id, + string Name, + string UnitOfMeasurement, + decimal? MainStoreReorderLevel, + decimal? KitchenParLevel, + bool IsActive); + +public sealed record StockLevelResponse( + Guid RawMaterialId, string RawMaterialName, string UnitOfMeasurement, string Store, decimal QuantityOnHand, bool IsLowStock); + +public sealed record StockMovementResponse( + Guid Id, + Guid RawMaterialId, + string RawMaterialName, + string Store, + decimal QuantityDelta, + string Type, + Guid? ReferenceId, + string PerformedByName, + string? Notes); + +public sealed record StockMovementLineResponse(Guid RawMaterialId, string RawMaterialName, decimal Quantity); + +public sealed record GoodsReceivedNoteResponse( + Guid Id, + Guid SupplierId, + string SupplierName, + string? Notes, + Guid? PurchaseOrderId, + int? QualityRating, + bool HasIssue, + IReadOnlyCollection Lines); + +public sealed record StockReleaseResponse( + Guid Id, string ApprovedByName, string? Notes, IReadOnlyCollection Lines); + +public sealed record ConsumedLineResponse(Guid RawMaterialId, string RawMaterialName, decimal QuantityDeducted); + +public sealed record ConsumptionResultResponse(bool Deducted, IReadOnlyCollection Lines); + +/// Recipe Management and Inventory Management calls, split out from the auth/user API surface. +public sealed partial class PosApiClient +{ + // ----- Menu items ----- + + public Task CreateMenuItemAsync(string name, string category, decimal price) => + Http.PostAsJsonAsync($"{BaseUrl}/menu-items", new { name, category, price }, Json); + + public Task UpdateMenuItemAsync(Guid id, string name, string category, decimal price) => + Http.PutAsJsonAsync($"{BaseUrl}/menu-items/{id}", new { name, category, price }, Json); + + public Task SetMenuItemActiveAsync(Guid id, bool isActive) => + Http.PutAsJsonAsync($"{BaseUrl}/menu-items/{id}/status", new { isActive }, Json); + + public Task GetMenuItemsAsync(string? query = null) => + Http.GetAsync($"{BaseUrl}/menu-items{query}"); + + public Task GetMenuItemAsync(Guid id) => Http.GetAsync($"{BaseUrl}/menu-items/{id}"); + + // ----- Recipes ----- + + public Task GetRecipeAsync(Guid menuItemId) => + Http.GetAsync($"{BaseUrl}/menu-items/{menuItemId}/recipe"); + + public Task UpsertRecipeAsync(Guid menuItemId, params (Guid RawMaterialId, decimal Quantity)[] lines) => + Http.PutAsJsonAsync( + $"{BaseUrl}/menu-items/{menuItemId}/recipe", + new { lines = lines.Select(l => new { rawMaterialId = l.RawMaterialId, quantity = l.Quantity }) }, + Json); + + public Task SetRecipeEnabledAsync(Guid menuItemId, bool isEnabled) => + Http.PutAsJsonAsync($"{BaseUrl}/menu-items/{menuItemId}/recipe/status", new { isEnabled }, Json); + + public Task DeleteRecipeAsync(Guid menuItemId) => + Http.DeleteAsync($"{BaseUrl}/menu-items/{menuItemId}/recipe"); + + // ----- Raw materials ----- + + public Task CreateRawMaterialAsync( + string name, string unitOfMeasurement = "Kilogram", decimal? mainStoreReorderLevel = null, decimal? kitchenParLevel = null) => + Http.PostAsJsonAsync( + $"{BaseUrl}/raw-materials", + new { name, unitOfMeasurement, mainStoreReorderLevel, kitchenParLevel }, + Json); + + public Task UpdateRawMaterialAsync( + Guid id, string name, string unitOfMeasurement, decimal? mainStoreReorderLevel, decimal? kitchenParLevel) => + Http.PutAsJsonAsync( + $"{BaseUrl}/raw-materials/{id}", + new { name, unitOfMeasurement, mainStoreReorderLevel, kitchenParLevel }, + Json); + + public Task SetRawMaterialActiveAsync(Guid id, bool isActive) => + Http.PutAsJsonAsync($"{BaseUrl}/raw-materials/{id}/status", new { isActive }, Json); + + public Task GetRawMaterialsAsync(string? query = null) => + Http.GetAsync($"{BaseUrl}/raw-materials{query}"); + + // ----- Main Store ----- + + public Task GetMainStoreStockAsync(bool lowStockOnly = false) => + Http.GetAsync($"{BaseUrl}/inventory/main-store/stock?lowStockOnly={lowStockOnly}"); + + public Task GetMainStoreMovementsAsync() => + Http.GetAsync($"{BaseUrl}/inventory/main-store/movements"); + + public Task CreateGoodsReceivedNoteAsync( + Guid supplierId, + (Guid RawMaterialId, decimal Quantity)[] lines, + string? notes = null, + Guid? purchaseOrderId = null, + int? qualityRating = null, + bool hasIssue = false) => + Http.PostAsJsonAsync( + $"{BaseUrl}/inventory/main-store/goods-received", + new + { + supplierId, + lines = lines.Select(l => new { rawMaterialId = l.RawMaterialId, quantity = l.Quantity }), + notes, + purchaseOrderId, + qualityRating, + hasIssue, + }, + Json); + + public Task CreateMainStoreAdjustmentAsync(Guid rawMaterialId, decimal quantityDelta, string reason) => + Http.PostAsJsonAsync( + $"{BaseUrl}/inventory/main-store/adjustments", + new { rawMaterialId, store = "MainStore", quantityDelta, reason }, + Json); + + // ----- Kitchen ----- + + public Task GetKitchenStockAsync(bool lowStockOnly = false) => + Http.GetAsync($"{BaseUrl}/inventory/kitchen/stock?lowStockOnly={lowStockOnly}"); + + public Task GetKitchenMovementsAsync() => + Http.GetAsync($"{BaseUrl}/inventory/kitchen/movements"); + + public Task CreateKitchenAdjustmentAsync(Guid rawMaterialId, decimal quantityDelta, string reason) => + Http.PostAsJsonAsync( + $"{BaseUrl}/inventory/kitchen/adjustments", + new { rawMaterialId, store = "Kitchen", quantityDelta, reason }, + Json); + + public Task ConsumeStockAsync(Guid menuItemId, decimal quantitySold) => + Http.PostAsJsonAsync($"{BaseUrl}/inventory/kitchen/consumption/{menuItemId}", new { quantitySold }, Json); + + // ----- Releases ----- + + public Task CreateStockReleaseAsync( + (Guid RawMaterialId, decimal Quantity)[] lines, string pin, string? notes = null) => + Http.PostAsJsonAsync( + $"{BaseUrl}/inventory/releases", + new { lines = lines.Select(l => new { rawMaterialId = l.RawMaterialId, quantity = l.Quantity }), pin, notes }, + Json); + + public Task GetStockReleasesAsync() => Http.GetAsync($"{BaseUrl}/inventory/releases"); +} \ No newline at end of file diff --git a/backend/tests/RestaurantPOS.IntegrationTests/Common/PosApiClient.Suppliers.cs b/backend/tests/RestaurantPOS.IntegrationTests/Common/PosApiClient.Suppliers.cs new file mode 100644 index 0000000..8cbb611 --- /dev/null +++ b/backend/tests/RestaurantPOS.IntegrationTests/Common/PosApiClient.Suppliers.cs @@ -0,0 +1,143 @@ +using System.Net.Http.Json; + +namespace RestaurantPOS.IntegrationTests.Common; + +public sealed record SupplierResponse( + Guid Id, + string Name, + string? ContactName, + string? Phone, + string? Email, + string? Address, + int PaymentTermsDays, + decimal? CreditLimit, + int? LeadTimeDays, + bool IsActive); + +public sealed record PurchaseOrderLineResponse(Guid RawMaterialId, string RawMaterialName, decimal Quantity, decimal UnitPrice, decimal LineTotal); + +public sealed record PurchaseOrderResponse( + Guid Id, + Guid SupplierId, + string SupplierName, + string Status, + DateTime CreatedAtUtc, + DateTime? ExpectedDeliveryDate, + DateTime? SubmittedAtUtc, + string? Notes, + decimal TotalAmount, + decimal AmountPaid, + decimal Balance, + IReadOnlyCollection Lines); + +public sealed record SupplierPriceResponse(Guid SupplierId, string SupplierName, Guid RawMaterialId, string RawMaterialName, decimal Price); + +public sealed record SupplierPriceHistoryEntryResponse(decimal Price, string RecordedByName, DateTime RecordedAtUtc); + +public sealed record SupplierPaymentResponse( + Guid Id, Guid PurchaseOrderId, decimal Amount, DateTime PaymentDateUtc, string Method, string? InvoiceReference); + +public sealed record SupplierPerformanceResponse( + Guid SupplierId, + int TotalOrders, + int DeliveredOrders, + int OnTimeDeliveries, + double? OnTimeDeliveryRate, + double? AverageDeliveryDays, + double? AverageQualityRating, + int IssueCount); + +/// Supplier Management calls: suppliers, purchase orders, pricing, payments and performance. +public sealed partial class PosApiClient +{ + public Task CreateSupplierAsync( + string name, + string? contactName = null, + string? phone = null, + string? email = null, + string? address = null, + int paymentTermsDays = 0, + decimal? creditLimit = null, + int? leadTimeDays = null) => + Http.PostAsJsonAsync( + $"{BaseUrl}/suppliers", + new { name, contactName, phone, email, address, paymentTermsDays, creditLimit, leadTimeDays }, + Json); + + public Task UpdateSupplierAsync( + Guid id, string name, int paymentTermsDays = 0, decimal? creditLimit = null, int? leadTimeDays = null) => + Http.PutAsJsonAsync( + $"{BaseUrl}/suppliers/{id}", + new { name, contactName = (string?)null, phone = (string?)null, email = (string?)null, address = (string?)null, paymentTermsDays, creditLimit, leadTimeDays }, + Json); + + public Task SetSupplierActiveAsync(Guid id, bool isActive) => + Http.PutAsJsonAsync($"{BaseUrl}/suppliers/{id}/status", new { isActive }, Json); + + public Task GetSuppliersAsync(string? query = null) => + Http.GetAsync($"{BaseUrl}/suppliers{query}"); + + public Task CreatePurchaseOrderAsync( + Guid supplierId, + (Guid RawMaterialId, decimal Quantity, decimal UnitPrice)[] lines, + DateTime? expectedDeliveryDate = null, + string? notes = null) => + Http.PostAsJsonAsync( + $"{BaseUrl}/purchase-orders", + new + { + supplierId, + lines = lines.Select(l => new { rawMaterialId = l.RawMaterialId, quantity = l.Quantity, unitPrice = l.UnitPrice }), + expectedDeliveryDate, + notes, + }, + Json); + + public Task UpdatePurchaseOrderAsync( + Guid id, (Guid RawMaterialId, decimal Quantity, decimal UnitPrice)[] lines, DateTime? expectedDeliveryDate = null, string? notes = null) => + Http.PutAsJsonAsync( + $"{BaseUrl}/purchase-orders/{id}", + new + { + lines = lines.Select(l => new { rawMaterialId = l.RawMaterialId, quantity = l.Quantity, unitPrice = l.UnitPrice }), + expectedDeliveryDate, + notes, + }, + Json); + + public Task SubmitPurchaseOrderAsync(Guid id) => + Http.PostAsync($"{BaseUrl}/purchase-orders/{id}/submit", null); + + public Task ConfirmPurchaseOrderAsync(Guid id) => + Http.PostAsync($"{BaseUrl}/purchase-orders/{id}/confirm", null); + + public Task CancelPurchaseOrderAsync(Guid id) => + Http.PostAsync($"{BaseUrl}/purchase-orders/{id}/cancel", null); + + public Task GetPurchaseOrdersAsync(string? query = null) => + Http.GetAsync($"{BaseUrl}/purchase-orders{query}"); + + public Task GetPurchaseOrderAsync(Guid id) => Http.GetAsync($"{BaseUrl}/purchase-orders/{id}"); + + public Task SetSupplierPriceAsync(Guid supplierId, Guid rawMaterialId, decimal price) => + Http.PutAsJsonAsync($"{BaseUrl}/suppliers/{supplierId}/prices", new { rawMaterialId, price }, Json); + + public Task GetSupplierPricesAsync(string? query = null) => + Http.GetAsync($"{BaseUrl}/suppliers/prices{query}"); + + public Task GetSupplierPriceHistoryAsync(Guid supplierId, Guid rawMaterialId) => + Http.GetAsync($"{BaseUrl}/suppliers/{supplierId}/prices/{rawMaterialId}/history"); + + public Task RecordSupplierPaymentAsync( + Guid purchaseOrderId, decimal amount, DateTime paymentDateUtc, string method = "Cash", string? invoiceReference = null) => + Http.PostAsJsonAsync( + $"{BaseUrl}/purchase-orders/{purchaseOrderId}/payments", + new { amount, paymentDateUtc, method, invoiceReference, notes = (string?)null }, + Json); + + public Task GetPurchaseOrderPaymentsAsync(Guid purchaseOrderId) => + Http.GetAsync($"{BaseUrl}/purchase-orders/{purchaseOrderId}/payments"); + + public Task GetSupplierPerformanceAsync(Guid supplierId) => + Http.GetAsync($"{BaseUrl}/suppliers/{supplierId}/performance"); +} \ No newline at end of file diff --git a/backend/tests/RestaurantPOS.IntegrationTests/Common/PosApiClient.cs b/backend/tests/RestaurantPOS.IntegrationTests/Common/PosApiClient.cs index 0f6f0a6..0452ce8 100644 --- a/backend/tests/RestaurantPOS.IntegrationTests/Common/PosApiClient.cs +++ b/backend/tests/RestaurantPOS.IntegrationTests/Common/PosApiClient.cs @@ -37,11 +37,11 @@ public sealed record PinResponse(string Pin); /// Thin wrapper over that keeps the tests focused on behaviour rather /// than on URL and JSON plumbing. /// -public sealed class PosApiClient(HttpClient http) +public sealed partial class PosApiClient(HttpClient http) { - private const string BaseUrl = "/api/v1"; + internal const string BaseUrl = "/api/v1"; - private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web) + internal static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web) { Converters = { new JsonStringEnumConverter() }, }; diff --git a/backend/tests/RestaurantPOS.IntegrationTests/Inventory/InventoryTests.cs b/backend/tests/RestaurantPOS.IntegrationTests/Inventory/InventoryTests.cs new file mode 100644 index 0000000..6fcd0b4 --- /dev/null +++ b/backend/tests/RestaurantPOS.IntegrationTests/Inventory/InventoryTests.cs @@ -0,0 +1,319 @@ +using System.Net; + +using FluentAssertions; + +using RestaurantPOS.IntegrationTests.Common; + +using Xunit; + +namespace RestaurantPOS.IntegrationTests.Inventory; + +public class InventoryTests : IntegrationTestBase +{ + [Fact] + public async Task ANewRawMaterial_StartsWithZeroStockInBothStores() + { + await SignInAsAdminAsync(); + var riceId = await CreateRawMaterialAsync("Rice"); + + var mainStore = await ReadStockAsync(await Client.GetMainStoreStockAsync()); + var kitchen = await ReadStockAsync(await Client.GetKitchenStockAsync()); + + mainStore.Should().Contain(s => s.RawMaterialId == riceId && s.QuantityOnHand == 0); + kitchen.Should().Contain(s => s.RawMaterialId == riceId && s.QuantityOnHand == 0); + } + + [Fact] + public async Task RawMaterialNamesAreUniqueRegardlessOfCasing() + { + await SignInAsAdminAsync(); + await Client.CreateRawMaterialAsync("Rice"); + + var duplicate = await Client.CreateRawMaterialAsync("RICE"); + + duplicate.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await PosApiClient.ReadErrorCodeAsync(duplicate)).Should().Be("RawMaterial.NameTaken"); + } + + [Fact] + public async Task ReceivingGoods_IncreasesMainStoreStockImmediately() + { + await SignInAsAdminAsync(); + var riceId = await CreateRawMaterialAsync("Rice"); + var supplierId = await CreateSupplierAsync(); + + var response = await Client.CreateGoodsReceivedNoteAsync(supplierId, [(riceId, 20m)], "Weekly delivery"); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + var stock = await ReadStockAsync(await Client.GetMainStoreStockAsync()); + stock.Should().Contain(s => s.RawMaterialId == riceId && s.QuantityOnHand == 20m); + } + + [Fact] + public async Task ReceivingGoods_RejectsAnInactiveSupplier() + { + await SignInAsAdminAsync(); + var riceId = await CreateRawMaterialAsync("Rice"); + var supplierId = await CreateSupplierAsync(); + await Client.SetSupplierActiveAsync(supplierId, false); + + var response = await Client.CreateGoodsReceivedNoteAsync(supplierId, [(riceId, 5m)]); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + (await PosApiClient.ReadErrorCodeAsync(response)).Should().Be("Supplier.Inactive"); + } + + [Fact] + public async Task LowStockIsFlaggedOnceTheReorderLevelIsReached() + { + await SignInAsAdminAsync(); + var riceId = await CreateRawMaterialAsync("Rice", mainStoreReorderLevel: 10m); + var supplierId = await CreateSupplierAsync(); + + await Client.CreateGoodsReceivedNoteAsync(supplierId, [(riceId, 10m)]); + + var stock = await ReadStockAsync(await Client.GetMainStoreStockAsync()); + stock.Should().Contain(s => s.RawMaterialId == riceId && s.IsLowStock); + } + + [Fact] + public async Task ReleasingStock_RequiresAValidApprovalPin() + { + await SignInAsAdminAsync(); + var riceId = await CreateRawMaterialAsync("Rice"); + var supplierId = await CreateSupplierAsync(); + await Client.CreateGoodsReceivedNoteAsync(supplierId, [(riceId, 20m)]); + await SetApprovalPinAsync(); + + var response = await Client.CreateStockReleaseAsync([(riceId, 5m)], "0000"); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + (await PosApiClient.ReadErrorCodeAsync(response)).Should().Be("Auth.InvalidPin"); + } + + [Fact] + public async Task ReleasingStock_WithNoAdminPinConfiguredAnywhere_FailsClearly() + { + await SignInAsAdminAsync(); + var riceId = await CreateRawMaterialAsync("Rice"); + var supplierId = await CreateSupplierAsync(); + await Client.CreateGoodsReceivedNoteAsync(supplierId, [(riceId, 20m)]); + + var response = await Client.CreateStockReleaseAsync([(riceId, 5m)], "0000"); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await PosApiClient.ReadErrorCodeAsync(response)).Should().Be("Auth.NoAdminPinConfigured"); + } + + [Fact] + public async Task ReleasingStock_MovesItFromMainStoreToKitchen() + { + var admin = await SignInAsAdminAsync(); + var riceId = await CreateRawMaterialAsync("Rice"); + var supplierId = await CreateSupplierAsync(); + await Client.CreateGoodsReceivedNoteAsync(supplierId, [(riceId, 20m)]); + var pin = await SetApprovalPinAsync(); + + var response = await Client.CreateStockReleaseAsync([(riceId, 5m)], pin, "Morning prep"); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + var release = await PosApiClient.ReadAsync(response); + release.ApprovedByName.Should().Be(admin.User.FullName); + release.Lines.Should().ContainSingle(l => l.RawMaterialId == riceId && l.Quantity == 5m); + + var mainStore = await ReadStockAsync(await Client.GetMainStoreStockAsync()); + var kitchen = await ReadStockAsync(await Client.GetKitchenStockAsync()); + mainStore.Should().Contain(s => s.RawMaterialId == riceId && s.QuantityOnHand == 15m); + kitchen.Should().Contain(s => s.RawMaterialId == riceId && s.QuantityOnHand == 5m); + } + + [Fact] + public async Task ReleasingMoreThanIsInMainStock_IsBlockedAndChangesNothing() + { + await SignInAsAdminAsync(); + var riceId = await CreateRawMaterialAsync("Rice"); + var supplierId = await CreateSupplierAsync(); + await Client.CreateGoodsReceivedNoteAsync(supplierId, [(riceId, 5m)]); + var pin = await SetApprovalPinAsync(); + + var response = await Client.CreateStockReleaseAsync([(riceId, 10m)], pin); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await PosApiClient.ReadErrorCodeAsync(response)).Should().Be("Inventory.InsufficientStock"); + + var mainStore = await ReadStockAsync(await Client.GetMainStoreStockAsync()); + mainStore.Should().Contain(s => s.RawMaterialId == riceId && s.QuantityOnHand == 5m, "a rejected release must not partially apply"); + } + + [Fact] + public async Task ReleasingTwoRawMaterialsWhereOnlyOneHasEnoughStock_AppliesNeitherOfThem() + { + await SignInAsAdminAsync(); + var riceId = await CreateRawMaterialAsync("Rice"); + var chickenId = await CreateRawMaterialAsync("Chicken"); + var supplierId = await CreateSupplierAsync(); + await Client.CreateGoodsReceivedNoteAsync(supplierId, [(riceId, 20m), (chickenId, 2m)]); + var pin = await SetApprovalPinAsync(); + + // Rice has plenty; chicken does not — the whole release must fail atomically. + var response = await Client.CreateStockReleaseAsync([(riceId, 5m), (chickenId, 10m)], pin); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + + var mainStore = await ReadStockAsync(await Client.GetMainStoreStockAsync()); + mainStore.Should().Contain(s => s.RawMaterialId == riceId && s.QuantityOnHand == 20m); + mainStore.Should().Contain(s => s.RawMaterialId == chickenId && s.QuantityOnHand == 2m); + } + + [Fact] + public async Task ConsumingStock_DeductsKitchenStockAccordingToTheRecipe() + { + await SignInAsAdminAsync(); + var riceId = await CreateRawMaterialAsync("Rice"); + var chickenId = await CreateRawMaterialAsync("Chicken"); + var supplierId = await CreateSupplierAsync(); + await Client.CreateGoodsReceivedNoteAsync(supplierId, [(riceId, 20m), (chickenId, 10m)]); + var pin = await SetApprovalPinAsync(); + await Client.CreateStockReleaseAsync([(riceId, 5m), (chickenId, 3m)], pin); + + var itemId = (await PosApiClient.ReadAsync( + await Client.CreateMenuItemAsync("Chicken Fried Rice", "Rice & Curry", 850m))).Id; + await Client.UpsertRecipeAsync(itemId, (riceId, 0.25m), (chickenId, 0.15m)); + + var response = await Client.ConsumeStockAsync(itemId, 2); + + var result = await PosApiClient.ReadAsync(response); + result.Deducted.Should().BeTrue(); + result.Lines.Should().Contain(l => l.RawMaterialId == riceId && l.QuantityDeducted == 0.5m); + result.Lines.Should().Contain(l => l.RawMaterialId == chickenId && l.QuantityDeducted == 0.3m); + + var kitchen = await ReadStockAsync(await Client.GetKitchenStockAsync()); + kitchen.Should().Contain(s => s.RawMaterialId == riceId && s.QuantityOnHand == 4.5m); + kitchen.Should().Contain(s => s.RawMaterialId == chickenId && s.QuantityOnHand == 2.7m); + } + + [Fact] + public async Task ConsumingStock_ForAMenuItemWithNoRecipe_SucceedsAsANoOp() + { + await SignInAsAdminAsync(); + var itemId = (await PosApiClient.ReadAsync( + await Client.CreateMenuItemAsync("Bottled Water", "Beverages", 100m))).Id; + + var response = await Client.ConsumeStockAsync(itemId, 5); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + (await PosApiClient.ReadAsync(response)).Deducted.Should().BeFalse(); + } + + [Fact] + public async Task ConsumingStock_ForADisabledRecipe_SucceedsAsANoOpRatherThanFailing() + { + await SignInAsAdminAsync(); + var riceId = await CreateRawMaterialAsync("Rice"); + var itemId = (await PosApiClient.ReadAsync( + await Client.CreateMenuItemAsync("Fried Rice", "Rice", 700m))).Id; + await Client.UpsertRecipeAsync(itemId, (riceId, 0.25m)); + await Client.SetRecipeEnabledAsync(itemId, false); + + var response = await Client.ConsumeStockAsync(itemId, 1); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + (await PosApiClient.ReadAsync(response)).Deducted.Should().BeFalse(); + } + + [Fact] + public async Task ConsumingMoreThanKitchenHasInStock_IsBlocked() + { + await SignInAsAdminAsync(); + var riceId = await CreateRawMaterialAsync("Rice"); + var itemId = (await PosApiClient.ReadAsync( + await Client.CreateMenuItemAsync("Fried Rice", "Rice", 700m))).Id; + await Client.UpsertRecipeAsync(itemId, (riceId, 1m)); + // No stock has been released to the kitchen at all. + + var response = await Client.ConsumeStockAsync(itemId, 1); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await PosApiClient.ReadErrorCodeAsync(response)).Should().Be("Inventory.InsufficientStock"); + } + + [Fact] + public async Task AnAdjustmentRequiresAReason() + { + await SignInAsAdminAsync(); + var riceId = await CreateRawMaterialAsync("Rice"); + + var response = await Client.CreateMainStoreAdjustmentAsync(riceId, 5m, ""); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task AnAdjustmentCorrectsTheBalanceAndIsRecordedInHistory() + { + await SignInAsAdminAsync(); + var riceId = await CreateRawMaterialAsync("Rice"); + var supplierId = await CreateSupplierAsync(); + await Client.CreateGoodsReceivedNoteAsync(supplierId, [(riceId, 20m)]); + + var response = await Client.CreateMainStoreAdjustmentAsync(riceId, -2m, "Spillage during counting"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var stock = await ReadStockAsync(await Client.GetMainStoreStockAsync()); + stock.Should().Contain(s => s.RawMaterialId == riceId && s.QuantityOnHand == 18m); + + var history = await ReadMovementsAsync(await Client.GetMainStoreMovementsAsync()); + history.Should().Contain(m => m.Type == "Adjustment" && m.Notes == "Spillage during counting"); + } + + [Fact] + public async Task DeactivatingARawMaterial_BlocksItFromANewGoodsReceivedNote() + { + await SignInAsAdminAsync(); + var riceId = await CreateRawMaterialAsync("Rice"); + var supplierId = await CreateSupplierAsync(); + await Client.SetRawMaterialActiveAsync(riceId, false); + + var response = await Client.CreateGoodsReceivedNoteAsync(supplierId, [(riceId, 5m)]); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + (await PosApiClient.ReadErrorCodeAsync(response)).Should().Be("RawMaterial.Inactive"); + } + + [Fact] + public async Task StaffGrantedOnlyKitchenTracking_CannotReceiveGoodsIntoMainStore() + { + await SignInAsAdminAsync(); + var (staff, _) = await CreateAndSignInStaffAsync(modules: "KitchenStockTracking"); + + (await staff.GetMainStoreStockAsync()).StatusCode.Should().Be(HttpStatusCode.Forbidden); + (await staff.GetKitchenStockAsync()).StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task StaffGrantedOnlyStoreStockManagement_CannotCreateAStockRelease() + { + await SignInAsAdminAsync(); + var (staff, _) = await CreateAndSignInStaffAsync(modules: "StoreStockManagement"); + + var response = await staff.CreateStockReleaseAsync([(Guid.NewGuid(), 1m)], "0000"); + + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } + + private async Task CreateRawMaterialAsync(string name, string unit = "Kilogram", decimal? mainStoreReorderLevel = null) => + (await PosApiClient.ReadAsync( + await Client.CreateRawMaterialAsync(name, unit, mainStoreReorderLevel))).Id; + + private async Task CreateSupplierAsync(string name = "ABC Wholesale") => + (await PosApiClient.ReadAsync(await Client.CreateSupplierAsync(name))).Id; + + private async Task SetApprovalPinAsync() => + (await PosApiClient.ReadAsync(await Client.SetApprovalPinAsync(AdminPassword, "4821"))).Pin; + + private static Task> ReadStockAsync(HttpResponseMessage response) => + PosApiClient.ReadAsync>(response); + + private static Task> ReadMovementsAsync(HttpResponseMessage response) => + PosApiClient.ReadAsync>(response); +} \ No newline at end of file diff --git a/backend/tests/RestaurantPOS.IntegrationTests/Recipes/RecipeManagementTests.cs b/backend/tests/RestaurantPOS.IntegrationTests/Recipes/RecipeManagementTests.cs new file mode 100644 index 0000000..d23affb --- /dev/null +++ b/backend/tests/RestaurantPOS.IntegrationTests/Recipes/RecipeManagementTests.cs @@ -0,0 +1,203 @@ +using System.Net; + +using FluentAssertions; + +using RestaurantPOS.IntegrationTests.Common; + +using Xunit; + +namespace RestaurantPOS.IntegrationTests.Recipes; + +public class RecipeManagementTests : IntegrationTestBase +{ + [Fact] + public async Task CreatingAMenuItem_StartsWithNoRecipe() + { + await SignInAsAdminAsync(); + + var response = await Client.CreateMenuItemAsync("Chicken Fried Rice", "Rice & Curry", 850m); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + var item = await PosApiClient.ReadAsync(response); + item.HasRecipe.Should().BeFalse(); + item.IsActive.Should().BeTrue(); + } + + [Fact] + public async Task MenuItemNamesAreUniqueRegardlessOfCasing() + { + await SignInAsAdminAsync(); + await Client.CreateMenuItemAsync("Chicken Fried Rice", "Rice & Curry", 850m); + + var duplicate = await Client.CreateMenuItemAsync("CHICKEN FRIED RICE", "Rice & Curry", 900m); + + duplicate.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await PosApiClient.ReadErrorCodeAsync(duplicate)).Should().Be("MenuItem.NameTaken"); + } + + [Fact] + public async Task ANegativePriceIsRejected() + { + await SignInAsAdminAsync(); + + var response = await Client.CreateMenuItemAsync("Fried Rice", "Rice", -1m); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task CreatingARecipe_RequiresAtLeastOneLine() + { + await SignInAsAdminAsync(); + var itemId = await CreateMenuItemAsync(); + + var response = await Client.UpsertRecipeAsync(itemId); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task CreatingARecipe_RejectsAZeroQuantity() + { + await SignInAsAdminAsync(); + var itemId = await CreateMenuItemAsync(); + var riceId = await CreateRawMaterialAsync("Rice"); + + var response = await Client.UpsertRecipeAsync(itemId, (riceId, 0m)); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task CreatingARecipe_RejectsTheSameRawMaterialTwice() + { + await SignInAsAdminAsync(); + var itemId = await CreateMenuItemAsync(); + var riceId = await CreateRawMaterialAsync("Rice"); + + var response = await Client.UpsertRecipeAsync(itemId, (riceId, 1m), (riceId, 2m)); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task CreatingARecipe_RejectsAnUnknownRawMaterial() + { + await SignInAsAdminAsync(); + var itemId = await CreateMenuItemAsync(); + + var response = await Client.UpsertRecipeAsync(itemId, (Guid.NewGuid(), 1m)); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + (await PosApiClient.ReadErrorCodeAsync(response)).Should().Be("Recipe.UnknownRawMaterial"); + } + + [Fact] + public async Task CreatingARecipe_RejectsAnInactiveRawMaterial() + { + await SignInAsAdminAsync(); + var itemId = await CreateMenuItemAsync(); + var riceId = await CreateRawMaterialAsync("Rice"); + await Client.SetRawMaterialActiveAsync(riceId, false); + + var response = await Client.UpsertRecipeAsync(itemId, (riceId, 1m)); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + (await PosApiClient.ReadErrorCodeAsync(response)).Should().Be("Recipe.InactiveRawMaterial"); + } + + [Fact] + public async Task UpsertingASecondTime_ReplacesTheLinesRatherThanMergingThem() + { + await SignInAsAdminAsync(); + var itemId = await CreateMenuItemAsync(); + var riceId = await CreateRawMaterialAsync("Rice"); + var chickenId = await CreateRawMaterialAsync("Chicken"); + var oilId = await CreateRawMaterialAsync("Cooking Oil", "Liter"); + + await Client.UpsertRecipeAsync(itemId, (riceId, 0.25m), (chickenId, 0.15m)); + var replaced = await Client.UpsertRecipeAsync(itemId, (riceId, 0.3m), (oilId, 0.05m)); + + var recipe = await PosApiClient.ReadAsync(replaced); + recipe.Lines.Should().HaveCount(2); + recipe.Lines.Should().Contain(l => l.RawMaterialId == riceId && l.Quantity == 0.3m); + recipe.Lines.Should().Contain(l => l.RawMaterialId == oilId); + recipe.Lines.Should().NotContain(l => l.RawMaterialId == chickenId); + + // The replacement must be durable, not just reflected in the handler's in-memory response. + var reFetched = await PosApiClient.ReadAsync(await Client.GetRecipeAsync(itemId)); + reFetched.Lines.Should().HaveCount(2); + reFetched.Lines.Should().NotContain(l => l.RawMaterialId == chickenId); + } + + [Fact] + public async Task ANewMenuItem_ReportsHasRecipeOnceOneExists() + { + await SignInAsAdminAsync(); + var itemId = await CreateMenuItemAsync(); + var riceId = await CreateRawMaterialAsync("Rice"); + + await Client.UpsertRecipeAsync(itemId, (riceId, 0.25m)); + + var item = await PosApiClient.ReadAsync(await Client.GetMenuItemAsync(itemId)); + item.HasRecipe.Should().BeTrue(); + } + + [Fact] + public async Task DisablingARecipe_KeepsItsLinesVisible() + { + await SignInAsAdminAsync(); + var itemId = await CreateMenuItemAsync(); + var riceId = await CreateRawMaterialAsync("Rice"); + await Client.UpsertRecipeAsync(itemId, (riceId, 0.25m)); + + var disabled = await PosApiClient.ReadAsync( + await Client.SetRecipeEnabledAsync(itemId, false)); + + disabled.IsEnabled.Should().BeFalse(); + disabled.Lines.Should().ContainSingle(); + } + + [Fact] + public async Task DeletingARecipe_AllowsANewOneToBeCreatedAfterwards() + { + await SignInAsAdminAsync(); + var itemId = await CreateMenuItemAsync(); + var riceId = await CreateRawMaterialAsync("Rice"); + await Client.UpsertRecipeAsync(itemId, (riceId, 0.25m)); + + (await Client.DeleteRecipeAsync(itemId)).StatusCode.Should().Be(HttpStatusCode.NoContent); + + var afterDelete = await Client.GetRecipeAsync(itemId); + afterDelete.StatusCode.Should().Be(HttpStatusCode.OK); + (await afterDelete.Content.ReadAsStringAsync()).Should().BeEmpty("no recipe exists for this menu item any more"); + + var recreated = await Client.UpsertRecipeAsync(itemId, (riceId, 0.5m)); + recreated.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task StaffWithoutTheModuleCannotReachMenuItems() + { + await SignInAsAdminAsync(); + var (staff, _) = await CreateAndSignInStaffAsync(modules: "StoreStockManagement"); + + (await staff.GetMenuItemsAsync()).StatusCode.Should().Be(HttpStatusCode.Forbidden); + } + + [Fact] + public async Task StaffGrantedRecipeManagement_CanManageMenuItems() + { + await SignInAsAdminAsync(); + var (staff, _) = await CreateAndSignInStaffAsync(modules: "RecipeManagement"); + + (await staff.CreateMenuItemAsync("Fried Rice", "Rice", 700m)) + .StatusCode.Should().Be(HttpStatusCode.Created); + } + + private async Task CreateMenuItemAsync(string name = "Chicken Fried Rice") => + (await PosApiClient.ReadAsync(await Client.CreateMenuItemAsync(name, "Rice & Curry", 850m))).Id; + + private async Task CreateRawMaterialAsync(string name, string unit = "Kilogram") => + (await PosApiClient.ReadAsync(await Client.CreateRawMaterialAsync(name, unit))).Id; +} \ No newline at end of file diff --git a/backend/tests/RestaurantPOS.IntegrationTests/Suppliers/SupplierManagementTests.cs b/backend/tests/RestaurantPOS.IntegrationTests/Suppliers/SupplierManagementTests.cs new file mode 100644 index 0000000..072d309 --- /dev/null +++ b/backend/tests/RestaurantPOS.IntegrationTests/Suppliers/SupplierManagementTests.cs @@ -0,0 +1,413 @@ +using System.Net; + +using FluentAssertions; + +using RestaurantPOS.IntegrationTests.Common; + +using Xunit; + +namespace RestaurantPOS.IntegrationTests.Suppliers; + +public class SupplierManagementTests : IntegrationTestBase +{ + [Fact] + public async Task CreatingASupplier_StoresContactDetailsAndTerms() + { + await SignInAsAdminAsync(); + + var response = await Client.CreateSupplierAsync( + "ABC Wholesale", "Mr. Perera", "0771234567", "ABC@Wholesale.LK", "123 Galle Rd", + paymentTermsDays: 30, creditLimit: 100000m, leadTimeDays: 3); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + var supplier = await PosApiClient.ReadAsync(response); + supplier.Email.Should().Be("abc@wholesale.lk"); + supplier.PaymentTermsDays.Should().Be(30); + supplier.CreditLimit.Should().Be(100000m); + supplier.IsActive.Should().BeTrue(); + } + + [Fact] + public async Task SupplierNamesAreUniqueRegardlessOfCasing() + { + await SignInAsAdminAsync(); + await Client.CreateSupplierAsync("ABC Wholesale"); + + var duplicate = await Client.CreateSupplierAsync("ABC WHOLESALE"); + + duplicate.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await PosApiClient.ReadErrorCodeAsync(duplicate)).Should().Be("Supplier.NameTaken"); + } + + [Fact] + public async Task StaffWithOnlyStoreStockManagement_CanReadSuppliersForTheGrnDropdownButNotManageThem() + { + await SignInAsAdminAsync(); + var (staff, _) = await CreateAndSignInStaffAsync(modules: "StoreStockManagement"); + + // Reading the list is needed to pick a supplier when recording a GRN, so it is allowed... + (await staff.GetSuppliersAsync()).StatusCode.Should().Be(HttpStatusCode.OK); + + // ...but actually managing suppliers, pricing or purchase orders stays SupplierManagement-only. + (await staff.CreateSupplierAsync("Sneaky Co")).StatusCode.Should().Be(HttpStatusCode.Forbidden); + (await staff.GetPurchaseOrdersAsync()).StatusCode.Should().Be(HttpStatusCode.Forbidden); + } + + [Fact] + public async Task StaffGrantedSupplierManagement_CanManageSuppliers() + { + await SignInAsAdminAsync(); + var (staff, _) = await CreateAndSignInStaffAsync(modules: "SupplierManagement"); + + (await staff.CreateSupplierAsync("ABC Wholesale")).StatusCode.Should().Be(HttpStatusCode.Created); + } + + // ----- Purchase order lifecycle ----- + + [Fact] + public async Task CreatingAPurchaseOrder_StartsAsDraftWithComputedTotal() + { + await SignInAsAdminAsync(); + var (supplierId, riceId, chickenId) = await SeedSupplierAndMaterialsAsync(); + + var response = await Client.CreatePurchaseOrderAsync( + supplierId, [(riceId, 50m, 210m), (chickenId, 20m, 950m)]); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + var order = await PosApiClient.ReadAsync(response); + order.Status.Should().Be("Draft"); + order.TotalAmount.Should().Be(50m * 210m + 20m * 950m); + order.Balance.Should().Be(order.TotalAmount); + } + + [Fact] + public async Task APurchaseOrder_RequiresAtLeastOneLine() + { + await SignInAsAdminAsync(); + var (supplierId, _, _) = await SeedSupplierAndMaterialsAsync(); + + var response = await Client.CreatePurchaseOrderAsync(supplierId, []); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task UpdatingADraftOrder_ReplacesItsLines() + { + await SignInAsAdminAsync(); + var (supplierId, riceId, chickenId) = await SeedSupplierAndMaterialsAsync(); + var order = await PosApiClient.ReadAsync( + await Client.CreatePurchaseOrderAsync(supplierId, [(riceId, 10m, 200m)])); + + var updated = await PosApiClient.ReadAsync( + await Client.UpdatePurchaseOrderAsync(order.Id, [(chickenId, 5m, 900m)])); + + updated.Lines.Should().ContainSingle(l => l.RawMaterialId == chickenId); + updated.TotalAmount.Should().Be(5m * 900m); + } + + [Fact] + public async Task OnceSubmitted_TheOrderCanNoLongerBeEdited() + { + await SignInAsAdminAsync(); + var (supplierId, riceId, _) = await SeedSupplierAndMaterialsAsync(); + var order = await PosApiClient.ReadAsync( + await Client.CreatePurchaseOrderAsync(supplierId, [(riceId, 10m, 200m)])); + + (await Client.SubmitPurchaseOrderAsync(order.Id)).StatusCode.Should().Be(HttpStatusCode.OK); + + var editAttempt = await Client.UpdatePurchaseOrderAsync(order.Id, [(riceId, 99m, 1m)]); + editAttempt.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await PosApiClient.ReadErrorCodeAsync(editAttempt)).Should().Be("PurchaseOrder.NotDraft"); + } + + [Fact] + public async Task ConfirmingBeforeSubmitting_IsRejected() + { + await SignInAsAdminAsync(); + var (supplierId, riceId, _) = await SeedSupplierAndMaterialsAsync(); + var order = await PosApiClient.ReadAsync( + await Client.CreatePurchaseOrderAsync(supplierId, [(riceId, 10m, 200m)])); + + var response = await Client.ConfirmPurchaseOrderAsync(order.Id); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await PosApiClient.ReadErrorCodeAsync(response)).Should().Be("PurchaseOrder.NotConfirmable"); + } + + [Fact] + public async Task FullLifecycle_DraftToSubmittedToConfirmed() + { + await SignInAsAdminAsync(); + var (supplierId, riceId, _) = await SeedSupplierAndMaterialsAsync(); + var order = await PosApiClient.ReadAsync( + await Client.CreatePurchaseOrderAsync(supplierId, [(riceId, 10m, 200m)])); + + await Client.SubmitPurchaseOrderAsync(order.Id); + var confirmed = await PosApiClient.ReadAsync( + await Client.ConfirmPurchaseOrderAsync(order.Id)); + + confirmed.Status.Should().Be("Confirmed"); + } + + [Fact] + public async Task CancellingADraftOrder_Succeeds() + { + await SignInAsAdminAsync(); + var (supplierId, riceId, _) = await SeedSupplierAndMaterialsAsync(); + var order = await PosApiClient.ReadAsync( + await Client.CreatePurchaseOrderAsync(supplierId, [(riceId, 10m, 200m)])); + + var cancelled = await PosApiClient.ReadAsync( + await Client.CancelPurchaseOrderAsync(order.Id)); + + cancelled.Status.Should().Be("Cancelled"); + } + + [Fact] + public async Task CancellingAnAlreadyDeliveredOrder_IsRejected() + { + await SignInAsAdminAsync(); + var (supplierId, riceId, _) = await SeedSupplierAndMaterialsAsync(); + var orderId = await CreateSubmittedConfirmedOrderAsync(supplierId, riceId); + await Client.CreateGoodsReceivedNoteAsync(supplierId, [(riceId, 10m)], purchaseOrderId: orderId); + + var response = await Client.CancelPurchaseOrderAsync(orderId); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await PosApiClient.ReadErrorCodeAsync(response)).Should().Be("PurchaseOrder.NotCancellable"); + } + + // ----- GRN <-> PO reconciliation ----- + + [Fact] + public async Task RecordingAGrnAgainstAConfirmedOrder_MarksItDelivered() + { + await SignInAsAdminAsync(); + var (supplierId, riceId, _) = await SeedSupplierAndMaterialsAsync(); + var orderId = await CreateSubmittedConfirmedOrderAsync(supplierId, riceId); + + var grnResponse = await Client.CreateGoodsReceivedNoteAsync( + supplierId, [(riceId, 10m)], purchaseOrderId: orderId, qualityRating: 4); + + var grn = await PosApiClient.ReadAsync(grnResponse); + grn.PurchaseOrderId.Should().Be(orderId); + grn.QualityRating.Should().Be(4); + + var order = await PosApiClient.ReadAsync(await Client.GetPurchaseOrderAsync(orderId)); + order.Status.Should().Be("Delivered"); + } + + [Fact] + public async Task AGrn_CanStandAloneWithoutAPurchaseOrder() + { + await SignInAsAdminAsync(); + var (supplierId, riceId, _) = await SeedSupplierAndMaterialsAsync(); + + var response = await Client.CreateGoodsReceivedNoteAsync(supplierId, [(riceId, 10m)]); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + (await PosApiClient.ReadAsync(response)).PurchaseOrderId.Should().BeNull(); + } + + [Fact] + public async Task AGrnAgainstACancelledOrder_IsRejected() + { + await SignInAsAdminAsync(); + var (supplierId, riceId, _) = await SeedSupplierAndMaterialsAsync(); + var order = await PosApiClient.ReadAsync( + await Client.CreatePurchaseOrderAsync(supplierId, [(riceId, 10m, 200m)])); + await Client.CancelPurchaseOrderAsync(order.Id); + + var response = await Client.CreateGoodsReceivedNoteAsync(supplierId, [(riceId, 10m)], purchaseOrderId: order.Id); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await PosApiClient.ReadErrorCodeAsync(response)).Should().Be("GoodsReceivedNote.PurchaseOrderCancelled"); + } + + [Fact] + public async Task AGrnAgainstAnotherSuppliersOrder_IsRejected() + { + await SignInAsAdminAsync(); + var (supplierId, riceId, _) = await SeedSupplierAndMaterialsAsync(); + var otherSupplierId = (await PosApiClient.ReadAsync( + await Client.CreateSupplierAsync("XYZ Traders"))).Id; + var order = await PosApiClient.ReadAsync( + await Client.CreatePurchaseOrderAsync(supplierId, [(riceId, 10m, 200m)])); + + var response = await Client.CreateGoodsReceivedNoteAsync( + otherSupplierId, [(riceId, 10m)], purchaseOrderId: order.Id); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + (await PosApiClient.ReadErrorCodeAsync(response)).Should().Be("GoodsReceivedNote.SupplierMismatch"); + } + + // ----- Payments ----- + + [Fact] + public async Task RecordingAPayment_ReducesTheOutstandingBalance() + { + await SignInAsAdminAsync(); + var (supplierId, riceId, _) = await SeedSupplierAndMaterialsAsync(); + var order = await PosApiClient.ReadAsync( + await Client.CreatePurchaseOrderAsync(supplierId, [(riceId, 10m, 1000m)])); + + var response = await Client.RecordSupplierPaymentAsync(order.Id, 4000m, DateTime.UtcNow, "BankTransfer", "INV-001"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var refreshed = await PosApiClient.ReadAsync(await Client.GetPurchaseOrderAsync(order.Id)); + refreshed.AmountPaid.Should().Be(4000m); + refreshed.Balance.Should().Be(6000m); + } + + [Fact] + public async Task APaymentExceedingTheOutstandingBalance_IsRejected() + { + await SignInAsAdminAsync(); + var (supplierId, riceId, _) = await SeedSupplierAndMaterialsAsync(); + var order = await PosApiClient.ReadAsync( + await Client.CreatePurchaseOrderAsync(supplierId, [(riceId, 10m, 1000m)])); + + var response = await Client.RecordSupplierPaymentAsync(order.Id, 999999m, DateTime.UtcNow); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + (await PosApiClient.ReadErrorCodeAsync(response)).Should().Be("SupplierPayment.ExceedsBalance"); + } + + [Fact] + public async Task PaymentsListedForAnOrder_ReflectWhatWasRecorded() + { + await SignInAsAdminAsync(); + var (supplierId, riceId, _) = await SeedSupplierAndMaterialsAsync(); + var order = await PosApiClient.ReadAsync( + await Client.CreatePurchaseOrderAsync(supplierId, [(riceId, 10m, 1000m)])); + await Client.RecordSupplierPaymentAsync(order.Id, 3000m, DateTime.UtcNow, "Cash"); + await Client.RecordSupplierPaymentAsync(order.Id, 2000m, DateTime.UtcNow, "Cheque"); + + var payments = await PosApiClient.ReadAsync>( + await Client.GetPurchaseOrderPaymentsAsync(order.Id)); + + payments.Should().HaveCount(2); + payments.Sum(p => p.Amount).Should().Be(5000m); + } + + // ----- Pricing ----- + + [Fact] + public async Task SettingAPrice_CanBeUpdatedAndKeepsHistory() + { + await SignInAsAdminAsync(); + var (supplierId, riceId, _) = await SeedSupplierAndMaterialsAsync(); + + await Client.SetSupplierPriceAsync(supplierId, riceId, 210m); + var updated = await PosApiClient.ReadAsync( + await Client.SetSupplierPriceAsync(supplierId, riceId, 215m)); + + updated.Price.Should().Be(215m); + + var history = await PosApiClient.ReadAsync>( + await Client.GetSupplierPriceHistoryAsync(supplierId, riceId)); + + history.Should().HaveCount(2); + history.Select(h => h.Price).Should().Contain([210m, 215m]); + } + + [Fact] + public async Task PricesForARawMaterial_CanBeComparedAcrossSuppliers() + { + await SignInAsAdminAsync(); + var (supplierId, riceId, _) = await SeedSupplierAndMaterialsAsync(); + var otherSupplierId = (await PosApiClient.ReadAsync( + await Client.CreateSupplierAsync("XYZ Traders"))).Id; + + await Client.SetSupplierPriceAsync(supplierId, riceId, 215m); + await Client.SetSupplierPriceAsync(otherSupplierId, riceId, 205m); + + var prices = await PosApiClient.ReadAsync>( + await Client.GetSupplierPricesAsync($"?rawMaterialId={riceId}")); + + prices.Should().HaveCount(2); + prices.Should().Contain(p => p.SupplierId == supplierId && p.Price == 215m); + prices.Should().Contain(p => p.SupplierId == otherSupplierId && p.Price == 205m); + } + + // ----- Performance ----- + + [Fact] + public async Task Performance_ReflectsOnTimeDeliveryAndQualityRatings() + { + await SignInAsAdminAsync(); + var (supplierId, riceId, _) = await SeedSupplierAndMaterialsAsync(); + + var order = await PosApiClient.ReadAsync( + await Client.CreatePurchaseOrderAsync( + supplierId, [(riceId, 10m, 200m)], expectedDeliveryDate: DateTime.UtcNow.AddDays(5))); + await Client.SubmitPurchaseOrderAsync(order.Id); + await Client.ConfirmPurchaseOrderAsync(order.Id); + await Client.CreateGoodsReceivedNoteAsync( + supplierId, [(riceId, 10m)], purchaseOrderId: order.Id, qualityRating: 5); + + var performance = await PosApiClient.ReadAsync( + await Client.GetSupplierPerformanceAsync(supplierId)); + + performance.TotalOrders.Should().Be(1); + performance.DeliveredOrders.Should().Be(1); + performance.OnTimeDeliveries.Should().Be(1); + performance.OnTimeDeliveryRate.Should().Be(100); + performance.AverageQualityRating.Should().Be(5); + performance.IssueCount.Should().Be(0); + } + + [Fact] + public async Task Performance_CountsFlaggedIssues() + { + await SignInAsAdminAsync(); + var (supplierId, riceId, _) = await SeedSupplierAndMaterialsAsync(); + await Client.CreateGoodsReceivedNoteAsync(supplierId, [(riceId, 10m)], hasIssue: true); + await Client.CreateGoodsReceivedNoteAsync(supplierId, [(riceId, 5m)], hasIssue: false); + + var performance = await PosApiClient.ReadAsync( + await Client.GetSupplierPerformanceAsync(supplierId)); + + performance.IssueCount.Should().Be(1); + } + + [Fact] + public async Task Performance_ForASupplierWithNoOrders_ReportsNullsRatherThanErrors() + { + await SignInAsAdminAsync(); + var supplierId = (await PosApiClient.ReadAsync( + await Client.CreateSupplierAsync("Brand New Supplier"))).Id; + + var performance = await PosApiClient.ReadAsync( + await Client.GetSupplierPerformanceAsync(supplierId)); + + performance.TotalOrders.Should().Be(0); + performance.OnTimeDeliveryRate.Should().BeNull(); + performance.AverageQualityRating.Should().BeNull(); + } + + // ----- Helpers ----- + + private async Task<(Guid SupplierId, Guid RiceId, Guid ChickenId)> SeedSupplierAndMaterialsAsync() + { + var supplierId = (await PosApiClient.ReadAsync( + await Client.CreateSupplierAsync("ABC Wholesale"))).Id; + var riceId = (await PosApiClient.ReadAsync( + await Client.CreateRawMaterialAsync("Rice"))).Id; + var chickenId = (await PosApiClient.ReadAsync( + await Client.CreateRawMaterialAsync("Chicken"))).Id; + + return (supplierId, riceId, chickenId); + } + + private async Task CreateSubmittedConfirmedOrderAsync(Guid supplierId, Guid rawMaterialId) + { + var order = await PosApiClient.ReadAsync( + await Client.CreatePurchaseOrderAsync(supplierId, [(rawMaterialId, 10m, 200m)])); + await Client.SubmitPurchaseOrderAsync(order.Id); + await Client.ConfirmPurchaseOrderAsync(order.Id); + + return order.Id; + } +} \ No newline at end of file diff --git a/backend/tests/RestaurantPOS.UnitTests/Domain/MenuItemTests.cs b/backend/tests/RestaurantPOS.UnitTests/Domain/MenuItemTests.cs new file mode 100644 index 0000000..d2a9bf3 --- /dev/null +++ b/backend/tests/RestaurantPOS.UnitTests/Domain/MenuItemTests.cs @@ -0,0 +1,58 @@ +using FluentAssertions; + +using RestaurantPOS.Domain.Entities; + +using Xunit; + +namespace RestaurantPOS.UnitTests.Domain; + +public class MenuItemTests +{ + [Fact] + public void Create_StartsActive() + { + var item = MenuItem.Create("Chicken Fried Rice", "Rice & Curry", 850m); + + item.IsActive.Should().BeTrue(); + } + + [Fact] + public void Create_RejectsANegativePrice() + { + var act = () => MenuItem.Create("Water", "Beverages", -1m); + + act.Should().Throw(); + } + + [Fact] + public void Create_AllowsAZeroPrice() + { + var item = MenuItem.Create("Complimentary Water", "Beverages", 0m); + + item.Price.Should().Be(0m); + } + + [Fact] + public void UpdateDetails_ChangesNameCategoryAndPrice() + { + var item = MenuItem.Create("Fried Rice", "Rice", 800m); + + item.UpdateDetails("Chicken Fried Rice", "Rice & Curry", 850m); + + item.Name.Should().Be("Chicken Fried Rice"); + item.Category.Should().Be("Rice & Curry"); + item.Price.Should().Be(850m); + } + + [Fact] + public void ActivateAndDeactivate_ToggleIsActive() + { + var item = MenuItem.Create("Fried Rice", "Rice", 800m); + + item.Deactivate(); + item.IsActive.Should().BeFalse(); + + item.Activate(); + item.IsActive.Should().BeTrue(); + } +} \ No newline at end of file diff --git a/backend/tests/RestaurantPOS.UnitTests/Domain/PurchaseOrderTests.cs b/backend/tests/RestaurantPOS.UnitTests/Domain/PurchaseOrderTests.cs new file mode 100644 index 0000000..9ce6331 --- /dev/null +++ b/backend/tests/RestaurantPOS.UnitTests/Domain/PurchaseOrderTests.cs @@ -0,0 +1,198 @@ +using FluentAssertions; + +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Enums; + +using Xunit; + +namespace RestaurantPOS.UnitTests.Domain; + +public class PurchaseOrderTests +{ + private static readonly Guid SupplierId = Guid.NewGuid(); + private static readonly Guid CreatedByUserId = Guid.NewGuid(); + private static readonly Guid RawMaterialA = Guid.NewGuid(); + private static readonly Guid RawMaterialB = Guid.NewGuid(); + + private static PurchaseOrder NewOrder(params (Guid, decimal, decimal)[] lines) => + PurchaseOrder.Create(SupplierId, CreatedByUserId, lines.Length == 0 ? [(RawMaterialA, 10m, 200m)] : lines); + + [Fact] + public void Create_StartsAsDraft() + { + var order = NewOrder(); + + order.Status.Should().Be(PurchaseOrderStatus.Draft); + order.SubmittedAtUtc.Should().BeNull(); + } + + [Fact] + public void Create_RequiresAtLeastOneLine() + { + var act = () => PurchaseOrder.Create(SupplierId, CreatedByUserId, []); + + act.Should().Throw(); + } + + [Fact] + public void Create_RejectsTheSameRawMaterialTwice() + { + var act = () => PurchaseOrder.Create( + SupplierId, CreatedByUserId, [(RawMaterialA, 1m, 100m), (RawMaterialA, 2m, 100m)]); + + act.Should().Throw(); + } + + [Fact] + public void Create_RejectsAZeroQuantity() + { + var act = () => PurchaseOrder.Create(SupplierId, CreatedByUserId, [(RawMaterialA, 0m, 100m)]); + + act.Should().Throw(); + } + + [Fact] + public void Create_RejectsANegativeUnitPrice() + { + var act = () => PurchaseOrder.Create(SupplierId, CreatedByUserId, [(RawMaterialA, 1m, -1m)]); + + act.Should().Throw(); + } + + [Fact] + public void TotalAmount_SumsQuantityTimesUnitPriceAcrossLines() + { + var order = NewOrder((RawMaterialA, 10m, 200m), (RawMaterialB, 5m, 900m)); + + order.TotalAmount.Should().Be(10m * 200m + 5m * 900m); + } + + [Fact] + public void Submit_SetsSubmittedAtAndMovesToSubmitted() + { + var order = NewOrder(); + var now = DateTime.UtcNow; + + order.Submit(now); + + order.Status.Should().Be(PurchaseOrderStatus.Submitted); + order.SubmittedAtUtc.Should().Be(now); + } + + [Fact] + public void Submit_FailsWhenNotADraft() + { + var order = NewOrder(); + order.Submit(DateTime.UtcNow); + + var act = () => order.Submit(DateTime.UtcNow); + + act.Should().Throw(); + } + + [Fact] + public void Confirm_RequiresSubmittedFirst() + { + var order = NewOrder(); + + var act = () => order.Confirm(); + + act.Should().Throw(); + } + + [Fact] + public void Confirm_MovesSubmittedToConfirmed() + { + var order = NewOrder(); + order.Submit(DateTime.UtcNow); + + order.Confirm(); + + order.Status.Should().Be(PurchaseOrderStatus.Confirmed); + } + + [Fact] + public void ReplaceLines_OnlyWorksWhileDraft() + { + var order = NewOrder(); + order.Submit(DateTime.UtcNow); + + var act = () => order.ReplaceLines([(RawMaterialB, 1m, 1m)]); + + act.Should().Throw(); + } + + [Fact] + public void ReplaceLines_OverwritesRatherThanMerges() + { + var order = NewOrder((RawMaterialA, 1m, 100m)); + + order.ReplaceLines([(RawMaterialB, 2m, 200m)]); + + order.Lines.Should().ContainSingle().Which.RawMaterialId.Should().Be(RawMaterialB); + } + + [Fact] + public void MarkDelivered_IsIdempotentSinceAnOrderCanBeReceivedAcrossMultipleGrns() + { + var order = NewOrder(); + order.Submit(DateTime.UtcNow); + order.Confirm(); + + order.MarkDelivered(); + var act = () => order.MarkDelivered(); + + act.Should().NotThrow(); + order.Status.Should().Be(PurchaseOrderStatus.Delivered); + } + + [Fact] + public void MarkDelivered_RefusesACancelledOrder() + { + var order = NewOrder(); + order.Cancel(); + + var act = () => order.MarkDelivered(); + + act.Should().Throw(); + } + + [Theory] + [InlineData(PurchaseOrderStatus.Draft)] + [InlineData(PurchaseOrderStatus.Submitted)] + [InlineData(PurchaseOrderStatus.Confirmed)] + public void Cancel_WorksFromAnyStateBeforeDelivery(PurchaseOrderStatus startingStatus) + { + var order = NewOrder(); + if (startingStatus >= PurchaseOrderStatus.Submitted) order.Submit(DateTime.UtcNow); + if (startingStatus >= PurchaseOrderStatus.Confirmed) order.Confirm(); + + order.Cancel(); + + order.Status.Should().Be(PurchaseOrderStatus.Cancelled); + } + + [Fact] + public void Cancel_RefusesADeliveredOrder() + { + var order = NewOrder(); + order.Submit(DateTime.UtcNow); + order.Confirm(); + order.MarkDelivered(); + + var act = () => order.Cancel(); + + act.Should().Throw(); + } + + [Fact] + public void Cancel_RefusesAnAlreadyCancelledOrder() + { + var order = NewOrder(); + order.Cancel(); + + var act = () => order.Cancel(); + + act.Should().Throw(); + } +} \ No newline at end of file diff --git a/backend/tests/RestaurantPOS.UnitTests/Domain/RawMaterialTests.cs b/backend/tests/RestaurantPOS.UnitTests/Domain/RawMaterialTests.cs new file mode 100644 index 0000000..5856589 --- /dev/null +++ b/backend/tests/RestaurantPOS.UnitTests/Domain/RawMaterialTests.cs @@ -0,0 +1,59 @@ +using FluentAssertions; + +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Enums; + +using Xunit; + +namespace RestaurantPOS.UnitTests.Domain; + +public class RawMaterialTests +{ + [Fact] + public void Create_StartsActiveWithNoThresholdsByDefault() + { + var rice = RawMaterial.Create("Rice", UnitOfMeasurement.Kilogram); + + rice.IsActive.Should().BeTrue(); + rice.MainStoreReorderLevel.Should().BeNull(); + rice.KitchenParLevel.Should().BeNull(); + } + + [Fact] + public void Create_RejectsANegativeReorderLevel() + { + var act = () => RawMaterial.Create("Rice", UnitOfMeasurement.Kilogram, mainStoreReorderLevel: -1m); + + act.Should().Throw(); + } + + [Fact] + public void Create_RejectsANegativeParLevel() + { + var act = () => RawMaterial.Create("Rice", UnitOfMeasurement.Kilogram, kitchenParLevel: -1m); + + act.Should().Throw(); + } + + [Fact] + public void UpdateDetails_CanChangeTheUnitOfMeasurement() + { + var rice = RawMaterial.Create("Rice", UnitOfMeasurement.Kilogram); + + rice.UpdateDetails("Rice", UnitOfMeasurement.Gram, null, null); + + rice.UnitOfMeasurement.Should().Be(UnitOfMeasurement.Gram); + } + + [Fact] + public void ActivateAndDeactivate_ToggleIsActive() + { + var rice = RawMaterial.Create("Rice", UnitOfMeasurement.Kilogram); + + rice.Deactivate(); + rice.IsActive.Should().BeFalse(); + + rice.Activate(); + rice.IsActive.Should().BeTrue(); + } +} \ No newline at end of file diff --git a/backend/tests/RestaurantPOS.UnitTests/Domain/RecipeTests.cs b/backend/tests/RestaurantPOS.UnitTests/Domain/RecipeTests.cs new file mode 100644 index 0000000..d2cd276 --- /dev/null +++ b/backend/tests/RestaurantPOS.UnitTests/Domain/RecipeTests.cs @@ -0,0 +1,86 @@ +using FluentAssertions; + +using RestaurantPOS.Domain.Entities; + +using Xunit; + +namespace RestaurantPOS.UnitTests.Domain; + +public class RecipeTests +{ + private static readonly Guid MenuItemId = Guid.NewGuid(); + private static readonly Guid RawMaterialA = Guid.NewGuid(); + private static readonly Guid RawMaterialB = Guid.NewGuid(); + + [Fact] + public void Create_RequiresAtLeastOneLine() + { + var act = () => Recipe.Create(MenuItemId, []); + + act.Should().Throw("BR-REC-002: a recipe must contain at least one raw material"); + } + + [Fact] + public void Create_RejectsAZeroOrNegativeQuantity() + { + var act = () => Recipe.Create(MenuItemId, [(RawMaterialA, 0m)]); + + act.Should().Throw("BR-REC-003: quantities must be greater than zero"); + } + + [Fact] + public void Create_AcceptsAFractionalQuantity() + { + var recipe = Recipe.Create(MenuItemId, [(RawMaterialA, 0.25m)]); + + recipe.Lines.Should().ContainSingle().Which.Quantity.Should().Be(0.25m); + } + + [Fact] + public void Create_RejectsTheSameRawMaterialTwice() + { + var act = () => Recipe.Create(MenuItemId, [(RawMaterialA, 1m), (RawMaterialA, 2m)]); + + act.Should().Throw(); + } + + [Fact] + public void Create_StartsEnabled() + { + var recipe = Recipe.Create(MenuItemId, [(RawMaterialA, 1m)]); + + recipe.IsEnabled.Should().BeTrue(); + } + + [Fact] + public void ReplaceLines_OverwritesRatherThanMerges() + { + var recipe = Recipe.Create(MenuItemId, [(RawMaterialA, 1m)]); + + recipe.ReplaceLines([(RawMaterialB, 2m)]); + + recipe.Lines.Should().ContainSingle().Which.RawMaterialId.Should().Be(RawMaterialB); + } + + [Fact] + public void ReplaceLines_StillEnforcesAtLeastOneLine() + { + var recipe = Recipe.Create(MenuItemId, [(RawMaterialA, 1m)]); + + var act = () => recipe.ReplaceLines([]); + + act.Should().Throw(); + } + + [Fact] + public void EnableAndDisable_ToggleIsEnabled() + { + var recipe = Recipe.Create(MenuItemId, [(RawMaterialA, 1m)]); + + recipe.Disable(); + recipe.IsEnabled.Should().BeFalse(); + + recipe.Enable(); + recipe.IsEnabled.Should().BeTrue(); + } +} \ No newline at end of file diff --git a/backend/tests/RestaurantPOS.UnitTests/Domain/StockLevelTests.cs b/backend/tests/RestaurantPOS.UnitTests/Domain/StockLevelTests.cs new file mode 100644 index 0000000..76e82b7 --- /dev/null +++ b/backend/tests/RestaurantPOS.UnitTests/Domain/StockLevelTests.cs @@ -0,0 +1,57 @@ +using FluentAssertions; + +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Enums; + +using Xunit; + +namespace RestaurantPOS.UnitTests.Domain; + +public class StockLevelTests +{ + [Fact] + public void NewLevel_StartsAtZero() + { + var level = new StockLevel(Guid.NewGuid(), StoreType.MainStore); + + level.QuantityOnHand.Should().Be(0); + } + + [Fact] + public void ApplyDelta_IncreasesTheBalance() + { + var level = new StockLevel(Guid.NewGuid(), StoreType.MainStore); + var now = DateTime.UtcNow; + + level.ApplyDelta(10m, now); + + level.QuantityOnHand.Should().Be(10m); + level.UpdatedAtUtc.Should().Be(now); + } + + [Fact] + public void ApplyDelta_AccumulatesAcrossMultipleCalls() + { + var level = new StockLevel(Guid.NewGuid(), StoreType.MainStore); + var now = DateTime.UtcNow; + + level.ApplyDelta(10m, now); + level.ApplyDelta(-3m, now); + + level.QuantityOnHand.Should().Be(7m); + } + + [Fact] + public void ApplyDelta_RefusesToGoNegative() + { + var level = new StockLevel(Guid.NewGuid(), StoreType.MainStore); + var now = DateTime.UtcNow; + level.ApplyDelta(5m, now); + + var act = () => level.ApplyDelta(-10m, now); + + act.Should().Throw( + "this is a last-resort guard; the application layer is expected to check first"); + level.QuantityOnHand.Should().Be(5m, "a rejected delta must not partially apply"); + } +} \ No newline at end of file diff --git a/backend/tests/RestaurantPOS.UnitTests/Domain/SupplierTests.cs b/backend/tests/RestaurantPOS.UnitTests/Domain/SupplierTests.cs new file mode 100644 index 0000000..48bf539 --- /dev/null +++ b/backend/tests/RestaurantPOS.UnitTests/Domain/SupplierTests.cs @@ -0,0 +1,90 @@ +using FluentAssertions; + +using RestaurantPOS.Domain.Entities; + +using Xunit; + +namespace RestaurantPOS.UnitTests.Domain; + +public class SupplierTests +{ + [Fact] + public void Create_StartsActiveWithZeroPaymentTermsByDefault() + { + var supplier = Supplier.Create("ABC Wholesale"); + + supplier.IsActive.Should().BeTrue(); + supplier.PaymentTermsDays.Should().Be(0); + supplier.CreditLimit.Should().BeNull(); + supplier.LeadTimeDays.Should().BeNull(); + } + + [Fact] + public void Create_StoresContactDetailsAndTerms() + { + var supplier = Supplier.Create( + "ABC Wholesale", + contactName: "Mr. Perera", + phone: "0771234567", + email: " ABC@Wholesale.LK ", + address: "123 Galle Rd", + paymentTermsDays: 30, + creditLimit: 100000m, + leadTimeDays: 3); + + supplier.ContactName.Should().Be("Mr. Perera"); + supplier.Phone.Should().Be("0771234567"); + supplier.Email.Should().Be("abc@wholesale.lk", "email is normalised to lower case like elsewhere in the domain"); + supplier.PaymentTermsDays.Should().Be(30); + supplier.CreditLimit.Should().Be(100000m); + supplier.LeadTimeDays.Should().Be(3); + } + + [Fact] + public void Create_RejectsNegativePaymentTerms() + { + var act = () => Supplier.Create("ABC", paymentTermsDays: -1); + + act.Should().Throw(); + } + + [Fact] + public void Create_RejectsNegativeCreditLimit() + { + var act = () => Supplier.Create("ABC", creditLimit: -1m); + + act.Should().Throw(); + } + + [Fact] + public void Create_RejectsNegativeLeadTime() + { + var act = () => Supplier.Create("ABC", leadTimeDays: -1); + + act.Should().Throw(); + } + + [Fact] + public void UpdateDetails_ReplacesEveryField() + { + var supplier = Supplier.Create("ABC Wholesale", paymentTermsDays: 30); + + supplier.UpdateDetails("XYZ Traders", "New Contact", "011", "new@x.lk", "New Address", 0, null, null); + + supplier.Name.Should().Be("XYZ Traders"); + supplier.PaymentTermsDays.Should().Be(0); + supplier.CreditLimit.Should().BeNull(); + } + + [Fact] + public void ActivateAndDeactivate_ToggleIsActive() + { + var supplier = Supplier.Create("ABC Wholesale"); + + supplier.Deactivate(); + supplier.IsActive.Should().BeFalse(); + + supplier.Activate(); + supplier.IsActive.Should().BeTrue(); + } +} \ No newline at end of file diff --git a/frontend/src/app/routing/router.tsx b/frontend/src/app/routing/router.tsx index 7b78e9c..24a8c43 100644 --- a/frontend/src/app/routing/router.tsx +++ b/frontend/src/app/routing/router.tsx @@ -15,6 +15,11 @@ const AccountPage = lazy(() => import("@/pages/account")); const UsersPage = lazy(() => import("@/pages/users")); const CheckoutPage = lazy(() => import("@/pages/checkout")); const ReportsPage = lazy(() => import("@/pages/reports")); +const RecipesPage = lazy(() => import("@/pages/recipes")); +const MainStorePage = lazy(() => import("@/pages/inventory/main-store")); +const KitchenPage = lazy(() => import("@/pages/inventory/kitchen")); +const ReleasesPage = lazy(() => import("@/pages/inventory/releases")); +const SuppliersPage = lazy(() => import("@/pages/suppliers")); function withSuspense(element: ReactNode) { return }>{element}; @@ -47,6 +52,26 @@ export const router = createBrowserRouter([ element: , children: [{ path: "users", element: withSuspense() }], }, + { + element: , + children: [{ path: "recipes", element: withSuspense() }], + }, + { + element: , + children: [{ path: "inventory/main-store", element: withSuspense() }], + }, + { + element: , + children: [{ path: "inventory/kitchen", element: withSuspense() }], + }, + { + element: , + children: [{ path: "inventory/releases", element: withSuspense() }], + }, + { + element: , + children: [{ path: "suppliers", element: withSuspense() }], + }, ], }, ], diff --git a/frontend/src/entities/inventory/index.ts b/frontend/src/entities/inventory/index.ts new file mode 100644 index 0000000..1b2111a --- /dev/null +++ b/frontend/src/entities/inventory/index.ts @@ -0,0 +1,18 @@ +export type { + StoreType, + StockMovementType, + StockLevel, + StockMovement, + StockMovementLine, + GoodsReceivedNote, + GoodsReceivedNoteSummary, + StockRelease, + StockReleaseSummary, + StockLineInput, + CreateGoodsReceivedNotePayload, + CreateStockReleasePayload, + CreateStockAdjustmentPayload, + StockMovementFilters, + ConsumedLine, + ConsumptionResult, +} from "./model/types"; diff --git a/frontend/src/entities/inventory/model/types.ts b/frontend/src/entities/inventory/model/types.ts new file mode 100644 index 0000000..e3f0b70 --- /dev/null +++ b/frontend/src/entities/inventory/model/types.ts @@ -0,0 +1,138 @@ +import type { UnitOfMeasurement } from "@/entities/raw-material"; + +export type StoreType = "MainStore" | "Kitchen"; + +export type StockMovementType = + | "GoodsReceived" + | "StockReleaseOut" + | "StockReleaseIn" + | "Adjustment" + | "Consumption"; + +/** A raw material's current balance in one store. */ +export interface StockLevel { + rawMaterialId: string; + rawMaterialName: string; + unitOfMeasurement: UnitOfMeasurement; + store: StoreType; + quantityOnHand: number; + isLowStock: boolean; +} + +/** One row in the permanent stock ledger. */ +export interface StockMovement { + id: string; + rawMaterialId: string; + rawMaterialName: string; + unitOfMeasurement: UnitOfMeasurement; + store: StoreType; + quantityDelta: number; + type: StockMovementType; + referenceId: string | null; + performedByUserId: string; + performedByName: string; + occurredAtUtc: string; + notes: string | null; +} + +export interface StockMovementLine { + rawMaterialId: string; + rawMaterialName: string; + unitOfMeasurement: UnitOfMeasurement; + quantity: number; +} + +export interface GoodsReceivedNote { + id: string; + supplierId: string; + supplierName: string; + receivedByUserId: string; + receivedByName: string; + receivedAtUtc: string; + notes: string | null; + purchaseOrderId: string | null; + /** 1-5. Null when no rating was recorded for this delivery. */ + qualityRating: number | null; + hasIssue: boolean; + lines: StockMovementLine[]; +} + +export interface GoodsReceivedNoteSummary { + id: string; + supplierId: string; + supplierName: string; + receivedByName: string; + receivedAtUtc: string; + notes: string | null; + purchaseOrderId: string | null; + qualityRating: number | null; + hasIssue: boolean; + lineCount: number; +} + +export interface StockRelease { + id: string; + requestedByUserId: string; + requestedByName: string; + requestedAtUtc: string; + approvedByUserId: string; + approvedByName: string; + approvedAtUtc: string; + notes: string | null; + lines: StockMovementLine[]; +} + +export interface StockReleaseSummary { + id: string; + requestedByName: string; + requestedAtUtc: string; + approvedByName: string; + approvedAtUtc: string; + notes: string | null; + lineCount: number; +} + +export interface StockLineInput { + rawMaterialId: string; + quantity: number; +} + +export interface CreateGoodsReceivedNotePayload { + supplierId: string; + lines: StockLineInput[]; + notes: string | null; + purchaseOrderId?: string | null; + qualityRating?: number | null; + hasIssue?: boolean; +} + +export interface CreateStockReleasePayload { + lines: StockLineInput[]; + pin: string; + notes: string | null; +} + +export interface CreateStockAdjustmentPayload { + rawMaterialId: string; + store: StoreType; + quantityDelta: number; + reason: string; +} + +export interface StockMovementFilters { + rawMaterialId?: string; + from?: string; + to?: string; +} + +export interface ConsumedLine { + rawMaterialId: string; + rawMaterialName: string; + quantityDeducted: number; + unitOfMeasurement: UnitOfMeasurement; +} + +export interface ConsumptionResult { + deducted: boolean; + lines: ConsumedLine[]; +} diff --git a/frontend/src/entities/menu-item/index.ts b/frontend/src/entities/menu-item/index.ts new file mode 100644 index 0000000..1077c45 --- /dev/null +++ b/frontend/src/entities/menu-item/index.ts @@ -0,0 +1,6 @@ +export type { + MenuItem, + CreateMenuItemPayload, + UpdateMenuItemPayload, + MenuItemFilters, +} from "./model/types"; diff --git a/frontend/src/entities/menu-item/model/types.ts b/frontend/src/entities/menu-item/model/types.ts new file mode 100644 index 0000000..a419264 --- /dev/null +++ b/frontend/src/entities/menu-item/model/types.ts @@ -0,0 +1,25 @@ +/** A sellable dish or drink. Mirrors `MenuItemDto` in `RestaurantPOS.Application`. */ +export interface MenuItem { + id: string; + name: string; + category: string; + price: number; + isActive: boolean; + /** Whether Recipe Management has a bill of materials attached to this item. Not every item needs one. */ + hasRecipe: boolean; + createdAtUtc: string; +} + +export interface CreateMenuItemPayload { + name: string; + category: string; + price: number; +} + +export type UpdateMenuItemPayload = CreateMenuItemPayload; + +export interface MenuItemFilters { + search?: string; + category?: string; + isActive?: boolean; +} diff --git a/frontend/src/entities/raw-material/index.ts b/frontend/src/entities/raw-material/index.ts new file mode 100644 index 0000000..2d27583 --- /dev/null +++ b/frontend/src/entities/raw-material/index.ts @@ -0,0 +1,8 @@ +export type { + UnitOfMeasurement, + RawMaterial, + CreateRawMaterialPayload, + UpdateRawMaterialPayload, + RawMaterialFilters, +} from "./model/types"; +export { UNITS_OF_MEASUREMENT, UNIT_ABBREVIATIONS } from "./model/types"; diff --git a/frontend/src/entities/raw-material/model/types.ts b/frontend/src/entities/raw-material/model/types.ts new file mode 100644 index 0000000..c87e813 --- /dev/null +++ b/frontend/src/entities/raw-material/model/types.ts @@ -0,0 +1,49 @@ +/** Matches the backend `UnitOfMeasurement` enum, serialised by name. */ +export type UnitOfMeasurement = "Kilogram" | "Gram" | "Liter" | "Milliliter" | "Piece" | "Bottle" | "Packet"; + +export const UNITS_OF_MEASUREMENT: UnitOfMeasurement[] = [ + "Kilogram", + "Gram", + "Liter", + "Milliliter", + "Piece", + "Bottle", + "Packet", +]; + +/** Short display form, e.g. for "0.25 kg" next to a quantity. */ +export const UNIT_ABBREVIATIONS: Record = { + Kilogram: "kg", + Gram: "g", + Liter: "L", + Milliliter: "ml", + Piece: "pcs", + Bottle: "bottles", + Packet: "packets", +}; + +/** An ingredient tracked in stock. Its unit is fixed for its whole lifetime. */ +export interface RawMaterial { + id: string; + name: string; + unitOfMeasurement: UnitOfMeasurement; + /** Main Store is flagged low once its stock reaches this. Null means no threshold is set. */ + mainStoreReorderLevel: number | null; + /** Kitchen is flagged low once its stock reaches this. Null means no threshold is set. */ + kitchenParLevel: number | null; + isActive: boolean; +} + +export interface CreateRawMaterialPayload { + name: string; + unitOfMeasurement: UnitOfMeasurement; + mainStoreReorderLevel: number | null; + kitchenParLevel: number | null; +} + +export type UpdateRawMaterialPayload = CreateRawMaterialPayload; + +export interface RawMaterialFilters { + search?: string; + isActive?: boolean; +} diff --git a/frontend/src/entities/recipe/index.ts b/frontend/src/entities/recipe/index.ts new file mode 100644 index 0000000..8811079 --- /dev/null +++ b/frontend/src/entities/recipe/index.ts @@ -0,0 +1 @@ +export type { Recipe, RecipeLine, RecipeLineInput } from "./model/types"; diff --git a/frontend/src/entities/recipe/model/types.ts b/frontend/src/entities/recipe/model/types.ts new file mode 100644 index 0000000..a938a2c --- /dev/null +++ b/frontend/src/entities/recipe/model/types.ts @@ -0,0 +1,23 @@ +import type { UnitOfMeasurement } from "@/entities/raw-material"; + +export interface RecipeLine { + rawMaterialId: string; + rawMaterialName: string; + unitOfMeasurement: UnitOfMeasurement; + quantity: number; +} + +/** The bill of materials for one menu item: what it consumes per unit sold. */ +export interface Recipe { + id: string; + menuItemId: string; + isEnabled: boolean; + lines: RecipeLine[]; + createdAtUtc: string; + updatedAtUtc: string | null; +} + +export interface RecipeLineInput { + rawMaterialId: string; + quantity: number; +} diff --git a/frontend/src/entities/supplier/.gitkeep b/frontend/src/entities/supplier/.gitkeep deleted file mode 100644 index fdffa2a..0000000 --- a/frontend/src/entities/supplier/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# placeholder diff --git a/frontend/src/entities/supplier/index.ts b/frontend/src/entities/supplier/index.ts new file mode 100644 index 0000000..a8bcfac --- /dev/null +++ b/frontend/src/entities/supplier/index.ts @@ -0,0 +1,20 @@ +export type { + Supplier, + SupplierPayload, + SupplierFilters, + PurchaseOrderStatus, + PurchaseOrderLine, + PurchaseOrderLineInput, + PurchaseOrderSummary, + PurchaseOrder, + CreatePurchaseOrderPayload, + UpdatePurchaseOrderPayload, + PurchaseOrderFilters, + SupplierPrice, + SupplierPriceHistoryEntry, + PaymentMethod, + SupplierPayment, + RecordSupplierPaymentPayload, + SupplierPerformance, +} from "./model/types"; +export { PURCHASE_ORDER_STATUSES, PAYMENT_METHODS } from "./model/types"; diff --git a/frontend/src/entities/supplier/model/types.ts b/frontend/src/entities/supplier/model/types.ts new file mode 100644 index 0000000..f703348 --- /dev/null +++ b/frontend/src/entities/supplier/model/types.ts @@ -0,0 +1,168 @@ +import type { UnitOfMeasurement } from "@/entities/raw-material"; + +/** A goods supplier: who a GRN credits stock to, who a PO is sent to. */ +export interface Supplier { + id: string; + name: string; + contactName: string | null; + phone: string | null; + email: string | null; + address: string | null; + /** Days after delivery payment is due. 0 means cash on delivery. */ + paymentTermsDays: number; + /** Maximum outstanding balance this supplier extends. Null means no limit is tracked. */ + creditLimit: number | null; + /** Typical days between placing an order and delivery. Null means not yet known. */ + leadTimeDays: number | null; + isActive: boolean; + createdAtUtc: string; +} + +export interface SupplierPayload { + name: string; + contactName: string | null; + phone: string | null; + email: string | null; + address: string | null; + paymentTermsDays: number; + creditLimit: number | null; + leadTimeDays: number | null; +} + +export interface SupplierFilters { + search?: string; + isActive?: boolean; +} + +/** Matches the backend `PurchaseOrderStatus` enum, serialised by name. */ +export type PurchaseOrderStatus = "Draft" | "Submitted" | "Confirmed" | "Delivered" | "Cancelled"; + +export const PURCHASE_ORDER_STATUSES: PurchaseOrderStatus[] = [ + "Draft", + "Submitted", + "Confirmed", + "Delivered", + "Cancelled", +]; + +export interface PurchaseOrderLine { + rawMaterialId: string; + rawMaterialName: string; + unitOfMeasurement: UnitOfMeasurement; + quantity: number; + unitPrice: number; + lineTotal: number; +} + +export interface PurchaseOrderLineInput { + rawMaterialId: string; + quantity: number; + unitPrice: number; +} + +/** A purchase order's header for a list screen, without its lines. */ +export interface PurchaseOrderSummary { + id: string; + supplierId: string; + supplierName: string; + status: PurchaseOrderStatus; + createdAtUtc: string; + expectedDeliveryDate: string | null; + totalAmount: number; + amountPaid: number; + balance: number; + lineCount: number; +} + +export interface PurchaseOrder { + id: string; + supplierId: string; + supplierName: string; + status: PurchaseOrderStatus; + createdByUserId: string; + createdByName: string; + createdAtUtc: string; + expectedDeliveryDate: string | null; + submittedAtUtc: string | null; + notes: string | null; + totalAmount: number; + amountPaid: number; + balance: number; + lines: PurchaseOrderLine[]; +} + +export interface CreatePurchaseOrderPayload { + supplierId: string; + lines: PurchaseOrderLineInput[]; + expectedDeliveryDate: string | null; + notes: string | null; +} + +export interface UpdatePurchaseOrderPayload { + lines: PurchaseOrderLineInput[]; + expectedDeliveryDate: string | null; + notes: string | null; +} + +export interface PurchaseOrderFilters { + supplierId?: string; + status?: PurchaseOrderStatus; +} + +export interface SupplierPrice { + supplierId: string; + supplierName: string; + rawMaterialId: string; + rawMaterialName: string; + unitOfMeasurement: UnitOfMeasurement; + price: number; + updatedAtUtc: string; +} + +export interface SupplierPriceHistoryEntry { + price: number; + recordedByUserId: string; + recordedByName: string; + recordedAtUtc: string; +} + +/** Matches the backend `PaymentMethod` enum, serialised by name. */ +export type PaymentMethod = "Cash" | "BankTransfer" | "Cheque" | "Card" | "Other"; + +export const PAYMENT_METHODS: PaymentMethod[] = ["Cash", "BankTransfer", "Cheque", "Card", "Other"]; + +export interface SupplierPayment { + id: string; + purchaseOrderId: string; + amount: number; + paymentDateUtc: string; + method: PaymentMethod; + invoiceReference: string | null; + recordedByUserId: string; + recordedByName: string; + notes: string | null; +} + +export interface RecordSupplierPaymentPayload { + amount: number; + paymentDateUtc: string; + method: PaymentMethod; + invoiceReference: string | null; + notes: string | null; +} + +/** Delivery and quality statistics for a supplier, derived from their purchase orders and GRNs. */ +export interface SupplierPerformance { + supplierId: string; + supplierName: string; + totalOrders: number; + deliveredOrders: number; + onTimeDeliveries: number; + /** Percentage, 0-100. Null when no delivered order had an expected date to compare against. */ + onTimeDeliveryRate: number | null; + /** Null when no order has both a submission date and a recorded delivery. */ + averageDeliveryDays: number | null; + /** 1-5. Null when no GRN for this supplier has a quality rating recorded. */ + averageQualityRating: number | null; + issueCount: number; +} diff --git a/frontend/src/features/inventory/api/inventoryApi.ts b/frontend/src/features/inventory/api/inventoryApi.ts new file mode 100644 index 0000000..1bc2bf3 --- /dev/null +++ b/frontend/src/features/inventory/api/inventoryApi.ts @@ -0,0 +1,56 @@ +import type { + CreateGoodsReceivedNotePayload, + CreateStockAdjustmentPayload, + CreateStockReleasePayload, + GoodsReceivedNote, + GoodsReceivedNoteSummary, + StockLevel, + StockMovement, + StockMovementFilters, + StockRelease, + StockReleaseSummary, +} from "@/entities/inventory"; +import { API_ENDPOINTS, apiService } from "@/shared/api/endpoints"; + +export const inventoryApi = { + mainStoreStock: (lowStockOnly = false) => + apiService.get(API_ENDPOINTS.INVENTORY.MAIN_STORE_STOCK, { lowStockOnly }), + + mainStoreMovements: (filters: StockMovementFilters = {}) => + apiService.get(API_ENDPOINTS.INVENTORY.MAIN_STORE_MOVEMENTS, { + rawMaterialId: filters.rawMaterialId || undefined, + from: filters.from || undefined, + to: filters.to || undefined, + }), + + createAdjustment: (store: "MainStore" | "Kitchen", payload: CreateStockAdjustmentPayload) => + apiService.post( + store === "MainStore" ? API_ENDPOINTS.INVENTORY.MAIN_STORE_ADJUSTMENTS : API_ENDPOINTS.INVENTORY.KITCHEN_ADJUSTMENTS, + payload, + ), + + goodsReceivedNotes: () => apiService.get(API_ENDPOINTS.INVENTORY.GOODS_RECEIVED), + + goodsReceivedNoteById: (id: string) => + apiService.get(API_ENDPOINTS.INVENTORY.GOODS_RECEIVED_BY_ID(id)), + + createGoodsReceivedNote: (payload: CreateGoodsReceivedNotePayload) => + apiService.post(API_ENDPOINTS.INVENTORY.GOODS_RECEIVED, payload), + + kitchenStock: (lowStockOnly = false) => + apiService.get(API_ENDPOINTS.INVENTORY.KITCHEN_STOCK, { lowStockOnly }), + + kitchenMovements: (filters: StockMovementFilters = {}) => + apiService.get(API_ENDPOINTS.INVENTORY.KITCHEN_MOVEMENTS, { + rawMaterialId: filters.rawMaterialId || undefined, + from: filters.from || undefined, + to: filters.to || undefined, + }), + + releases: () => apiService.get(API_ENDPOINTS.INVENTORY.RELEASES), + + releaseById: (id: string) => apiService.get(API_ENDPOINTS.INVENTORY.RELEASE_BY_ID(id)), + + createRelease: (payload: CreateStockReleasePayload) => + apiService.post(API_ENDPOINTS.INVENTORY.RELEASES, payload), +}; diff --git a/frontend/src/features/inventory/index.ts b/frontend/src/features/inventory/index.ts new file mode 100644 index 0000000..8f01940 --- /dev/null +++ b/frontend/src/features/inventory/index.ts @@ -0,0 +1,14 @@ +export { inventoryApi } from "./api/inventoryApi"; +export { + useMainStoreStock, + useMainStoreMovements, + useKitchenStock, + useKitchenMovements, + useGoodsReceivedNotes, + useStockReleases, + useInventoryMutations, +} from "./model/useInventory"; +export { StockLinesEditor } from "./ui/StockLinesEditor"; +export { GoodsReceivedNoteDialog } from "./ui/GoodsReceivedNoteDialog"; +export { StockReleaseDialog } from "./ui/StockReleaseDialog"; +export { StockAdjustmentDialog } from "./ui/StockAdjustmentDialog"; diff --git a/frontend/src/features/inventory/model/useInventory.ts b/frontend/src/features/inventory/model/useInventory.ts new file mode 100644 index 0000000..61af345 --- /dev/null +++ b/frontend/src/features/inventory/model/useInventory.ts @@ -0,0 +1,105 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { + CreateGoodsReceivedNotePayload, + CreateStockAdjustmentPayload, + CreateStockReleasePayload, + GoodsReceivedNote, + StockMovementFilters, + StockRelease, +} from "@/entities/inventory"; +import { inventoryApi } from "../api/inventoryApi"; + +const MAIN_STORE_STOCK_KEY = "main-store-stock"; +const MAIN_STORE_MOVEMENTS_KEY = "main-store-movements"; +const KITCHEN_STOCK_KEY = "kitchen-stock"; +const KITCHEN_MOVEMENTS_KEY = "kitchen-movements"; +const GRNS_KEY = "goods-received-notes"; +const RELEASES_KEY = "stock-releases"; + +export function useMainStoreStock(lowStockOnly = false) { + return useQuery({ + queryKey: [MAIN_STORE_STOCK_KEY, lowStockOnly], + queryFn: () => inventoryApi.mainStoreStock(lowStockOnly), + }); +} + +export function useMainStoreMovements(filters: StockMovementFilters = {}) { + return useQuery({ + queryKey: [MAIN_STORE_MOVEMENTS_KEY, filters], + queryFn: () => inventoryApi.mainStoreMovements(filters), + }); +} + +export function useKitchenStock(lowStockOnly = false) { + return useQuery({ + queryKey: [KITCHEN_STOCK_KEY, lowStockOnly], + queryFn: () => inventoryApi.kitchenStock(lowStockOnly), + }); +} + +export function useKitchenMovements(filters: StockMovementFilters = {}) { + return useQuery({ + queryKey: [KITCHEN_MOVEMENTS_KEY, filters], + queryFn: () => inventoryApi.kitchenMovements(filters), + }); +} + +export function useGoodsReceivedNotes() { + return useQuery({ queryKey: [GRNS_KEY], queryFn: inventoryApi.goodsReceivedNotes }); +} + +export function useStockReleases() { + return useQuery({ queryKey: [RELEASES_KEY], queryFn: inventoryApi.releases }); +} + +/** Commands that move stock. Each invalidates every view the change could affect. */ +export function useInventoryMutations() { + const queryClient = useQueryClient(); + + const invalidateMainStore = () => { + queryClient.invalidateQueries({ queryKey: [MAIN_STORE_STOCK_KEY] }); + queryClient.invalidateQueries({ queryKey: [MAIN_STORE_MOVEMENTS_KEY] }); + }; + + const invalidateKitchen = () => { + queryClient.invalidateQueries({ queryKey: [KITCHEN_STOCK_KEY] }); + queryClient.invalidateQueries({ queryKey: [KITCHEN_MOVEMENTS_KEY] }); + }; + + const createGoodsReceivedNote = useMutation({ + mutationFn: inventoryApi.createGoodsReceivedNote, + onSuccess: () => { + invalidateMainStore(); + queryClient.invalidateQueries({ queryKey: [GRNS_KEY] }); + }, + }); + + const createRelease = useMutation({ + mutationFn: inventoryApi.createRelease, + onSuccess: () => { + invalidateMainStore(); + invalidateKitchen(); + queryClient.invalidateQueries({ queryKey: [RELEASES_KEY] }); + }, + }); + + const createMainStoreAdjustment = useMutation< + Awaited>, + Error, + CreateStockAdjustmentPayload + >({ + mutationFn: (payload) => inventoryApi.createAdjustment("MainStore", payload), + onSuccess: invalidateMainStore, + }); + + const createKitchenAdjustment = useMutation< + Awaited>, + Error, + CreateStockAdjustmentPayload + >({ + mutationFn: (payload) => inventoryApi.createAdjustment("Kitchen", payload), + onSuccess: invalidateKitchen, + }); + + return { createGoodsReceivedNote, createRelease, createMainStoreAdjustment, createKitchenAdjustment }; +} diff --git a/frontend/src/features/inventory/ui/GoodsReceivedNoteDialog.tsx b/frontend/src/features/inventory/ui/GoodsReceivedNoteDialog.tsx new file mode 100644 index 0000000..e123572 --- /dev/null +++ b/frontend/src/features/inventory/ui/GoodsReceivedNoteDialog.tsx @@ -0,0 +1,232 @@ +import { useMemo, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { toast } from "sonner"; +import type { StockLineInput } from "@/entities/inventory"; +import type { RawMaterial } from "@/entities/raw-material"; +import type { Supplier } from "@/entities/supplier"; +import { usePurchaseOrders } from "@/features/purchase-orders"; +import { + Button, + Checkbox, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + FormField, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/ui"; +import { useInventoryMutations } from "../model/useInventory"; +import { StockLinesEditor } from "./StockLinesEditor"; + +export interface GoodsReceivedNoteDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + suppliers: Supplier[]; + rawMaterials: RawMaterial[]; +} + +const NO_PURCHASE_ORDER = "none"; +const NOT_RATED = "unrated"; +const QUALITY_RATINGS = [1, 2, 3, 4, 5]; + +interface FormValues { + supplierId: string; + purchaseOrderId: string; + qualityRating: string; + hasIssue: boolean; + notes: string; +} + +/** + * Records stock received from a supplier. There is no separate confirmation step — saving this + * increases Main Store stock immediately, since a GRN is only ever entered once the goods have + * actually been counted in. + */ +export function GoodsReceivedNoteDialog({ open, onOpenChange, suppliers, rawMaterials }: GoodsReceivedNoteDialogProps) { + const { createGoodsReceivedNote } = useInventoryMutations(); + const [lines, setLines] = useState([]); + + const defaults: FormValues = { + supplierId: "", + purchaseOrderId: NO_PURCHASE_ORDER, + qualityRating: NOT_RATED, + hasIssue: false, + notes: "", + }; + + const { + control, + register, + handleSubmit, + reset, + watch, + setValue, + formState: { errors }, + } = useForm({ defaultValues: defaults }); + + const supplierId = watch("supplierId"); + + // Only orders already sent to (or received from) this supplier make sense to link a delivery + // to — a draft has not been placed yet, and a cancelled order cannot be fulfilled. + const { data: supplierOrders } = usePurchaseOrders({ supplierId }, !!supplierId); + const linkableOrders = useMemo( + () => (supplierOrders ?? []).filter((o) => o.status !== "Draft" && o.status !== "Cancelled"), + [supplierOrders], + ); + + const close = (isOpen: boolean) => { + if (!isOpen) { + reset(defaults); + setLines([]); + } + onOpenChange(isOpen); + }; + + const onSubmit = handleSubmit(async (values) => { + if (lines.length === 0) { + toast.error("Add at least one raw material."); + return; + } + + try { + await createGoodsReceivedNote.mutateAsync({ + supplierId: values.supplierId, + lines, + notes: values.notes.trim() || null, + purchaseOrderId: values.purchaseOrderId === NO_PURCHASE_ORDER ? null : values.purchaseOrderId, + qualityRating: values.qualityRating === NOT_RATED ? null : Number(values.qualityRating), + hasIssue: values.hasIssue, + }); + toast.success("Stock received. Main Store balances have been updated."); + close(false); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Something went wrong."); + } + }); + + return ( + + + + Receive goods + Main Store stock increases as soon as you save this. + + +
+ + ( + + )} + /> + + + {linkableOrders.length > 0 && ( + + ( + + )} + /> + + )} + +
+

Raw materials received

+ +
+ +
+ + ( + + )} + /> + + + + + +
+ + ( + + )} + /> + + + + + + +
+
+ ); +} diff --git a/frontend/src/features/inventory/ui/StockAdjustmentDialog.tsx b/frontend/src/features/inventory/ui/StockAdjustmentDialog.tsx new file mode 100644 index 0000000..2299ab2 --- /dev/null +++ b/frontend/src/features/inventory/ui/StockAdjustmentDialog.tsx @@ -0,0 +1,157 @@ +import { Controller, useForm } from "react-hook-form"; +import { toast } from "sonner"; +import type { StoreType } from "@/entities/inventory"; +import type { RawMaterial } from "@/entities/raw-material"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + FormField, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/ui"; +import { useInventoryMutations } from "../model/useInventory"; + +export interface StockAdjustmentDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + store: StoreType; + rawMaterials: RawMaterial[]; +} + +interface FormValues { + rawMaterialId: string; + direction: "increase" | "decrease"; + quantity: string; + reason: string; +} + +/** + * Corrects a raw material's balance to match a physical stock count. The direction and quantity + * are entered separately (rather than one signed number) so it reads naturally: "decrease Rice + * by 2 kg", not "adjust Rice by -2". + */ +export function StockAdjustmentDialog({ open, onOpenChange, store, rawMaterials }: StockAdjustmentDialogProps) { + const { createMainStoreAdjustment, createKitchenAdjustment } = useInventoryMutations(); + const mutation = store === "MainStore" ? createMainStoreAdjustment : createKitchenAdjustment; + + const { + register, + handleSubmit, + control, + reset, + formState: { errors }, + } = useForm({ + defaultValues: { rawMaterialId: "", direction: "decrease", quantity: "", reason: "" }, + }); + + const close = (isOpen: boolean) => { + if (!isOpen) reset({ rawMaterialId: "", direction: "decrease", quantity: "", reason: "" }); + onOpenChange(isOpen); + }; + + const onSubmit = handleSubmit(async (values) => { + const magnitude = Number(values.quantity); + + if (!Number.isFinite(magnitude) || magnitude <= 0) { + return; + } + + const quantityDelta = values.direction === "decrease" ? -magnitude : magnitude; + + try { + await mutation.mutateAsync({ rawMaterialId: values.rawMaterialId, store, quantityDelta, reason: values.reason }); + toast.success("Stock adjustment recorded."); + close(false); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Something went wrong."); + } + }); + + return ( + + + + Adjust stock + Correct a raw material's balance to match a physical count. + + +
+ + ( + + )} + /> + + +
+ + + + + + ( + + )} + /> + +
+ + + + + + + + + +
+
+
+ ); +} diff --git a/frontend/src/features/inventory/ui/StockLinesEditor.tsx b/frontend/src/features/inventory/ui/StockLinesEditor.tsx new file mode 100644 index 0000000..40d22e4 --- /dev/null +++ b/frontend/src/features/inventory/ui/StockLinesEditor.tsx @@ -0,0 +1,145 @@ +import { useState } from "react"; +import { Plus, Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import type { RawMaterial } from "@/entities/raw-material"; +import { UNIT_ABBREVIATIONS } from "@/entities/raw-material"; +import type { StockLineInput } from "@/entities/inventory"; +import { + Button, + EmptyState, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/ui"; + +export interface StockLinesEditorProps { + /** Raw materials that may be picked. Callers pass an already-filtered active list. */ + rawMaterials: RawMaterial[]; + lines: StockLineInput[]; + onChange: (lines: StockLineInput[]) => void; + emptyMessage?: string; +} + +/** + * Add/remove/quantity editor for a list of (raw material, quantity) lines — the shared shape + * behind both a Goods Received Note and a stock release. + */ +export function StockLinesEditor({ + rawMaterials, + lines, + onChange, + emptyMessage = "Add a raw material below to get started.", +}: StockLinesEditorProps) { + const [pendingRawMaterialId, setPendingRawMaterialId] = useState(""); + const [pendingQuantity, setPendingQuantity] = useState(""); + + const byId = (id: string) => rawMaterials.find((r) => r.id === id); + const availableToAdd = rawMaterials.filter((r) => !lines.some((l) => l.rawMaterialId === r.id)); + + const addLine = () => { + const quantity = Number(pendingQuantity); + + if (!pendingRawMaterialId || !Number.isFinite(quantity) || quantity <= 0) { + toast.error("Choose a raw material and enter a quantity greater than zero."); + return; + } + + onChange([...lines, { rawMaterialId: pendingRawMaterialId, quantity }]); + setPendingRawMaterialId(""); + setPendingQuantity(""); + }; + + const removeLine = (rawMaterialId: string) => + onChange(lines.filter((l) => l.rawMaterialId !== rawMaterialId)); + + const updateQuantity = (rawMaterialId: string, quantity: string) => { + const parsed = Number(quantity); + onChange( + lines.map((l) => (l.rawMaterialId === rawMaterialId ? { ...l, quantity: Number.isFinite(parsed) ? parsed : l.quantity } : l)), + ); + }; + + return ( +
+ {lines.length === 0 ? ( + + ) : ( +
+ {lines.map((line) => { + const material = byId(line.rawMaterialId); + + return ( +
+
+

{material?.name ?? "Unknown"}

+
+ updateQuantity(line.rawMaterialId, e.target.value)} + className="w-24" + aria-label={`Quantity of ${material?.name ?? "raw material"}`} + /> + + {material ? UNIT_ABBREVIATIONS[material.unitOfMeasurement] : ""} + + +
+ ); + })} +
+ )} + + {availableToAdd.length > 0 && ( +
+
+ + +
+
+ + setPendingQuantity(e.target.value)} + placeholder="5" + /> +
+ +
+ )} +
+ ); +} diff --git a/frontend/src/features/inventory/ui/StockReleaseDialog.tsx b/frontend/src/features/inventory/ui/StockReleaseDialog.tsx new file mode 100644 index 0000000..5eecc34 --- /dev/null +++ b/frontend/src/features/inventory/ui/StockReleaseDialog.tsx @@ -0,0 +1,121 @@ +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import type { StockLineInput } from "@/entities/inventory"; +import type { RawMaterial } from "@/entities/raw-material"; +import { + Alert, + AlertDescription, + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + FormField, + Input, +} from "@/shared/ui"; +import { useInventoryMutations } from "../model/useInventory"; +import { StockLinesEditor } from "./StockLinesEditor"; + +export interface StockReleaseDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + rawMaterials: RawMaterial[]; +} + +interface FormValues { + pin: string; + notes: string; +} + +/** + * Releases stock from the Main Store to the Kitchen. An administrator's approval PIN travels in + * the same request — there is no separate pending/approve step, so the release only ever exists + * already authorised. + */ +export function StockReleaseDialog({ open, onOpenChange, rawMaterials }: StockReleaseDialogProps) { + const { createRelease } = useInventoryMutations(); + const [lines, setLines] = useState([]); + + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ defaultValues: { pin: "", notes: "" } }); + + const close = (isOpen: boolean) => { + if (!isOpen) { + reset({ pin: "", notes: "" }); + setLines([]); + } + onOpenChange(isOpen); + }; + + const onSubmit = handleSubmit(async (values) => { + if (lines.length === 0) { + toast.error("Add at least one raw material."); + return; + } + + try { + const release = await createRelease.mutateAsync({ lines, pin: values.pin, notes: values.notes.trim() || null }); + toast.success(`Release approved by ${release.approvedByName}.`); + close(false); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Something went wrong."); + } + }); + + return ( + + + + Release stock to the kitchen + An administrator's approval PIN is required to authorise this. + + +
+
+

Raw materials to release

+ +
+ + + + + + + An administrator must type their PIN to approve this release. + + + + + + + + + + +
+
+
+ ); +} diff --git a/frontend/src/features/menu-items/api/menuItemsApi.ts b/frontend/src/features/menu-items/api/menuItemsApi.ts new file mode 100644 index 0000000..8697983 --- /dev/null +++ b/frontend/src/features/menu-items/api/menuItemsApi.ts @@ -0,0 +1,22 @@ +import type { CreateMenuItemPayload, MenuItem, MenuItemFilters, UpdateMenuItemPayload } from "@/entities/menu-item"; +import { API_ENDPOINTS, apiService } from "@/shared/api/endpoints"; + +export const menuItemsApi = { + list: (filters: MenuItemFilters = {}) => + apiService.get(API_ENDPOINTS.MENU_ITEMS.BASE, { + search: filters.search || undefined, + category: filters.category || undefined, + isActive: filters.isActive, + }), + + byId: (id: string) => apiService.get(API_ENDPOINTS.MENU_ITEMS.BY_ID(id)), + + create: (payload: CreateMenuItemPayload) => + apiService.post(API_ENDPOINTS.MENU_ITEMS.BASE, payload), + + update: (id: string, payload: UpdateMenuItemPayload) => + apiService.put(API_ENDPOINTS.MENU_ITEMS.BY_ID(id), payload), + + setActive: (id: string, isActive: boolean) => + apiService.put(API_ENDPOINTS.MENU_ITEMS.STATUS(id), { isActive }), +}; diff --git a/frontend/src/features/menu-items/index.ts b/frontend/src/features/menu-items/index.ts new file mode 100644 index 0000000..df740d7 --- /dev/null +++ b/frontend/src/features/menu-items/index.ts @@ -0,0 +1,3 @@ +export { menuItemsApi } from "./api/menuItemsApi"; +export { useMenuItems, useMenuItemMutations } from "./model/useMenuItems"; +export { MenuItemFormDialog } from "./ui/MenuItemFormDialog"; diff --git a/frontend/src/features/menu-items/model/menuItemSchema.ts b/frontend/src/features/menu-items/model/menuItemSchema.ts new file mode 100644 index 0000000..1545da4 --- /dev/null +++ b/frontend/src/features/menu-items/model/menuItemSchema.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +export const menuItemSchema = z.object({ + name: z.string().trim().min(1, "Name is required.").max(150), + category: z.string().trim().min(1, "Category is required.").max(80), + price: z + .string() + .trim() + .refine((v) => v !== "" && !Number.isNaN(Number(v)) && Number(v) >= 0, { + message: "Enter a valid, non-negative price.", + }), +}); + +export type MenuItemForm = z.infer; + +/** Converts the form's string price field to the number the API expects. */ +export const toPriceNumber = (value: string): number => Number(value); diff --git a/frontend/src/features/menu-items/model/useMenuItems.ts b/frontend/src/features/menu-items/model/useMenuItems.ts new file mode 100644 index 0000000..7111055 --- /dev/null +++ b/frontend/src/features/menu-items/model/useMenuItems.ts @@ -0,0 +1,35 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { CreateMenuItemPayload, MenuItem, MenuItemFilters, UpdateMenuItemPayload } from "@/entities/menu-item"; +import { menuItemsApi } from "../api/menuItemsApi"; + +const MENU_ITEMS_KEY = "menu-items"; + +export function useMenuItems(filters: MenuItemFilters = {}) { + return useQuery({ + queryKey: [MENU_ITEMS_KEY, filters], + queryFn: () => menuItemsApi.list(filters), + placeholderData: (previous) => previous, + }); +} + +export function useMenuItemMutations() { + const queryClient = useQueryClient(); + const invalidate = () => queryClient.invalidateQueries({ queryKey: [MENU_ITEMS_KEY] }); + + const create = useMutation({ + mutationFn: menuItemsApi.create, + onSuccess: invalidate, + }); + + const update = useMutation({ + mutationFn: ({ id, payload }) => menuItemsApi.update(id, payload), + onSuccess: invalidate, + }); + + const setActive = useMutation({ + mutationFn: ({ id, isActive }) => menuItemsApi.setActive(id, isActive), + onSuccess: invalidate, + }); + + return { create, update, setActive }; +} diff --git a/frontend/src/features/menu-items/ui/MenuItemFormDialog.tsx b/frontend/src/features/menu-items/ui/MenuItemFormDialog.tsx new file mode 100644 index 0000000..ba1fdbd --- /dev/null +++ b/frontend/src/features/menu-items/ui/MenuItemFormDialog.tsx @@ -0,0 +1,111 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import type { MenuItem } from "@/entities/menu-item"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + FormField, + Input, +} from "@/shared/ui"; +import { useMenuItemMutations } from "../model/useMenuItems"; +import { MenuItemForm, menuItemSchema, toPriceNumber } from "../model/menuItemSchema"; + +export interface MenuItemFormDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** The item being edited, or omitted to create a new one. */ + item?: MenuItem; +} + +/** Creates or edits a menu item's name, category and price. */ +export function MenuItemFormDialog({ open, onOpenChange, item }: MenuItemFormDialogProps) { + const isEditing = !!item; + const { create, update } = useMenuItemMutations(); + const pending = create.isPending || update.isPending; + + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(menuItemSchema), + defaultValues: { + name: item?.name ?? "", + category: item?.category ?? "", + price: item ? String(item.price) : "", + }, + }); + + const close = (isOpen: boolean) => { + if (!isOpen) { + reset({ + name: item?.name ?? "", + category: item?.category ?? "", + price: item ? String(item.price) : "", + }); + } + onOpenChange(isOpen); + }; + + const onSubmit = handleSubmit(async (values) => { + const payload = { name: values.name, category: values.category, price: toPriceNumber(values.price) }; + + try { + if (isEditing) { + await update.mutateAsync({ id: item.id, payload }); + toast.success(`${values.name} was updated.`); + } else { + await create.mutateAsync(payload); + toast.success(`${values.name} was added to the menu.`); + } + close(false); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Something went wrong."); + } + }); + + return ( + + + + {isEditing ? "Edit menu item" : "Add menu item"} + + {isEditing + ? "Changing the price only affects new orders." + : "You can attach a recipe once it's been added to the menu."} + + + +
+ + + + + + + + + + + + + + + + +
+
+
+ ); +} diff --git a/frontend/src/features/purchase-orders/api/purchaseOrdersApi.ts b/frontend/src/features/purchase-orders/api/purchaseOrdersApi.ts new file mode 100644 index 0000000..12ef6ac --- /dev/null +++ b/frontend/src/features/purchase-orders/api/purchaseOrdersApi.ts @@ -0,0 +1,37 @@ +import type { + CreatePurchaseOrderPayload, + PurchaseOrder, + PurchaseOrderFilters, + PurchaseOrderSummary, + RecordSupplierPaymentPayload, + SupplierPayment, + UpdatePurchaseOrderPayload, +} from "@/entities/supplier"; +import { API_ENDPOINTS, apiService } from "@/shared/api/endpoints"; + +export const purchaseOrdersApi = { + list: (filters: PurchaseOrderFilters = {}) => + apiService.get(API_ENDPOINTS.PURCHASE_ORDERS.BASE, { + supplierId: filters.supplierId, + status: filters.status, + }), + + getById: (id: string) => apiService.get(API_ENDPOINTS.PURCHASE_ORDERS.BY_ID(id)), + + create: (payload: CreatePurchaseOrderPayload) => + apiService.post(API_ENDPOINTS.PURCHASE_ORDERS.BASE, payload), + + update: (id: string, payload: UpdatePurchaseOrderPayload) => + apiService.put(API_ENDPOINTS.PURCHASE_ORDERS.BY_ID(id), payload), + + submit: (id: string) => apiService.post(API_ENDPOINTS.PURCHASE_ORDERS.SUBMIT(id)), + + confirm: (id: string) => apiService.post(API_ENDPOINTS.PURCHASE_ORDERS.CONFIRM(id)), + + cancel: (id: string) => apiService.post(API_ENDPOINTS.PURCHASE_ORDERS.CANCEL(id)), + + payments: (id: string) => apiService.get(API_ENDPOINTS.PURCHASE_ORDERS.PAYMENTS(id)), + + recordPayment: (id: string, payload: RecordSupplierPaymentPayload) => + apiService.post(API_ENDPOINTS.PURCHASE_ORDERS.PAYMENTS(id), payload), +}; diff --git a/frontend/src/features/purchase-orders/index.ts b/frontend/src/features/purchase-orders/index.ts new file mode 100644 index 0000000..008a4d0 --- /dev/null +++ b/frontend/src/features/purchase-orders/index.ts @@ -0,0 +1,12 @@ +export { purchaseOrdersApi } from "./api/purchaseOrdersApi"; +export { + usePurchaseOrders, + usePurchaseOrder, + usePurchaseOrderPayments, + usePurchaseOrderMutations, +} from "./model/usePurchaseOrders"; +export { PURCHASE_ORDER_STATUS_BADGE } from "./lib/status"; +export { PurchaseOrderLinesEditor } from "./ui/PurchaseOrderLinesEditor"; +export { PurchaseOrderFormDialog } from "./ui/PurchaseOrderFormDialog"; +export { PurchaseOrderDetailDialog } from "./ui/PurchaseOrderDetailDialog"; +export { RecordPaymentDialog } from "./ui/RecordPaymentDialog"; diff --git a/frontend/src/features/purchase-orders/lib/status.ts b/frontend/src/features/purchase-orders/lib/status.ts new file mode 100644 index 0000000..b0b5c6d --- /dev/null +++ b/frontend/src/features/purchase-orders/lib/status.ts @@ -0,0 +1,10 @@ +import type { PurchaseOrderStatus } from "@/entities/supplier"; +import type { BadgeProps } from "@/shared/ui"; + +export const PURCHASE_ORDER_STATUS_BADGE: Record> = { + Draft: "secondary", + Submitted: "default", + Confirmed: "warning", + Delivered: "success", + Cancelled: "destructive", +}; diff --git a/frontend/src/features/purchase-orders/model/usePurchaseOrders.ts b/frontend/src/features/purchase-orders/model/usePurchaseOrders.ts new file mode 100644 index 0000000..cfda7e6 --- /dev/null +++ b/frontend/src/features/purchase-orders/model/usePurchaseOrders.ts @@ -0,0 +1,90 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { + CreatePurchaseOrderPayload, + PurchaseOrder, + PurchaseOrderFilters, + RecordSupplierPaymentPayload, + SupplierPayment, + UpdatePurchaseOrderPayload, +} from "@/entities/supplier"; +import { purchaseOrdersApi } from "../api/purchaseOrdersApi"; + +const PURCHASE_ORDERS_KEY = "purchase-orders"; +const PURCHASE_ORDER_KEY = "purchase-order"; +const PURCHASE_ORDER_PAYMENTS_KEY = "purchase-order-payments"; +const SUPPLIER_PERFORMANCE_KEY = "supplier-performance"; + +export function usePurchaseOrders(filters: PurchaseOrderFilters = {}, enabled = true) { + return useQuery({ + queryKey: [PURCHASE_ORDERS_KEY, filters], + queryFn: () => purchaseOrdersApi.list(filters), + placeholderData: (previous) => previous, + enabled, + }); +} + +export function usePurchaseOrder(id: string | undefined) { + return useQuery({ + queryKey: [PURCHASE_ORDER_KEY, id], + queryFn: () => purchaseOrdersApi.getById(id!), + enabled: !!id, + }); +} + +export function usePurchaseOrderPayments(id: string | undefined) { + return useQuery({ + queryKey: [PURCHASE_ORDER_PAYMENTS_KEY, id], + queryFn: () => purchaseOrdersApi.payments(id!), + enabled: !!id, + }); +} + +/** Commands across a purchase order's lifecycle: draft edits, status transitions, and payments. */ +export function usePurchaseOrderMutations() { + const queryClient = useQueryClient(); + + const invalidate = (id?: string) => { + queryClient.invalidateQueries({ queryKey: [PURCHASE_ORDERS_KEY] }); + if (id) queryClient.invalidateQueries({ queryKey: [PURCHASE_ORDER_KEY, id] }); + }; + + const create = useMutation({ + mutationFn: purchaseOrdersApi.create, + onSuccess: () => invalidate(), + }); + + const update = useMutation({ + mutationFn: ({ id, payload }) => purchaseOrdersApi.update(id, payload), + onSuccess: (_, { id }) => invalidate(id), + }); + + const submit = useMutation({ + mutationFn: purchaseOrdersApi.submit, + onSuccess: (_, id) => invalidate(id), + }); + + const confirm = useMutation({ + mutationFn: purchaseOrdersApi.confirm, + onSuccess: (_, id) => invalidate(id), + }); + + const cancel = useMutation({ + mutationFn: purchaseOrdersApi.cancel, + onSuccess: (_, id) => invalidate(id), + }); + + const recordPayment = useMutation< + SupplierPayment, + Error, + { id: string; supplierId: string; payload: RecordSupplierPaymentPayload } + >({ + mutationFn: ({ id, payload }) => purchaseOrdersApi.recordPayment(id, payload), + onSuccess: (_, { id, supplierId }) => { + invalidate(id); + queryClient.invalidateQueries({ queryKey: [PURCHASE_ORDER_PAYMENTS_KEY, id] }); + queryClient.invalidateQueries({ queryKey: [SUPPLIER_PERFORMANCE_KEY, supplierId] }); + }, + }); + + return { create, update, submit, confirm, cancel, recordPayment }; +} diff --git a/frontend/src/features/purchase-orders/ui/PurchaseOrderDetailDialog.tsx b/frontend/src/features/purchase-orders/ui/PurchaseOrderDetailDialog.tsx new file mode 100644 index 0000000..1d66b8a --- /dev/null +++ b/frontend/src/features/purchase-orders/ui/PurchaseOrderDetailDialog.tsx @@ -0,0 +1,185 @@ +import { useState } from "react"; +import { toast } from "sonner"; +import { + Badge, + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + LoadingState, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/shared/ui"; +import { toApiError } from "@/shared/api/problem"; +import { usePurchaseOrder, usePurchaseOrderMutations, usePurchaseOrderPayments } from "../model/usePurchaseOrders"; +import { PURCHASE_ORDER_STATUS_BADGE } from "../lib/status"; +import { RecordPaymentDialog } from "./RecordPaymentDialog"; + +export interface PurchaseOrderDetailDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + purchaseOrderId: string; +} + +/** A purchase order's full lines, totals, status actions and payment history. */ +export function PurchaseOrderDetailDialog({ open, onOpenChange, purchaseOrderId }: PurchaseOrderDetailDialogProps) { + const { data: order, isLoading } = usePurchaseOrder(open ? purchaseOrderId : undefined); + const { data: payments } = usePurchaseOrderPayments(open ? purchaseOrderId : undefined); + const { submit, confirm, cancel } = usePurchaseOrderMutations(); + const [paymentDialogOpen, setPaymentDialogOpen] = useState(false); + + const runAction = async (action: "submit" | "confirm" | "cancel") => { + try { + if (action === "submit") { + await submit.mutateAsync(purchaseOrderId); + toast.success("Purchase order submitted to the supplier."); + } else if (action === "confirm") { + await confirm.mutateAsync(purchaseOrderId); + toast.success("Purchase order confirmed."); + } else { + await cancel.mutateAsync(purchaseOrderId); + toast.success("Purchase order cancelled."); + } + } catch (error) { + toast.error(toApiError(error).message); + } + }; + + return ( + <> + + + + Purchase order + + + {isLoading || !order ? ( + + ) : ( +
+
+
+

{order.supplierName}

+

+ Created by {order.createdByName} on {new Date(order.createdAtUtc).toLocaleDateString()} +

+ {order.expectedDeliveryDate && ( +

+ Expected delivery: {new Date(order.expectedDeliveryDate).toLocaleDateString()} +

+ )} +
+ {order.status} +
+ + + + + Raw material + Qty + Unit price + Line total + + + + {order.lines.map((line) => ( + + {line.rawMaterialName} + {line.quantity} + {line.unitPrice.toFixed(2)} + {line.lineTotal.toFixed(2)} + + ))} + +
+ +
+ + Total: {order.totalAmount.toFixed(2)} + + + Paid: {order.amountPaid.toFixed(2)} + + + Balance: {order.balance.toFixed(2)} + +
+ + {order.notes &&

Notes: {order.notes}

} + + {payments && payments.length > 0 && ( +
+

Payments

+ + + + Date + Method + Amount + Recorded by + + + + {payments.map((payment) => ( + + + {new Date(payment.paymentDateUtc).toLocaleDateString()} + + {payment.method} + {payment.amount.toFixed(2)} + {payment.recordedByName} + + ))} + +
+
+ )} +
+ )} + + +
+ {order?.status === "Draft" && ( + + )} + {order?.status === "Submitted" && ( + + )} + {order && order.status !== "Delivered" && order.status !== "Cancelled" && ( + + )} + {order && order.status !== "Draft" && order.status !== "Cancelled" && order.balance > 0 && ( + + )} +
+ +
+
+
+ + {order && ( + + )} + + ); +} diff --git a/frontend/src/features/purchase-orders/ui/PurchaseOrderFormDialog.tsx b/frontend/src/features/purchase-orders/ui/PurchaseOrderFormDialog.tsx new file mode 100644 index 0000000..031d26a --- /dev/null +++ b/frontend/src/features/purchase-orders/ui/PurchaseOrderFormDialog.tsx @@ -0,0 +1,165 @@ +import { useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { toast } from "sonner"; +import type { RawMaterial } from "@/entities/raw-material"; +import type { PurchaseOrder, PurchaseOrderLineInput, Supplier } from "@/entities/supplier"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + FormField, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/ui"; +import { toApiError } from "@/shared/api/problem"; +import { usePurchaseOrderMutations } from "../model/usePurchaseOrders"; +import { PurchaseOrderLinesEditor } from "./PurchaseOrderLinesEditor"; + +export interface PurchaseOrderFormDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + suppliers: Supplier[]; + rawMaterials: RawMaterial[]; + /** The draft order being edited, or omitted to create a new one. */ + order?: PurchaseOrder; +} + +interface FormValues { + supplierId: string; + expectedDeliveryDate: string; + notes: string; +} + +/** + * Creates a new draft purchase order, or edits an existing one that has not yet been submitted — + * the supplier is fixed once a draft exists, since submitting sends it to that specific supplier. + */ +export function PurchaseOrderFormDialog({ + open, + onOpenChange, + suppliers, + rawMaterials, + order, +}: PurchaseOrderFormDialogProps) { + const isEditing = !!order; + const { create, update } = usePurchaseOrderMutations(); + const pending = create.isPending || update.isPending; + + const [lines, setLines] = useState( + order?.lines.map((l) => ({ rawMaterialId: l.rawMaterialId, quantity: l.quantity, unitPrice: l.unitPrice })) ?? [], + ); + + const defaults: FormValues = { + supplierId: order?.supplierId ?? "", + expectedDeliveryDate: order?.expectedDeliveryDate?.slice(0, 10) ?? "", + notes: order?.notes ?? "", + }; + + const { + control, + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ defaultValues: defaults }); + + const close = (isOpen: boolean) => { + if (!isOpen) { + reset(defaults); + setLines(order?.lines.map((l) => ({ rawMaterialId: l.rawMaterialId, quantity: l.quantity, unitPrice: l.unitPrice })) ?? []); + } + onOpenChange(isOpen); + }; + + const onSubmit = handleSubmit(async (values) => { + if (lines.length === 0) { + toast.error("Add at least one raw material."); + return; + } + + const expectedDeliveryDate = values.expectedDeliveryDate || null; + const notes = values.notes.trim() || null; + + try { + if (isEditing) { + await update.mutateAsync({ id: order.id, payload: { lines, expectedDeliveryDate, notes } }); + toast.success("Purchase order updated."); + } else { + await create.mutateAsync({ supplierId: values.supplierId, lines, expectedDeliveryDate, notes }); + toast.success("Purchase order created as a draft."); + } + close(false); + } catch (error) { + toast.error(toApiError(error).message); + } + }); + + return ( + + + + {isEditing ? "Edit draft purchase order" : "New purchase order"} + + {isEditing ? "Only draft orders can be edited." : "Created as a draft — submit it to send it to the supplier."} + + + +
+ + ( + + )} + /> + + +
+

Raw materials ordered

+ +
+ +
+ + + + + + + +
+ + + + + +
+
+
+ ); +} diff --git a/frontend/src/features/purchase-orders/ui/PurchaseOrderLinesEditor.tsx b/frontend/src/features/purchase-orders/ui/PurchaseOrderLinesEditor.tsx new file mode 100644 index 0000000..6a3d4f7 --- /dev/null +++ b/frontend/src/features/purchase-orders/ui/PurchaseOrderLinesEditor.tsx @@ -0,0 +1,178 @@ +import { useState } from "react"; +import { Plus, Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import type { RawMaterial } from "@/entities/raw-material"; +import { UNIT_ABBREVIATIONS } from "@/entities/raw-material"; +import type { PurchaseOrderLineInput } from "@/entities/supplier"; +import { + Button, + EmptyState, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/ui"; + +export interface PurchaseOrderLinesEditorProps { + /** Raw materials that may be picked. Callers pass an already-filtered active list. */ + rawMaterials: RawMaterial[]; + lines: PurchaseOrderLineInput[]; + onChange: (lines: PurchaseOrderLineInput[]) => void; +} + +/** Add/remove/quantity/price editor for a purchase order's lines, with a running total. */ +export function PurchaseOrderLinesEditor({ rawMaterials, lines, onChange }: PurchaseOrderLinesEditorProps) { + const [pendingRawMaterialId, setPendingRawMaterialId] = useState(""); + const [pendingQuantity, setPendingQuantity] = useState(""); + const [pendingUnitPrice, setPendingUnitPrice] = useState(""); + + const byId = (id: string) => rawMaterials.find((r) => r.id === id); + const availableToAdd = rawMaterials.filter((r) => !lines.some((l) => l.rawMaterialId === r.id)); + const total = lines.reduce((sum, l) => sum + l.quantity * l.unitPrice, 0); + + const addLine = () => { + const quantity = Number(pendingQuantity); + const unitPrice = Number(pendingUnitPrice); + + if (!pendingRawMaterialId || !Number.isFinite(quantity) || quantity <= 0) { + toast.error("Choose a raw material and enter a quantity greater than zero."); + return; + } + + if (!Number.isFinite(unitPrice) || unitPrice < 0) { + toast.error("Enter a unit price of zero or more."); + return; + } + + onChange([...lines, { rawMaterialId: pendingRawMaterialId, quantity, unitPrice }]); + setPendingRawMaterialId(""); + setPendingQuantity(""); + setPendingUnitPrice(""); + }; + + const removeLine = (rawMaterialId: string) => + onChange(lines.filter((l) => l.rawMaterialId !== rawMaterialId)); + + const updateLine = (rawMaterialId: string, field: "quantity" | "unitPrice", value: string) => { + const parsed = Number(value); + onChange( + lines.map((l) => + l.rawMaterialId === rawMaterialId && Number.isFinite(parsed) ? { ...l, [field]: parsed } : l, + ), + ); + }; + + return ( +
+ {lines.length === 0 ? ( + + ) : ( +
+ {lines.map((line) => { + const material = byId(line.rawMaterialId); + + return ( +
+
+

{material?.name ?? "Unknown"}

+
+ updateLine(line.rawMaterialId, "quantity", e.target.value)} + className="w-24" + aria-label={`Quantity of ${material?.name ?? "raw material"}`} + /> + + {material ? UNIT_ABBREVIATIONS[material.unitOfMeasurement] : ""} + + @ + updateLine(line.rawMaterialId, "unitPrice", e.target.value)} + className="w-28" + aria-label={`Unit price of ${material?.name ?? "raw material"}`} + /> + + = {(line.quantity * line.unitPrice).toFixed(2)} + + +
+ ); + })} + +
+ Total: {total.toFixed(2)} +
+
+ )} + + {availableToAdd.length > 0 && ( +
+
+ + +
+
+ + setPendingQuantity(e.target.value)} + placeholder="5" + /> +
+
+ + setPendingUnitPrice(e.target.value)} + placeholder="250.00" + /> +
+ +
+ )} +
+ ); +} diff --git a/frontend/src/features/purchase-orders/ui/RecordPaymentDialog.tsx b/frontend/src/features/purchase-orders/ui/RecordPaymentDialog.tsx new file mode 100644 index 0000000..c2d12f6 --- /dev/null +++ b/frontend/src/features/purchase-orders/ui/RecordPaymentDialog.tsx @@ -0,0 +1,160 @@ +import { Controller, useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { PAYMENT_METHODS, type PaymentMethod } from "@/entities/supplier"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + FormField, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/ui"; +import { toApiError } from "@/shared/api/problem"; +import { usePurchaseOrderMutations } from "../model/usePurchaseOrders"; + +export interface RecordPaymentDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + purchaseOrderId: string; + supplierId: string; + /** Shown so the person recording the payment can see how much is left to pay. */ + balance: number; +} + +interface FormValues { + amount: string; + paymentDateUtc: string; + method: PaymentMethod; + invoiceReference: string; + notes: string; +} + +const todayIso = () => new Date().toISOString().slice(0, 10); + +/** Records a payment toward a purchase order's outstanding balance. */ +export function RecordPaymentDialog({ + open, + onOpenChange, + purchaseOrderId, + supplierId, + balance, +}: RecordPaymentDialogProps) { + const { recordPayment } = usePurchaseOrderMutations(); + + const defaults: FormValues = { + amount: balance > 0 ? balance.toFixed(2) : "", + paymentDateUtc: todayIso(), + method: "Cash", + invoiceReference: "", + notes: "", + }; + + const { + control, + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ defaultValues: defaults }); + + const close = (isOpen: boolean) => { + if (!isOpen) reset(defaults); + onOpenChange(isOpen); + }; + + const onSubmit = handleSubmit(async (values) => { + const amount = Number(values.amount); + + if (!Number.isFinite(amount) || amount <= 0) { + toast.error("Enter a payment amount greater than zero."); + return; + } + + try { + await recordPayment.mutateAsync({ + id: purchaseOrderId, + supplierId, + payload: { + amount, + paymentDateUtc: new Date(values.paymentDateUtc).toISOString(), + method: values.method, + invoiceReference: values.invoiceReference.trim() || null, + notes: values.notes.trim() || null, + }, + }); + toast.success("Payment recorded."); + close(false); + } catch (error) { + toast.error(toApiError(error).message); + } + }); + + return ( + + + + Record payment + Balance outstanding: {balance.toFixed(2)} + + +
+
+ + + + + + + +
+ + + ( + + )} + /> + + + + + + + + + + + + + + +
+
+
+ ); +} diff --git a/frontend/src/features/raw-materials/api/rawMaterialsApi.ts b/frontend/src/features/raw-materials/api/rawMaterialsApi.ts new file mode 100644 index 0000000..8aeb04d --- /dev/null +++ b/frontend/src/features/raw-materials/api/rawMaterialsApi.ts @@ -0,0 +1,24 @@ +import type { + CreateRawMaterialPayload, + RawMaterial, + RawMaterialFilters, + UpdateRawMaterialPayload, +} from "@/entities/raw-material"; +import { API_ENDPOINTS, apiService } from "@/shared/api/endpoints"; + +export const rawMaterialsApi = { + list: (filters: RawMaterialFilters = {}) => + apiService.get(API_ENDPOINTS.RAW_MATERIALS.BASE, { + search: filters.search || undefined, + isActive: filters.isActive, + }), + + create: (payload: CreateRawMaterialPayload) => + apiService.post(API_ENDPOINTS.RAW_MATERIALS.BASE, payload), + + update: (id: string, payload: UpdateRawMaterialPayload) => + apiService.put(API_ENDPOINTS.RAW_MATERIALS.BY_ID(id), payload), + + setActive: (id: string, isActive: boolean) => + apiService.put(API_ENDPOINTS.RAW_MATERIALS.STATUS(id), { isActive }), +}; diff --git a/frontend/src/features/raw-materials/index.ts b/frontend/src/features/raw-materials/index.ts new file mode 100644 index 0000000..046a389 --- /dev/null +++ b/frontend/src/features/raw-materials/index.ts @@ -0,0 +1,3 @@ +export { rawMaterialsApi } from "./api/rawMaterialsApi"; +export { useRawMaterials, useRawMaterialMutations } from "./model/useRawMaterials"; +export { RawMaterialFormDialog } from "./ui/RawMaterialFormDialog"; diff --git a/frontend/src/features/raw-materials/model/rawMaterialSchema.ts b/frontend/src/features/raw-materials/model/rawMaterialSchema.ts new file mode 100644 index 0000000..9f96b36 --- /dev/null +++ b/frontend/src/features/raw-materials/model/rawMaterialSchema.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; +import { UNITS_OF_MEASUREMENT } from "@/entities/raw-material"; + +const optionalNonNegativeNumber = z + .string() + .trim() + .refine((v) => v === "" || (!Number.isNaN(Number(v)) && Number(v) >= 0), { + message: "Enter a non-negative number, or leave blank.", + }); + +export const rawMaterialSchema = z.object({ + name: z.string().trim().min(1, "Name is required.").max(150), + unitOfMeasurement: z.enum(UNITS_OF_MEASUREMENT as [string, ...string[]]), + mainStoreReorderLevel: optionalNonNegativeNumber, + kitchenParLevel: optionalNonNegativeNumber, +}); + +export type RawMaterialForm = z.infer; + +/** Converts the form's empty-string "no threshold" sentinel to the `null` the API expects. */ +export const toNullableThreshold = (value: string): number | null => (value === "" ? null : Number(value)); diff --git a/frontend/src/features/raw-materials/model/useRawMaterials.ts b/frontend/src/features/raw-materials/model/useRawMaterials.ts new file mode 100644 index 0000000..5542c86 --- /dev/null +++ b/frontend/src/features/raw-materials/model/useRawMaterials.ts @@ -0,0 +1,40 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { + CreateRawMaterialPayload, + RawMaterial, + RawMaterialFilters, + UpdateRawMaterialPayload, +} from "@/entities/raw-material"; +import { rawMaterialsApi } from "../api/rawMaterialsApi"; + +const RAW_MATERIALS_KEY = "raw-materials"; + +export function useRawMaterials(filters: RawMaterialFilters = {}) { + return useQuery({ + queryKey: [RAW_MATERIALS_KEY, filters], + queryFn: () => rawMaterialsApi.list(filters), + placeholderData: (previous) => previous, + }); +} + +export function useRawMaterialMutations() { + const queryClient = useQueryClient(); + const invalidate = () => queryClient.invalidateQueries({ queryKey: [RAW_MATERIALS_KEY] }); + + const create = useMutation({ + mutationFn: rawMaterialsApi.create, + onSuccess: invalidate, + }); + + const update = useMutation({ + mutationFn: ({ id, payload }) => rawMaterialsApi.update(id, payload), + onSuccess: invalidate, + }); + + const setActive = useMutation({ + mutationFn: ({ id, isActive }) => rawMaterialsApi.setActive(id, isActive), + onSuccess: invalidate, + }); + + return { create, update, setActive }; +} diff --git a/frontend/src/features/raw-materials/ui/RawMaterialFormDialog.tsx b/frontend/src/features/raw-materials/ui/RawMaterialFormDialog.tsx new file mode 100644 index 0000000..8c08ea5 --- /dev/null +++ b/frontend/src/features/raw-materials/ui/RawMaterialFormDialog.tsx @@ -0,0 +1,148 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { Controller, useForm } from "react-hook-form"; +import { toast } from "sonner"; +import type { RawMaterial, UnitOfMeasurement } from "@/entities/raw-material"; +import { UNITS_OF_MEASUREMENT } from "@/entities/raw-material"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + FormField, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/ui"; +import { useRawMaterialMutations } from "../model/useRawMaterials"; +import { RawMaterialForm, rawMaterialSchema, toNullableThreshold } from "../model/rawMaterialSchema"; + +export interface RawMaterialFormDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** The raw material being edited, or omitted to create a new one. */ + rawMaterial?: RawMaterial; +} + +/** Creates or edits a raw material: its name, unit of measurement, and stock thresholds. */ +export function RawMaterialFormDialog({ open, onOpenChange, rawMaterial }: RawMaterialFormDialogProps) { + const isEditing = !!rawMaterial; + const { create, update } = useRawMaterialMutations(); + const pending = create.isPending || update.isPending; + + const defaults = { + name: rawMaterial?.name ?? "", + unitOfMeasurement: rawMaterial?.unitOfMeasurement ?? "Kilogram", + mainStoreReorderLevel: rawMaterial?.mainStoreReorderLevel != null ? String(rawMaterial.mainStoreReorderLevel) : "", + kitchenParLevel: rawMaterial?.kitchenParLevel != null ? String(rawMaterial.kitchenParLevel) : "", + }; + + const { + register, + handleSubmit, + control, + reset, + formState: { errors }, + } = useForm({ resolver: zodResolver(rawMaterialSchema), defaultValues: defaults }); + + const close = (isOpen: boolean) => { + if (!isOpen) reset(defaults); + onOpenChange(isOpen); + }; + + const onSubmit = handleSubmit(async (values) => { + const payload = { + name: values.name, + unitOfMeasurement: values.unitOfMeasurement as UnitOfMeasurement, + mainStoreReorderLevel: toNullableThreshold(values.mainStoreReorderLevel), + kitchenParLevel: toNullableThreshold(values.kitchenParLevel), + }; + + try { + if (isEditing) { + await update.mutateAsync({ id: rawMaterial.id, payload }); + toast.success(`${values.name} was updated.`); + } else { + await create.mutateAsync(payload); + toast.success(`${values.name} was added.`); + } + close(false); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Something went wrong."); + } + }); + + return ( + + + + {isEditing ? "Edit raw material" : "Add raw material"} + + The unit chosen here is used everywhere this ingredient appears — recipes, stock counts and history. + + + +
+ + + + + + ( + + )} + /> + + +
+ + + + + + + +
+ + + + + +
+
+
+ ); +} diff --git a/frontend/src/features/recipes/api/recipesApi.ts b/frontend/src/features/recipes/api/recipesApi.ts new file mode 100644 index 0000000..9bdf98c --- /dev/null +++ b/frontend/src/features/recipes/api/recipesApi.ts @@ -0,0 +1,23 @@ +import type { Recipe, RecipeLineInput } from "@/entities/recipe"; +import { API_ENDPOINTS, apiService } from "@/shared/api/endpoints"; + +export const recipesApi = { + /** + * Null means the menu item has no recipe yet — a normal, expected state, not an error. The + * server sends a genuinely empty body for that case, which axios surfaces as `""` rather than + * JSON `null`, so that's normalised here rather than leaking into every consumer of this call. + */ + get: (menuItemId: string) => + apiService + .get(API_ENDPOINTS.MENU_ITEMS.RECIPE(menuItemId)) + .then((data) => data || null), + + /** Creates the recipe if none exists, or replaces its lines if one already does. */ + upsert: (menuItemId: string, lines: RecipeLineInput[]) => + apiService.put(API_ENDPOINTS.MENU_ITEMS.RECIPE(menuItemId), { lines }), + + setEnabled: (menuItemId: string, isEnabled: boolean) => + apiService.put(API_ENDPOINTS.MENU_ITEMS.RECIPE_STATUS(menuItemId), { isEnabled }), + + remove: (menuItemId: string) => apiService.delete(API_ENDPOINTS.MENU_ITEMS.RECIPE(menuItemId)), +}; diff --git a/frontend/src/features/recipes/index.ts b/frontend/src/features/recipes/index.ts new file mode 100644 index 0000000..555fc41 --- /dev/null +++ b/frontend/src/features/recipes/index.ts @@ -0,0 +1,3 @@ +export { recipesApi } from "./api/recipesApi"; +export { useRecipe, useRecipeMutations } from "./model/useRecipe"; +export { RecipeEditorDialog } from "./ui/RecipeEditorDialog"; diff --git a/frontend/src/features/recipes/model/useRecipe.ts b/frontend/src/features/recipes/model/useRecipe.ts new file mode 100644 index 0000000..3721ae6 --- /dev/null +++ b/frontend/src/features/recipes/model/useRecipe.ts @@ -0,0 +1,42 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { RecipeLineInput } from "@/entities/recipe"; +import { recipesApi } from "../api/recipesApi"; + +const MENU_ITEMS_KEY = "menu-items"; +const recipeKey = (menuItemId: string) => ["recipe", menuItemId]; + +/** The recipe for one menu item. `data` is `null` (not an error) when it has none. */ +export function useRecipe(menuItemId: string | undefined) { + return useQuery({ + queryKey: recipeKey(menuItemId ?? ""), + queryFn: () => recipesApi.get(menuItemId!), + enabled: !!menuItemId, + }); +} + +export function useRecipeMutations(menuItemId: string) { + const queryClient = useQueryClient(); + + const invalidate = () => { + queryClient.invalidateQueries({ queryKey: recipeKey(menuItemId) }); + // hasRecipe on the menu item list/detail depends on whether a recipe now exists. + queryClient.invalidateQueries({ queryKey: [MENU_ITEMS_KEY] }); + }; + + const upsert = useMutation({ + mutationFn: (lines: RecipeLineInput[]) => recipesApi.upsert(menuItemId, lines), + onSuccess: invalidate, + }); + + const setEnabled = useMutation({ + mutationFn: (isEnabled: boolean) => recipesApi.setEnabled(menuItemId, isEnabled), + onSuccess: invalidate, + }); + + const remove = useMutation({ + mutationFn: () => recipesApi.remove(menuItemId), + onSuccess: invalidate, + }); + + return { upsert, setEnabled, remove }; +} diff --git a/frontend/src/features/recipes/ui/RecipeEditorDialog.tsx b/frontend/src/features/recipes/ui/RecipeEditorDialog.tsx new file mode 100644 index 0000000..cbc62b8 --- /dev/null +++ b/frontend/src/features/recipes/ui/RecipeEditorDialog.tsx @@ -0,0 +1,263 @@ +import { useState } from "react"; +import { Plus, Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import type { MenuItem } from "@/entities/menu-item"; +import type { RecipeLineInput } from "@/entities/recipe"; +import { UNIT_ABBREVIATIONS } from "@/entities/raw-material"; +import { useRawMaterials } from "@/features/raw-materials"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + EmptyState, + Input, + Label, + LoadingState, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Switch, +} from "@/shared/ui"; +import { useRecipe, useRecipeMutations } from "../model/useRecipe"; + +export interface RecipeEditorDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + menuItem: MenuItem; +} + +/** + * Attaches, edits or removes a menu item's recipe: which raw materials a single sale consumes, + * and how much of each. + */ +export function RecipeEditorDialog({ open, onOpenChange, menuItem }: RecipeEditorDialogProps) { + return ( + + + onOpenChange(false)} /> + + + ); +} + +function RecipeEditorBody({ menuItem, onDone }: { menuItem: MenuItem; onDone: () => void }) { + const { data: recipe, isLoading: recipeLoading } = useRecipe(menuItem.id); + const { data: rawMaterials, isLoading: materialsLoading } = useRawMaterials({ isActive: true }); + const { upsert, setEnabled, remove } = useRecipeMutations(menuItem.id); + + const [lines, setLines] = useState(() => recipe?.lines?.map(toLineInput) ?? []); + const [linesInitialised, setLinesInitialised] = useState(false); + const [pendingRawMaterialId, setPendingRawMaterialId] = useState(""); + const [pendingQuantity, setPendingQuantity] = useState(""); + + // The recipe query resolves after the initial render, so seed local editable state the first + // time real data (or its absence) arrives rather than trying to derive it inline. + if (!linesInitialised && !recipeLoading) { + setLines(recipe?.lines?.map(toLineInput) ?? []); + setLinesInitialised(true); + } + + const nameFor = (rawMaterialId: string) => + recipe?.lines?.find((l) => l.rawMaterialId === rawMaterialId)?.rawMaterialName ?? + rawMaterials?.find((r) => r.id === rawMaterialId)?.name ?? + "Unknown"; + + const unitFor = (rawMaterialId: string) => + recipe?.lines?.find((l) => l.rawMaterialId === rawMaterialId)?.unitOfMeasurement ?? + rawMaterials?.find((r) => r.id === rawMaterialId)?.unitOfMeasurement; + + const availableToAdd = (rawMaterials ?? []).filter((r) => !lines.some((l) => l.rawMaterialId === r.id)); + + const addLine = () => { + const quantity = Number(pendingQuantity); + + if (!pendingRawMaterialId || !Number.isFinite(quantity) || quantity <= 0) { + toast.error("Choose a raw material and enter a quantity greater than zero."); + return; + } + + setLines((prev) => [...prev, { rawMaterialId: pendingRawMaterialId, quantity }]); + setPendingRawMaterialId(""); + setPendingQuantity(""); + }; + + const removeLine = (rawMaterialId: string) => + setLines((prev) => prev.filter((l) => l.rawMaterialId !== rawMaterialId)); + + const updateQuantity = (rawMaterialId: string, quantity: string) => { + const parsed = Number(quantity); + setLines((prev) => + prev.map((l) => (l.rawMaterialId === rawMaterialId ? { ...l, quantity: Number.isFinite(parsed) ? parsed : l.quantity } : l)), + ); + }; + + const handleSave = async () => { + if (lines.length === 0) { + toast.error("Add at least one raw material before saving."); + return; + } + + try { + await upsert.mutateAsync(lines); + toast.success(`Recipe saved for ${menuItem.name}.`); + onDone(); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Something went wrong."); + } + }; + + const handleToggleEnabled = async (checked: boolean) => { + try { + await setEnabled.mutateAsync(checked); + toast.success(checked ? "Recipe enabled." : "Recipe disabled — it can't be used for a new sale."); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Something went wrong."); + } + }; + + const handleRemove = async () => { + try { + await remove.mutateAsync(); + toast.success(`Recipe removed for ${menuItem.name}.`); + onDone(); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Something went wrong."); + } + }; + + const loading = recipeLoading || materialsLoading; + + return ( + <> + + Recipe for {menuItem.name} + + Ingredients here are deducted from Kitchen stock automatically whenever this item is sold. + + + + {loading ? ( + + ) : ( +
+ {recipe && ( +
+
+ +

+ {recipe.isEnabled ? "Used for new sales." : "Disabled — cannot be used for a new sale."} +

+
+ +
+ )} + +
+ {lines.length === 0 ? ( + + ) : ( + lines.map((line) => ( +
+
+

{nameFor(line.rawMaterialId)}

+
+ updateQuantity(line.rawMaterialId, e.target.value)} + className="w-24" + aria-label={`Quantity of ${nameFor(line.rawMaterialId)}`} + /> + + {UNIT_ABBREVIATIONS[unitFor(line.rawMaterialId)!]} + + +
+ )) + )} +
+ + {availableToAdd.length > 0 && ( +
+
+ + +
+
+ + setPendingQuantity(e.target.value)} + placeholder="0.25" + /> +
+ +
+ )} +
+ )} + + + {recipe ? ( + + ) : ( + + )} +
+ + +
+
+ + ); +} + +function toLineInput(line: { rawMaterialId: string; quantity: number }): RecipeLineInput { + return { rawMaterialId: line.rawMaterialId, quantity: line.quantity }; +} diff --git a/frontend/src/features/suppliers/api/suppliersApi.ts b/frontend/src/features/suppliers/api/suppliersApi.ts new file mode 100644 index 0000000..e9e4028 --- /dev/null +++ b/frontend/src/features/suppliers/api/suppliersApi.ts @@ -0,0 +1,37 @@ +import type { + Supplier, + SupplierPayload, + SupplierFilters, + SupplierPrice, + SupplierPriceHistoryEntry, + SupplierPerformance, +} from "@/entities/supplier"; +import { API_ENDPOINTS, apiService } from "@/shared/api/endpoints"; + +export const suppliersApi = { + list: (filters: SupplierFilters = {}) => + apiService.get(API_ENDPOINTS.SUPPLIERS.BASE, { + search: filters.search || undefined, + isActive: filters.isActive, + }), + + create: (payload: SupplierPayload) => apiService.post(API_ENDPOINTS.SUPPLIERS.BASE, payload), + + update: (id: string, payload: SupplierPayload) => + apiService.put(API_ENDPOINTS.SUPPLIERS.BY_ID(id), payload), + + setActive: (id: string, isActive: boolean) => + apiService.put(API_ENDPOINTS.SUPPLIERS.STATUS(id), { isActive }), + + prices: (supplierId?: string, rawMaterialId?: string) => + apiService.get(API_ENDPOINTS.SUPPLIERS.PRICES, { supplierId, rawMaterialId }), + + setPrice: (supplierId: string, rawMaterialId: string, price: number) => + apiService.put(API_ENDPOINTS.SUPPLIERS.SET_PRICE(supplierId), { rawMaterialId, price }), + + priceHistory: (supplierId: string, rawMaterialId: string) => + apiService.get(API_ENDPOINTS.SUPPLIERS.PRICE_HISTORY(supplierId, rawMaterialId)), + + performance: (supplierId: string) => + apiService.get(API_ENDPOINTS.SUPPLIERS.PERFORMANCE(supplierId)), +}; diff --git a/frontend/src/features/suppliers/index.ts b/frontend/src/features/suppliers/index.ts new file mode 100644 index 0000000..1e1fa2a --- /dev/null +++ b/frontend/src/features/suppliers/index.ts @@ -0,0 +1,12 @@ +export { suppliersApi } from "./api/suppliersApi"; +export { + useSuppliers, + useSupplierMutations, + useSupplierPrices, + useSupplierPriceHistory, + useSetSupplierPrice, + useSupplierPerformance, +} from "./model/useSuppliers"; +export { SupplierFormDialog } from "./ui/SupplierFormDialog"; +export { SetSupplierPriceDialog } from "./ui/SetSupplierPriceDialog"; +export { SupplierPriceHistoryDialog } from "./ui/SupplierPriceHistoryDialog"; diff --git a/frontend/src/features/suppliers/model/supplierSchema.ts b/frontend/src/features/suppliers/model/supplierSchema.ts new file mode 100644 index 0000000..66f8a56 --- /dev/null +++ b/frontend/src/features/suppliers/model/supplierSchema.ts @@ -0,0 +1,42 @@ +import { z } from "zod"; + +const optionalTrimmed = z.string().trim().max(200).optional().or(z.literal("")); + +const optionalNonNegativeInteger = z + .string() + .trim() + .refine((v) => v === "" || (Number.isInteger(Number(v)) && Number(v) >= 0), { + message: "Enter a whole number of zero or more, or leave blank.", + }); + +const nonNegativeInteger = z + .string() + .trim() + .refine((v) => Number.isInteger(Number(v)) && Number(v) >= 0, { + message: "Enter a whole number of zero or more.", + }); + +const optionalNonNegativeNumber = z + .string() + .trim() + .refine((v) => v === "" || (!Number.isNaN(Number(v)) && Number(v) >= 0), { + message: "Enter a non-negative number, or leave blank.", + }); + +export const supplierSchema = z.object({ + name: z.string().trim().min(1, "Name is required.").max(200), + contactName: optionalTrimmed, + phone: optionalTrimmed, + email: z.string().trim().max(200).email("Enter a valid email address.").optional().or(z.literal("")), + address: optionalTrimmed, + paymentTermsDays: nonNegativeInteger, + creditLimit: optionalNonNegativeNumber, + leadTimeDays: optionalNonNegativeInteger, +}); + +export type SupplierForm = z.infer; + +/** Converts a form's empty-string "not set" sentinel to the `null` the API expects. */ +export const toNullableString = (value: string | undefined): string | null => (value ? value : null); + +export const toNullableNumber = (value: string): number | null => (value === "" ? null : Number(value)); diff --git a/frontend/src/features/suppliers/model/useSuppliers.ts b/frontend/src/features/suppliers/model/useSuppliers.ts new file mode 100644 index 0000000..feb8845 --- /dev/null +++ b/frontend/src/features/suppliers/model/useSuppliers.ts @@ -0,0 +1,75 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { Supplier, SupplierFilters, SupplierPayload, SupplierPrice } from "@/entities/supplier"; +import { suppliersApi } from "../api/suppliersApi"; + +const SUPPLIERS_KEY = "suppliers"; +const SUPPLIER_PRICES_KEY = "supplier-prices"; +const SUPPLIER_PRICE_HISTORY_KEY = "supplier-price-history"; +const SUPPLIER_PERFORMANCE_KEY = "supplier-performance"; + +export function useSuppliers(filters: SupplierFilters = {}) { + return useQuery({ + queryKey: [SUPPLIERS_KEY, filters], + queryFn: () => suppliersApi.list(filters), + placeholderData: (previous) => previous, + }); +} + +export function useSupplierMutations() { + const queryClient = useQueryClient(); + const invalidate = () => queryClient.invalidateQueries({ queryKey: [SUPPLIERS_KEY] }); + + const create = useMutation({ + mutationFn: suppliersApi.create, + onSuccess: invalidate, + }); + + const update = useMutation({ + mutationFn: ({ id, payload }) => suppliersApi.update(id, payload), + onSuccess: invalidate, + }); + + const setActive = useMutation({ + mutationFn: ({ id, isActive }) => suppliersApi.setActive(id, isActive), + onSuccess: invalidate, + }); + + return { create, update, setActive }; +} + +/** The current price list, filterable by supplier (a price list) or raw material (a comparison). */ +export function useSupplierPrices(supplierId?: string, rawMaterialId?: string) { + return useQuery({ + queryKey: [SUPPLIER_PRICES_KEY, supplierId, rawMaterialId], + queryFn: () => suppliersApi.prices(supplierId, rawMaterialId), + enabled: !!supplierId || !!rawMaterialId, + }); +} + +export function useSupplierPriceHistory(supplierId: string, rawMaterialId: string, enabled: boolean) { + return useQuery({ + queryKey: [SUPPLIER_PRICE_HISTORY_KEY, supplierId, rawMaterialId], + queryFn: () => suppliersApi.priceHistory(supplierId, rawMaterialId), + enabled, + }); +} + +export function useSetSupplierPrice() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ supplierId, rawMaterialId, price }) => suppliersApi.setPrice(supplierId, rawMaterialId, price), + onSuccess: (_, { supplierId, rawMaterialId }) => { + queryClient.invalidateQueries({ queryKey: [SUPPLIER_PRICES_KEY] }); + queryClient.invalidateQueries({ queryKey: [SUPPLIER_PRICE_HISTORY_KEY, supplierId, rawMaterialId] }); + }, + }); +} + +export function useSupplierPerformance(supplierId: string, enabled: boolean) { + return useQuery({ + queryKey: [SUPPLIER_PERFORMANCE_KEY, supplierId], + queryFn: () => suppliersApi.performance(supplierId), + enabled, + }); +} diff --git a/frontend/src/features/suppliers/ui/SetSupplierPriceDialog.tsx b/frontend/src/features/suppliers/ui/SetSupplierPriceDialog.tsx new file mode 100644 index 0000000..af7916b --- /dev/null +++ b/frontend/src/features/suppliers/ui/SetSupplierPriceDialog.tsx @@ -0,0 +1,133 @@ +import { Controller, useForm } from "react-hook-form"; +import { toast } from "sonner"; +import type { RawMaterial } from "@/entities/raw-material"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + FormField, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/ui"; +import { toApiError } from "@/shared/api/problem"; +import { useSetSupplierPrice } from "../model/useSuppliers"; + +export interface SetSupplierPriceDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + supplierId: string; + supplierName: string; + rawMaterials: RawMaterial[]; + /** Preselects a raw material, e.g. when opened from that row's "Update price" action. */ + rawMaterialId?: string; +} + +interface FormValues { + rawMaterialId: string; + price: string; +} + +/** Records what a supplier currently charges for a raw material, keeping the prior price in history. */ +export function SetSupplierPriceDialog({ + open, + onOpenChange, + supplierId, + supplierName, + rawMaterials, + rawMaterialId, +}: SetSupplierPriceDialogProps) { + const setPrice = useSetSupplierPrice(); + + const defaults: FormValues = { rawMaterialId: rawMaterialId ?? "", price: "" }; + + const { + control, + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ defaultValues: defaults }); + + const close = (isOpen: boolean) => { + if (!isOpen) reset(defaults); + onOpenChange(isOpen); + }; + + const onSubmit = handleSubmit(async (values) => { + const price = Number(values.price); + + if (!values.rawMaterialId) { + toast.error("Choose a raw material."); + return; + } + + if (!Number.isFinite(price) || price < 0) { + toast.error("Enter a price of zero or more."); + return; + } + + try { + await setPrice.mutateAsync({ supplierId, rawMaterialId: values.rawMaterialId, price }); + toast.success("Price recorded."); + close(false); + } catch (error) { + toast.error(toApiError(error).message); + } + }); + + return ( + + + + Set price + What {supplierName} currently charges for a raw material. + + +
+ + ( + + )} + /> + + + + + + + + + + +
+
+
+ ); +} diff --git a/frontend/src/features/suppliers/ui/SupplierFormDialog.tsx b/frontend/src/features/suppliers/ui/SupplierFormDialog.tsx new file mode 100644 index 0000000..5161f87 --- /dev/null +++ b/frontend/src/features/suppliers/ui/SupplierFormDialog.tsx @@ -0,0 +1,152 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import type { Supplier } from "@/entities/supplier"; +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + FormField, + Input, +} from "@/shared/ui"; +import { toApiError } from "@/shared/api/problem"; +import { useSupplierMutations } from "../model/useSuppliers"; +import { SupplierForm, supplierSchema, toNullableNumber, toNullableString } from "../model/supplierSchema"; + +export interface SupplierFormDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + supplier?: Supplier; +} + +/** Creates or edits a supplier: contact details, credit terms and lead time. */ +export function SupplierFormDialog({ open, onOpenChange, supplier }: SupplierFormDialogProps) { + const isEditing = !!supplier; + const { create, update } = useSupplierMutations(); + const pending = create.isPending || update.isPending; + + const defaults: SupplierForm = { + name: supplier?.name ?? "", + contactName: supplier?.contactName ?? "", + phone: supplier?.phone ?? "", + email: supplier?.email ?? "", + address: supplier?.address ?? "", + paymentTermsDays: String(supplier?.paymentTermsDays ?? 0), + creditLimit: supplier?.creditLimit != null ? String(supplier.creditLimit) : "", + leadTimeDays: supplier?.leadTimeDays != null ? String(supplier.leadTimeDays) : "", + }; + + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ resolver: zodResolver(supplierSchema), defaultValues: defaults }); + + const close = (isOpen: boolean) => { + if (!isOpen) reset(defaults); + onOpenChange(isOpen); + }; + + const onSubmit = handleSubmit(async (values) => { + const payload = { + name: values.name, + contactName: toNullableString(values.contactName), + phone: toNullableString(values.phone), + email: toNullableString(values.email), + address: toNullableString(values.address), + paymentTermsDays: Number(values.paymentTermsDays), + creditLimit: toNullableNumber(values.creditLimit), + leadTimeDays: toNullableNumber(values.leadTimeDays), + }; + + try { + if (isEditing) { + await update.mutateAsync({ id: supplier.id, payload }); + toast.success(`${values.name} was updated.`); + } else { + await create.mutateAsync(payload); + toast.success(`${values.name} was added.`); + } + close(false); + } catch (error) { + toast.error(toApiError(error).message); + } + }); + + return ( + + + + {isEditing ? "Edit supplier" : "Add supplier"} + + +
+ + + + +
+ + + + + + + +
+ + + + + + + + + +
+ + + + + + + + + + + +
+ + + + + +
+
+
+ ); +} diff --git a/frontend/src/features/suppliers/ui/SupplierPriceHistoryDialog.tsx b/frontend/src/features/suppliers/ui/SupplierPriceHistoryDialog.tsx new file mode 100644 index 0000000..9e3b16f --- /dev/null +++ b/frontend/src/features/suppliers/ui/SupplierPriceHistoryDialog.tsx @@ -0,0 +1,74 @@ +import { History } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + EmptyState, + LoadingState, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/shared/ui"; +import { useSupplierPriceHistory } from "../model/useSuppliers"; + +export interface SupplierPriceHistoryDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + supplierId: string; + rawMaterialId: string; + rawMaterialName: string; +} + +/** Every price a supplier has been recorded as charging for one raw material, newest first. */ +export function SupplierPriceHistoryDialog({ + open, + onOpenChange, + supplierId, + rawMaterialId, + rawMaterialName, +}: SupplierPriceHistoryDialogProps) { + const { data: history, isLoading } = useSupplierPriceHistory(supplierId, rawMaterialId, open); + + return ( + + + + Price history + {rawMaterialName} + + + {isLoading ? ( + + ) : !history || history.length === 0 ? ( + } title="No price history yet" /> + ) : ( + + + + Price + Recorded by + Date + + + + {history.map((entry, index) => ( + + {entry.price.toFixed(2)} + {entry.recordedByName} + + {new Date(entry.recordedAtUtc).toLocaleString()} + + + ))} + +
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/inventory/kitchen/index.tsx b/frontend/src/pages/inventory/kitchen/index.tsx new file mode 100644 index 0000000..6a934aa --- /dev/null +++ b/frontend/src/pages/inventory/kitchen/index.tsx @@ -0,0 +1,67 @@ +import { useState } from "react"; +import { AlertTriangle, ClipboardList, History } from "lucide-react"; +import { StockAdjustmentDialog, useKitchenMovements, useKitchenStock } from "@/features/inventory"; +import { useRawMaterials } from "@/features/raw-materials"; +import { Button, Card, SegmentedTabs } from "@/shared/ui"; +import { MovementHistoryTable } from "../main-store/MovementHistoryTable"; +import { StockTable } from "../main-store/StockTable"; + +type Tab = "stock" | "history"; + +const TABS: ReadonlyArray<{ value: Tab; label: string; icon: React.ReactNode }> = [ + { value: "stock", label: "Current Stock", icon: }, + { value: "history", label: "History", icon: }, +]; + +export default function KitchenPage() { + const [tab, setTab] = useState("stock"); + const [adjustmentDialogOpen, setAdjustmentDialogOpen] = useState(false); + + const { data: stock, isLoading: stockLoading } = useKitchenStock(); + const { data: movements, isLoading: movementsLoading } = useKitchenMovements(); + const { data: rawMaterials } = useRawMaterials(); + + const activeRawMaterials = (rawMaterials ?? []).filter((r) => r.isActive); + const lowStockCount = (stock ?? []).filter((s) => s.isLowStock).length; + + return ( +
+
+
+

Kitchen Stock Tracking

+

+ Stock here comes only from an approved release from the Main Store, and is consumed automatically as + orders are prepared. +

+
+ +
+ + {lowStockCount > 0 && ( +
+ + + {lowStockCount} raw material{lowStockCount === 1 ? " is" : "s are"} running low — consider requesting a + release from the Main Store. + +
+ )} + + setTab(v as Tab)} /> + + + {tab === "stock" && } + {tab === "history" && } + + + +
+ ); +} diff --git a/frontend/src/pages/inventory/main-store/GoodsReceivedTable.tsx b/frontend/src/pages/inventory/main-store/GoodsReceivedTable.tsx new file mode 100644 index 0000000..0aba586 --- /dev/null +++ b/frontend/src/pages/inventory/main-store/GoodsReceivedTable.tsx @@ -0,0 +1,52 @@ +import { Truck } from "lucide-react"; +import type { GoodsReceivedNoteSummary } from "@/entities/inventory"; +import { EmptyState, LoadingState, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/ui"; + +export function GoodsReceivedTable({ + notes, + isLoading, +}: { + notes: GoodsReceivedNoteSummary[] | undefined; + isLoading: boolean; +}) { + if (isLoading) { + return ; + } + + if (!notes || notes.length === 0) { + return ( + } + title="No goods received yet" + description="Record stock coming in from a supplier with “Receive goods”." + /> + ); + } + + return ( + + + + Date + Supplier + Raw materials + Received by + Notes + + + + {notes.map((note) => ( + + + {new Date(note.receivedAtUtc).toLocaleString()} + + {note.supplierName} + {note.lineCount} + {note.receivedByName} + {note.notes ?? "—"} + + ))} + +
+ ); +} diff --git a/frontend/src/pages/inventory/main-store/MovementHistoryTable.tsx b/frontend/src/pages/inventory/main-store/MovementHistoryTable.tsx new file mode 100644 index 0000000..10a5f92 --- /dev/null +++ b/frontend/src/pages/inventory/main-store/MovementHistoryTable.tsx @@ -0,0 +1,62 @@ +import { History } from "lucide-react"; +import type { StockMovement, StockMovementType } from "@/entities/inventory"; +import { UNIT_ABBREVIATIONS } from "@/entities/raw-material"; +import { Badge, EmptyState, LoadingState, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/ui"; + +const TYPE_LABELS: Record = { + GoodsReceived: "Goods received", + StockReleaseOut: "Released to kitchen", + StockReleaseIn: "Received from store", + Adjustment: "Adjustment", + Consumption: "Sale", +}; + +export function MovementHistoryTable({ movements, isLoading }: { movements: StockMovement[] | undefined; isLoading: boolean }) { + if (isLoading) { + return ; + } + + if (!movements || movements.length === 0) { + return ( + } + title="No stock movements yet" + description="Receiving goods, releasing stock and adjustments will all show up here." + /> + ); + } + + return ( + + + + When + Raw material + Type + Change + By + Notes + + + + {movements.map((m) => ( + + + {new Date(m.occurredAtUtc).toLocaleString()} + + {m.rawMaterialName} + + {TYPE_LABELS[m.type]} + + + {m.quantityDelta > 0 ? "+" : ""} + {m.quantityDelta} {UNIT_ABBREVIATIONS[m.unitOfMeasurement]} + + {m.performedByName} + {m.notes ?? "—"} + + ))} + +
+ ); +} diff --git a/frontend/src/pages/inventory/main-store/RawMaterialsTable.tsx b/frontend/src/pages/inventory/main-store/RawMaterialsTable.tsx new file mode 100644 index 0000000..a028c83 --- /dev/null +++ b/frontend/src/pages/inventory/main-store/RawMaterialsTable.tsx @@ -0,0 +1,120 @@ +import { MoreHorizontal, Package, Pencil, ShieldCheck, ShieldOff } from "lucide-react"; +import { toast } from "sonner"; +import type { RawMaterial } from "@/entities/raw-material"; +import { UNIT_ABBREVIATIONS } from "@/entities/raw-material"; +import { useRawMaterialMutations } from "@/features/raw-materials"; +import { toApiError } from "@/shared/api/problem"; +import { + Badge, + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + EmptyState, + LoadingState, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/shared/ui"; + +export function RawMaterialsTable({ + rawMaterials, + isLoading, + onEdit, +}: { + rawMaterials: RawMaterial[] | undefined; + isLoading: boolean; + onEdit: (rawMaterial: RawMaterial) => void; +}) { + const { setActive } = useRawMaterialMutations(); + + const toggleActive = async (material: RawMaterial) => { + try { + await setActive.mutateAsync({ id: material.id, isActive: !material.isActive }); + toast.success(material.isActive ? `${material.name} was deactivated.` : `${material.name} was reactivated.`); + } catch (error) { + toast.error(toApiError(error).message); + } + }; + + if (isLoading) { + return ; + } + + if (!rawMaterials || rawMaterials.length === 0) { + return ( + } + title="No raw materials yet" + description="Add one to start building recipes and tracking stock." + /> + ); + } + + return ( + + + + Name + Unit + Main Store reorder level + Kitchen par level + Status + + + + + {rawMaterials.map((material) => ( + + {material.name} + + {UNIT_ABBREVIATIONS[material.unitOfMeasurement]} + + + {material.mainStoreReorderLevel ?? "—"} + + + {material.kitchenParLevel ?? "—"} + + + {material.isActive ? ( + Active + ) : ( + Deactivated + )} + + + + + + + + onEdit(material)}> + Edit + + toggleActive(material)}> + {material.isActive ? ( + <> + Deactivate + + ) : ( + <> + Reactivate + + )} + + + + + + ))} + +
+ ); +} diff --git a/frontend/src/pages/inventory/main-store/StockTable.tsx b/frontend/src/pages/inventory/main-store/StockTable.tsx new file mode 100644 index 0000000..9f5b40c --- /dev/null +++ b/frontend/src/pages/inventory/main-store/StockTable.tsx @@ -0,0 +1,43 @@ +import { PackageOpen } from "lucide-react"; +import type { StockLevel } from "@/entities/inventory"; +import { UNIT_ABBREVIATIONS } from "@/entities/raw-material"; +import { Badge, EmptyState, LoadingState, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/ui"; + +export function StockTable({ stock, isLoading }: { stock: StockLevel[] | undefined; isLoading: boolean }) { + if (isLoading) { + return ; + } + + if (!stock || stock.length === 0) { + return ( + } + title="No raw materials yet" + description="Add a raw material to start tracking stock." + /> + ); + } + + return ( + + + + Raw material + On hand + + + + + {stock.map((row) => ( + + {row.rawMaterialName} + + {row.quantityOnHand} {UNIT_ABBREVIATIONS[row.unitOfMeasurement]} + + {row.isLowStock && Low stock} + + ))} + +
+ ); +} diff --git a/frontend/src/pages/inventory/main-store/index.tsx b/frontend/src/pages/inventory/main-store/index.tsx new file mode 100644 index 0000000..e9228f7 --- /dev/null +++ b/frontend/src/pages/inventory/main-store/index.tsx @@ -0,0 +1,114 @@ +import { useState } from "react"; +import { AlertTriangle, ClipboardList, History, Package, Plus, Warehouse } from "lucide-react"; +import type { RawMaterial } from "@/entities/raw-material"; +import { GoodsReceivedNoteDialog, StockAdjustmentDialog, useGoodsReceivedNotes, useMainStoreMovements, useMainStoreStock } from "@/features/inventory"; +import { RawMaterialFormDialog, useRawMaterials } from "@/features/raw-materials"; +import { useSuppliers } from "@/features/suppliers"; +import { Button, Card, SegmentedTabs } from "@/shared/ui"; +import { GoodsReceivedTable } from "./GoodsReceivedTable"; +import { MovementHistoryTable } from "./MovementHistoryTable"; +import { RawMaterialsTable } from "./RawMaterialsTable"; +import { StockTable } from "./StockTable"; + +type Tab = "stock" | "raw-materials" | "goods-received" | "history"; + +const TABS: ReadonlyArray<{ value: Tab; label: string; icon: React.ReactNode }> = [ + { value: "stock", label: "Current Stock", icon: }, + { value: "raw-materials", label: "Raw Materials", icon: }, + { value: "goods-received", label: "Goods Received", icon: }, + { value: "history", label: "History", icon: }, +]; + +export default function MainStorePage() { + const [tab, setTab] = useState("stock"); + + const { data: stock, isLoading: stockLoading } = useMainStoreStock(); + const { data: rawMaterials, isLoading: rawMaterialsLoading } = useRawMaterials(); + // Suppliers are managed on their own screen; this page only needs the active list for the GRN dialog. + const { data: suppliers } = useSuppliers({ isActive: true }); + const { data: goodsReceived, isLoading: goodsReceivedLoading } = useGoodsReceivedNotes(); + const { data: movements, isLoading: movementsLoading } = useMainStoreMovements(); + + const activeRawMaterials = (rawMaterials ?? []).filter((r) => r.isActive); + const activeSuppliers = suppliers ?? []; + const lowStockCount = (stock ?? []).filter((s) => s.isLowStock).length; + + const [rawMaterialForm, setRawMaterialForm] = useState(undefined); + const [grnDialogOpen, setGrnDialogOpen] = useState(false); + const [adjustmentDialogOpen, setAdjustmentDialogOpen] = useState(false); + + return ( +
+
+
+

Store Stock Management

+

+ Receive goods from suppliers and keep the Main Store's stock accurate. +

+
+
+ + +
+
+ + {lowStockCount > 0 && ( +
+ + + {lowStockCount} raw material{lowStockCount === 1 ? " is" : "s are"} at or below its reorder level. + +
+ )} + + setTab(v as Tab)} /> + + + {tab === "stock" && } + + {tab === "raw-materials" && ( + <> +
+ +
+ + + )} + + {tab === "goods-received" && } + + {tab === "history" && } +
+ + !open && setRawMaterialForm(undefined)} + rawMaterial={rawMaterialForm ?? undefined} + /> + + + + +
+ ); +} diff --git a/frontend/src/pages/inventory/releases/index.tsx b/frontend/src/pages/inventory/releases/index.tsx new file mode 100644 index 0000000..83ff100 --- /dev/null +++ b/frontend/src/pages/inventory/releases/index.tsx @@ -0,0 +1,81 @@ +import { useState } from "react"; +import { PackageSearch, Plus } from "lucide-react"; +import { StockReleaseDialog, useStockReleases } from "@/features/inventory"; +import { useRawMaterials } from "@/features/raw-materials"; +import { + Button, + Card, + EmptyState, + LoadingState, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/shared/ui"; + +export default function ReleasesPage() { + const [dialogOpen, setDialogOpen] = useState(false); + const { data: releases, isLoading } = useStockReleases(); + const { data: rawMaterials } = useRawMaterials(); + + const activeRawMaterials = (rawMaterials ?? []).filter((r) => r.isActive); + + return ( +
+
+
+

Kitchen Stock Release

+

+ Move stock from the Main Store to the Kitchen, authorised by an administrator's PIN. +

+
+ +
+ + + {isLoading ? ( + + ) : !releases || releases.length === 0 ? ( + } + title="No stock has been released yet" + description="Release raw materials to the kitchen with the button above." + /> + ) : ( + + + + Date + Raw materials + Requested by + Approved by + Notes + + + + {releases.map((release) => ( + + + {new Date(release.requestedAtUtc).toLocaleString()} + + {release.lineCount} + {release.requestedByName} + {release.approvedByName} + + {release.notes ?? "—"} + + + ))} + +
+ )} +
+ + +
+ ); +} diff --git a/frontend/src/pages/recipes/index.tsx b/frontend/src/pages/recipes/index.tsx new file mode 100644 index 0000000..d66a29e --- /dev/null +++ b/frontend/src/pages/recipes/index.tsx @@ -0,0 +1,188 @@ +import { useState } from "react"; +import { ChefHat, MoreHorizontal, Pencil, Plus, Search, ShieldCheck, ShieldOff } from "lucide-react"; +import { toast } from "sonner"; +import type { MenuItem, MenuItemFilters } from "@/entities/menu-item"; +import { MenuItemFormDialog, useMenuItemMutations, useMenuItems } from "@/features/menu-items"; +import { RecipeEditorDialog } from "@/features/recipes"; +import { toApiError } from "@/shared/api/problem"; +import { + Badge, + Button, + Card, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + EmptyState, + Input, + LoadingState, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/shared/ui"; + +const STATUS_FILTER_ALL = "all"; + +const currency = new Intl.NumberFormat("en-LK", { style: "currency", currency: "LKR" }); + +export default function RecipesPage() { + const [search, setSearch] = useState(""); + const [statusFilter, setStatusFilter] = useState(STATUS_FILTER_ALL); + + const filters: MenuItemFilters = { + search: search || undefined, + isActive: statusFilter === STATUS_FILTER_ALL ? undefined : statusFilter === "active", + }; + + const { data: items, isLoading } = useMenuItems(filters); + const { setActive } = useMenuItemMutations(); + + const [formItem, setFormItem] = useState(undefined); + const [recipeItem, setRecipeItem] = useState(null); + + const toggleActive = async (item: MenuItem) => { + try { + await setActive.mutateAsync({ id: item.id, isActive: !item.isActive }); + toast.success(item.isActive ? `${item.name} was deactivated.` : `${item.name} was reactivated.`); + } catch (error) { + toast.error(toApiError(error).message); + } + }; + + return ( +
+
+
+

Recipe Management

+

+ Manage your menu and attach a recipe to track what each dish consumes. +

+
+ +
+ +
+
+ + setSearch(e.target.value)} + placeholder="Search by name or category…" + className="pl-9" + /> +
+ + +
+ + + {isLoading ? ( + + ) : !items || items.length === 0 ? ( + } + title="No menu items match your filters" + description="Try clearing the search or filters, or add a new item." + /> + ) : ( + + + + Name + Category + Price + Recipe + Status + + + + + {items.map((item) => ( + + {item.name} + {item.category} + {currency.format(item.price)} + + {item.hasRecipe ? ( + Recipe set + ) : ( + No recipe + )} + + + {item.isActive ? ( + Active + ) : ( + Deactivated + )} + + + + + + + + setRecipeItem(item)}> + Manage recipe + + setFormItem(item)}> + Edit + + toggleActive(item)}> + {item.isActive ? ( + <> + Deactivate + + ) : ( + <> + Reactivate + + )} + + + + + + ))} + +
+ )} +
+ + !open && setFormItem(undefined)} + item={formItem ?? undefined} + /> + + {recipeItem && ( + !open && setRecipeItem(null)} + menuItem={recipeItem} + /> + )} +
+ ); +} diff --git a/frontend/src/pages/suppliers/PurchaseOrdersTable.tsx b/frontend/src/pages/suppliers/PurchaseOrdersTable.tsx new file mode 100644 index 0000000..bbf2b4d --- /dev/null +++ b/frontend/src/pages/suppliers/PurchaseOrdersTable.tsx @@ -0,0 +1,140 @@ +import { ClipboardList, Eye, MoreHorizontal, Pencil, Send, ShieldCheck, XCircle } from "lucide-react"; +import { toast } from "sonner"; +import type { PurchaseOrderSummary } from "@/entities/supplier"; +import { PURCHASE_ORDER_STATUS_BADGE, usePurchaseOrderMutations } from "@/features/purchase-orders"; +import { toApiError } from "@/shared/api/problem"; +import { + Badge, + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + EmptyState, + LoadingState, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/shared/ui"; + +export function PurchaseOrdersTable({ + orders, + isLoading, + onView, + onEdit, +}: { + orders: PurchaseOrderSummary[] | undefined; + isLoading: boolean; + onView: (order: PurchaseOrderSummary) => void; + onEdit: (order: PurchaseOrderSummary) => void; +}) { + const { submit, confirm, cancel } = usePurchaseOrderMutations(); + + const submitOrder = async (order: PurchaseOrderSummary) => { + try { + await submit.mutateAsync(order.id); + toast.success("Purchase order submitted to the supplier."); + } catch (error) { + toast.error(toApiError(error).message); + } + }; + + const confirmOrder = async (order: PurchaseOrderSummary) => { + try { + await confirm.mutateAsync(order.id); + toast.success("Purchase order confirmed."); + } catch (error) { + toast.error(toApiError(error).message); + } + }; + + const cancelOrder = async (order: PurchaseOrderSummary) => { + try { + await cancel.mutateAsync(order.id); + toast.success("Purchase order cancelled."); + } catch (error) { + toast.error(toApiError(error).message); + } + }; + + if (isLoading) { + return ; + } + + if (!orders || orders.length === 0) { + return ( + } + title="No purchase orders yet" + description="Create one to start ordering from a supplier." + /> + ); + } + + return ( + + + + Date + Supplier + Status + Total + Balance + + + + + {orders.map((order) => ( + + + {new Date(order.createdAtUtc).toLocaleDateString()} + + {order.supplierName} + + {order.status} + + {order.totalAmount.toFixed(2)} + {order.balance.toFixed(2)} + + + + + + + onView(order)}> + View details + + {order.status === "Draft" && ( + <> + onEdit(order)}> + Edit draft + + submitOrder(order)}> + Submit to supplier + + + )} + {order.status === "Submitted" && ( + confirmOrder(order)}> + Mark confirmed + + )} + {order.status !== "Delivered" && order.status !== "Cancelled" && ( + cancelOrder(order)}> + Cancel order + + )} + + + + + ))} + +
+ ); +} diff --git a/frontend/src/pages/suppliers/SupplierPerformancePanel.tsx b/frontend/src/pages/suppliers/SupplierPerformancePanel.tsx new file mode 100644 index 0000000..09011a6 --- /dev/null +++ b/frontend/src/pages/suppliers/SupplierPerformancePanel.tsx @@ -0,0 +1,93 @@ +import { useState } from "react"; +import { BarChart3 } from "lucide-react"; +import type { Supplier } from "@/entities/supplier"; +import { useSupplierPerformance } from "@/features/suppliers"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + EmptyState, + LoadingState, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/ui"; + +function StatCard({ label, value, hint }: { label: string; value: string; hint?: string }) { + return ( + + + {label} + {value} + + {hint && ( + {hint} + )} + + ); +} + +export function SupplierPerformancePanel({ suppliers }: { suppliers: Supplier[] }) { + const [supplierId, setSupplierId] = useState(suppliers[0]?.id ?? ""); + const { data: performance, isLoading } = useSupplierPerformance(supplierId, !!supplierId); + + if (suppliers.length === 0) { + return ( + } title="Add a supplier first" description="Performance is derived from their orders and deliveries." /> + ); + } + + return ( +
+
+ +
+ + {isLoading ? ( + + ) : !performance ? ( + } title="No data yet" /> + ) : performance.totalOrders === 0 ? ( + } + title="No orders yet" + description="Performance builds up once purchase orders have been placed and delivered." + /> + ) : ( +
+ + + + +
+ )} +
+ ); +} diff --git a/frontend/src/pages/suppliers/SupplierPricingPanel.tsx b/frontend/src/pages/suppliers/SupplierPricingPanel.tsx new file mode 100644 index 0000000..0efeffc --- /dev/null +++ b/frontend/src/pages/suppliers/SupplierPricingPanel.tsx @@ -0,0 +1,137 @@ +import { useState } from "react"; +import { History, Pencil, Tag } from "lucide-react"; +import type { Supplier } from "@/entities/supplier"; +import type { RawMaterial } from "@/entities/raw-material"; +import { + SetSupplierPriceDialog, + SupplierPriceHistoryDialog, + useSupplierPrices, +} from "@/features/suppliers"; +import { + Button, + EmptyState, + LoadingState, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/shared/ui"; + +export function SupplierPricingPanel({ suppliers, rawMaterials }: { suppliers: Supplier[]; rawMaterials: RawMaterial[] }) { + const [supplierId, setSupplierId] = useState(suppliers[0]?.id ?? ""); + const { data: prices, isLoading } = useSupplierPrices(supplierId || undefined); + + const [priceDialog, setPriceDialog] = useState<{ rawMaterialId?: string } | null>(null); + const [historyEntry, setHistoryEntry] = useState<{ rawMaterialId: string; rawMaterialName: string } | null>(null); + + const supplier = suppliers.find((s) => s.id === supplierId); + + if (suppliers.length === 0) { + return } title="Add a supplier first" description="Pricing is recorded per supplier." />; + } + + return ( +
+
+
+ +
+ +
+ + {isLoading ? ( + + ) : !prices || prices.length === 0 ? ( + } title="No prices recorded yet" description="Set what this supplier charges for a raw material." /> + ) : ( + + + + Raw material + Price + Updated + + + + + {prices.map((price) => ( + + {price.rawMaterialName} + + {price.price.toFixed(2)} + / {price.unitOfMeasurement} + + + {new Date(price.updatedAtUtc).toLocaleDateString()} + + +
+ + +
+
+
+ ))} +
+
+ )} + + {priceDialog && supplier && ( + !open && setPriceDialog(null)} + supplierId={supplier.id} + supplierName={supplier.name} + rawMaterials={rawMaterials} + rawMaterialId={priceDialog.rawMaterialId} + /> + )} + + {historyEntry && supplierId && ( + !open && setHistoryEntry(null)} + supplierId={supplierId} + rawMaterialId={historyEntry.rawMaterialId} + rawMaterialName={historyEntry.rawMaterialName} + /> + )} +
+ ); +} diff --git a/frontend/src/pages/suppliers/SuppliersTable.tsx b/frontend/src/pages/suppliers/SuppliersTable.tsx new file mode 100644 index 0000000..ac1c471 --- /dev/null +++ b/frontend/src/pages/suppliers/SuppliersTable.tsx @@ -0,0 +1,111 @@ +import { MoreHorizontal, Pencil, ShieldCheck, ShieldOff, Truck } from "lucide-react"; +import { toast } from "sonner"; +import type { Supplier } from "@/entities/supplier"; +import { useSupplierMutations } from "@/features/suppliers"; +import { toApiError } from "@/shared/api/problem"; +import { + Badge, + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + EmptyState, + LoadingState, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/shared/ui"; + +export function SuppliersTable({ + suppliers, + isLoading, + onEdit, +}: { + suppliers: Supplier[] | undefined; + isLoading: boolean; + onEdit: (supplier: Supplier) => void; +}) { + const { setActive } = useSupplierMutations(); + + const toggleActive = async (supplier: Supplier) => { + try { + await setActive.mutateAsync({ id: supplier.id, isActive: !supplier.isActive }); + toast.success(supplier.isActive ? `${supplier.name} was deactivated.` : `${supplier.name} was reactivated.`); + } catch (error) { + toast.error(toApiError(error).message); + } + }; + + if (isLoading) { + return ; + } + + if (!suppliers || suppliers.length === 0) { + return ( + } title="No suppliers yet" description="Add one to get started." /> + ); + } + + return ( + + + + Name + Contact + Phone + Terms + Status + + + + + {suppliers.map((supplier) => ( + + {supplier.name} + {supplier.contactName ?? "—"} + {supplier.phone ?? "—"} + + {supplier.paymentTermsDays === 0 ? "Cash on delivery" : `${supplier.paymentTermsDays} days`} + + + {supplier.isActive ? ( + Active + ) : ( + Deactivated + )} + + + + + + + + onEdit(supplier)}> + Edit + + toggleActive(supplier)}> + {supplier.isActive ? ( + <> + Deactivate + + ) : ( + <> + Reactivate + + )} + + + + + + ))} + +
+ ); +} diff --git a/frontend/src/pages/suppliers/index.tsx b/frontend/src/pages/suppliers/index.tsx new file mode 100644 index 0000000..0d9b584 --- /dev/null +++ b/frontend/src/pages/suppliers/index.tsx @@ -0,0 +1,148 @@ +import { useState } from "react"; +import { BarChart3, ClipboardList, Plus, Tag, Truck } from "lucide-react"; +import type { PurchaseOrderStatus, PurchaseOrderSummary, Supplier } from "@/entities/supplier"; +import { PURCHASE_ORDER_STATUSES } from "@/entities/supplier"; +import { + PurchaseOrderDetailDialog, + PurchaseOrderFormDialog, + usePurchaseOrder, + usePurchaseOrders, +} from "@/features/purchase-orders"; +import { useRawMaterials } from "@/features/raw-materials"; +import { SupplierFormDialog, useSuppliers } from "@/features/suppliers"; +import { + Button, + Card, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + SegmentedTabs, +} from "@/shared/ui"; +import { PurchaseOrdersTable } from "./PurchaseOrdersTable"; +import { SuppliersTable } from "./SuppliersTable"; +import { SupplierPerformancePanel } from "./SupplierPerformancePanel"; +import { SupplierPricingPanel } from "./SupplierPricingPanel"; + +type Tab = "suppliers" | "purchase-orders" | "pricing" | "performance"; + +const TABS: ReadonlyArray<{ value: Tab; label: string; icon: React.ReactNode }> = [ + { value: "suppliers", label: "Suppliers", icon: }, + { value: "purchase-orders", label: "Purchase Orders", icon: }, + { value: "pricing", label: "Pricing", icon: }, + { value: "performance", label: "Performance", icon: }, +]; + +export default function SuppliersPage() { + const [tab, setTab] = useState("suppliers"); + const [statusFilter, setStatusFilter] = useState("All"); + + const { data: suppliers, isLoading: suppliersLoading } = useSuppliers(); + const { data: rawMaterials } = useRawMaterials(); + const { data: orders, isLoading: ordersLoading } = usePurchaseOrders( + statusFilter === "All" ? {} : { status: statusFilter }, + ); + + const activeSuppliers = (suppliers ?? []).filter((s) => s.isActive); + const activeRawMaterials = (rawMaterials ?? []).filter((r) => r.isActive); + + const [supplierForm, setSupplierForm] = useState(undefined); + const [poCreateOpen, setPoCreateOpen] = useState(false); + const [poEditId, setPoEditId] = useState(null); + const [poViewId, setPoViewId] = useState(null); + + const { data: editingOrder } = usePurchaseOrder(poEditId ?? undefined); + + return ( +
+
+
+

Supplier Management

+

+ Suppliers, purchase orders, negotiated pricing and delivery performance. +

+
+
+ {tab === "suppliers" && ( + + )} + {tab === "purchase-orders" && ( + + )} +
+
+ + setTab(v as Tab)} /> + + + {tab === "suppliers" && ( + + )} + + {tab === "purchase-orders" && ( + <> +
+
+ +
+
+ setPoViewId(order.id)} + onEdit={(order: PurchaseOrderSummary) => setPoEditId(order.id)} + /> + + )} + + {tab === "pricing" && } + + {tab === "performance" && } +
+ + !open && setSupplierForm(undefined)} + supplier={supplierForm ?? undefined} + /> + + { + if (!open) { + setPoCreateOpen(false); + setPoEditId(null); + } + }} + suppliers={activeSuppliers} + rawMaterials={activeRawMaterials} + order={poEditId ? editingOrder : undefined} + /> + + {poViewId && ( + !open && setPoViewId(null)} + purchaseOrderId={poViewId} + /> + )} +
+ ); +} diff --git a/frontend/src/shared/api/endpoints/index.ts b/frontend/src/shared/api/endpoints/index.ts index aed08d5..7597803 100644 --- a/frontend/src/shared/api/endpoints/index.ts +++ b/frontend/src/shared/api/endpoints/index.ts @@ -20,6 +20,49 @@ export const API_ENDPOINTS = { STATUS: (id: string) => `/users/${id}/status`, PASSWORD: (id: string) => `/users/${id}/password`, }, + MENU_ITEMS: { + BASE: "/menu-items", + BY_ID: (id: string) => `/menu-items/${id}`, + STATUS: (id: string) => `/menu-items/${id}/status`, + RECIPE: (menuItemId: string) => `/menu-items/${menuItemId}/recipe`, + RECIPE_STATUS: (menuItemId: string) => `/menu-items/${menuItemId}/recipe/status`, + }, + RAW_MATERIALS: { + BASE: "/raw-materials", + BY_ID: (id: string) => `/raw-materials/${id}`, + STATUS: (id: string) => `/raw-materials/${id}/status`, + }, + SUPPLIERS: { + BASE: "/suppliers", + BY_ID: (id: string) => `/suppliers/${id}`, + STATUS: (id: string) => `/suppliers/${id}/status`, + PRICES: "/suppliers/prices", + SET_PRICE: (id: string) => `/suppliers/${id}/prices`, + PRICE_HISTORY: (id: string, rawMaterialId: string) => + `/suppliers/${id}/prices/${rawMaterialId}/history`, + PERFORMANCE: (id: string) => `/suppliers/${id}/performance`, + }, + PURCHASE_ORDERS: { + BASE: "/purchase-orders", + BY_ID: (id: string) => `/purchase-orders/${id}`, + SUBMIT: (id: string) => `/purchase-orders/${id}/submit`, + CONFIRM: (id: string) => `/purchase-orders/${id}/confirm`, + CANCEL: (id: string) => `/purchase-orders/${id}/cancel`, + PAYMENTS: (id: string) => `/purchase-orders/${id}/payments`, + }, + INVENTORY: { + MAIN_STORE_STOCK: "/inventory/main-store/stock", + MAIN_STORE_MOVEMENTS: "/inventory/main-store/movements", + MAIN_STORE_ADJUSTMENTS: "/inventory/main-store/adjustments", + GOODS_RECEIVED: "/inventory/main-store/goods-received", + GOODS_RECEIVED_BY_ID: (id: string) => `/inventory/main-store/goods-received/${id}`, + KITCHEN_STOCK: "/inventory/kitchen/stock", + KITCHEN_MOVEMENTS: "/inventory/kitchen/movements", + KITCHEN_ADJUSTMENTS: "/inventory/kitchen/adjustments", + CONSUMPTION: (menuItemId: string) => `/inventory/kitchen/consumption/${menuItemId}`, + RELEASES: "/inventory/releases", + RELEASE_BY_ID: (id: string) => `/inventory/releases/${id}`, + }, } as const; /** Thin typed wrapper that unwraps `response.data`. */ diff --git a/frontend/src/shared/config/moduleRoutes.ts b/frontend/src/shared/config/moduleRoutes.ts index 14fc676..f01a2e7 100644 --- a/frontend/src/shared/config/moduleRoutes.ts +++ b/frontend/src/shared/config/moduleRoutes.ts @@ -35,15 +35,15 @@ export interface ModuleRoute { export const MODULE_ROUTES: Record = { PosBilling: { path: "/checkout", icon: Receipt }, - RecipeManagement: { icon: ChefHat }, - StoreStockManagement: { icon: Warehouse }, - KitchenStockRelease: { icon: PackageSearch }, - KitchenStockTracking: { icon: ClipboardList }, + RecipeManagement: { path: "/recipes", icon: ChefHat }, + StoreStockManagement: { path: "/inventory/main-store", icon: Warehouse }, + KitchenStockRelease: { path: "/inventory/releases", icon: PackageSearch }, + KitchenStockTracking: { path: "/inventory/kitchen", icon: ClipboardList }, KitchenOperations: { icon: ChefHat }, ReportsAnalytics: { path: "/reports", icon: BarChart3 }, Notifications: { icon: Bell }, UserManagement: { path: "/users", icon: Users }, - SupplierManagement: { icon: Truck }, + SupplierManagement: { path: "/suppliers", icon: Truck }, ExpensesManagement: { icon: Receipt }, SystemSettings: { icon: Settings }, }; diff --git a/frontend/src/shared/ui/index.ts b/frontend/src/shared/ui/index.ts index 9d5277f..aff75dc 100644 --- a/frontend/src/shared/ui/index.ts +++ b/frontend/src/shared/ui/index.ts @@ -39,3 +39,4 @@ export { DropdownMenuSeparator, DropdownMenuGroup, } from "./dropdown-menu"; +export { SegmentedTabs, type SegmentedTabsProps } from "./segmented-tabs"; diff --git a/frontend/src/shared/ui/segmented-tabs.tsx b/frontend/src/shared/ui/segmented-tabs.tsx new file mode 100644 index 0000000..6390373 --- /dev/null +++ b/frontend/src/shared/ui/segmented-tabs.tsx @@ -0,0 +1,47 @@ +import * as React from "react"; +import { cn } from "@/shared/lib/utils"; + +export interface SegmentedTabsProps { + tabs: ReadonlyArray<{ value: string; label: string; icon?: React.ReactNode }>; + value: string; + onValueChange: (value: string) => void; + className?: string; +} + +/** + * A simple, accessible tab switcher for a page with a handful of local sections. Built from + * plain buttons rather than a Radix primitive — for a handful of in-page views like these, + * hand-rolled ARIA tab semantics are all that's needed, and it avoids a dependency this project + * doesn't otherwise use. + */ +export function SegmentedTabs({ tabs, value, onValueChange, className }: SegmentedTabsProps) { + return ( +
+ {tabs.map((tab) => { + const selected = tab.value === value; + + return ( + + ); + })} +
+ ); +} diff --git a/frontend/tests/components/StockLinesEditor.test.tsx b/frontend/tests/components/StockLinesEditor.test.tsx new file mode 100644 index 0000000..1c50695 --- /dev/null +++ b/frontend/tests/components/StockLinesEditor.test.tsx @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { StockLinesEditor } from "@/features/inventory"; +import type { RawMaterial } from "@/entities/raw-material"; + +const rawMaterials: RawMaterial[] = [ + { id: "rice", name: "Rice", unitOfMeasurement: "Kilogram", mainStoreReorderLevel: null, kitchenParLevel: null, isActive: true }, + { id: "chicken", name: "Chicken", unitOfMeasurement: "Kilogram", mainStoreReorderLevel: null, kitchenParLevel: null, isActive: true }, +]; + +describe("StockLinesEditor", () => { + it("shows an empty state with no lines", () => { + render(); + + expect(screen.getByText("No raw materials added yet")).toBeInTheDocument(); + }); + + it("adds a line with the chosen raw material and quantity", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + await user.click(screen.getByRole("combobox")); + await user.click(screen.getByRole("option", { name: "Rice" })); + await user.type(screen.getByLabelText("Quantity"), "5"); + await user.click(screen.getByRole("button", { name: /add/i })); + + expect(onChange).toHaveBeenCalledWith([{ rawMaterialId: "rice", quantity: 5 }]); + }); + + it("refuses to add without a quantity greater than zero", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + await user.click(screen.getByRole("combobox")); + await user.click(screen.getByRole("option", { name: "Rice" })); + await user.click(screen.getByRole("button", { name: /add/i })); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it("excludes a raw material already on the list from the picker", async () => { + const user = userEvent.setup(); + render( + , + ); + + expect(screen.getByText("Rice")).toBeInTheDocument(); + + await user.click(screen.getByRole("combobox")); + + // Only Chicken remains selectable — Rice is already a line, not an option. + expect(screen.getByRole("option", { name: "Chicken" })).toBeInTheDocument(); + expect(screen.queryByRole("option", { name: "Rice" })).not.toBeInTheDocument(); + }); + + it("removes a line", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render( + , + ); + + await user.click(screen.getByRole("button", { name: /remove rice/i })); + + expect(onChange).toHaveBeenCalledWith([]); + }); +}); diff --git a/frontend/tests/unit/features/menuItemSchema.test.ts b/frontend/tests/unit/features/menuItemSchema.test.ts new file mode 100644 index 0000000..736aa4e --- /dev/null +++ b/frontend/tests/unit/features/menuItemSchema.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { menuItemSchema, toPriceNumber } from "@/features/menu-items/model/menuItemSchema"; + +const valid = { name: "Chicken Fried Rice", category: "Rice & Curry", price: "850" }; + +describe("menuItemSchema", () => { + it("accepts a well-formed submission", () => { + expect(menuItemSchema.safeParse(valid).success).toBe(true); + }); + + it("requires a name and category", () => { + expect(menuItemSchema.safeParse({ ...valid, name: "" }).success).toBe(false); + expect(menuItemSchema.safeParse({ ...valid, category: "" }).success).toBe(false); + }); + + it.each(["", "abc", "-1", "-0.01"])("rejects an invalid price: %s", (price) => { + expect(menuItemSchema.safeParse({ ...valid, price }).success).toBe(false); + }); + + it.each(["0", "850", "12.50"])("accepts a valid non-negative price: %s", (price) => { + expect(menuItemSchema.safeParse({ ...valid, price }).success).toBe(true); + }); +}); + +describe("toPriceNumber", () => { + it("converts the form's string price to a number", () => { + expect(toPriceNumber("850")).toBe(850); + expect(toPriceNumber("12.5")).toBe(12.5); + }); +}); diff --git a/frontend/tests/unit/features/rawMaterialSchema.test.ts b/frontend/tests/unit/features/rawMaterialSchema.test.ts new file mode 100644 index 0000000..8f66146 --- /dev/null +++ b/frontend/tests/unit/features/rawMaterialSchema.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { rawMaterialSchema, toNullableThreshold } from "@/features/raw-materials/model/rawMaterialSchema"; + +const valid = { + name: "Rice", + unitOfMeasurement: "Kilogram", + mainStoreReorderLevel: "", + kitchenParLevel: "", +}; + +describe("rawMaterialSchema", () => { + it("accepts a submission with no thresholds set", () => { + expect(rawMaterialSchema.safeParse(valid).success).toBe(true); + }); + + it("accepts thresholds when provided", () => { + const result = rawMaterialSchema.safeParse({ ...valid, mainStoreReorderLevel: "10", kitchenParLevel: "2" }); + expect(result.success).toBe(true); + }); + + it("rejects a negative threshold", () => { + expect(rawMaterialSchema.safeParse({ ...valid, mainStoreReorderLevel: "-1" }).success).toBe(false); + }); + + it("rejects an unrecognised unit of measurement", () => { + expect(rawMaterialSchema.safeParse({ ...valid, unitOfMeasurement: "Stone" }).success).toBe(false); + }); + + it("requires a name", () => { + expect(rawMaterialSchema.safeParse({ ...valid, name: "" }).success).toBe(false); + }); +}); + +describe("toNullableThreshold", () => { + it("converts the empty-string sentinel to null", () => { + expect(toNullableThreshold("")).toBeNull(); + }); + + it("converts a populated value to a number", () => { + expect(toNullableThreshold("10")).toBe(10); + }); +}); diff --git a/frontend/tests/unit/features/recipesApi.test.ts b/frontend/tests/unit/features/recipesApi.test.ts new file mode 100644 index 0000000..7587722 --- /dev/null +++ b/frontend/tests/unit/features/recipesApi.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, afterEach } from "vitest"; +import { http, HttpResponse } from "msw"; +import { recipesApi } from "@/features/recipes/api/recipesApi"; +import { server } from "@tests/mocks/server"; + +const MENU_ITEM_ID = "11111111-1111-1111-1111-111111111111"; + +describe("recipesApi.get", () => { + afterEach(() => server.resetHandlers()); + + it("normalises the server's empty body (no recipe yet) to null", async () => { + // The backend sends a genuinely empty body for "no recipe" rather than the JSON literal + // `null`, which is exactly the shape that broke the recipe editor before this was fixed: + // axios turns an empty body into `""`, and `""?.lines` is not short-circuited by optional + // chaining the way `null?.lines` is. + server.use( + http.get(`*/api/v1/menu-items/${MENU_ITEM_ID}/recipe`, () => new HttpResponse("", { status: 200 })), + ); + + const result = await recipesApi.get(MENU_ITEM_ID); + + expect(result).toBeNull(); + }); + + it("passes a real recipe straight through", async () => { + const recipe = { + id: "22222222-2222-2222-2222-222222222222", + menuItemId: MENU_ITEM_ID, + isEnabled: true, + lines: [{ rawMaterialId: "r1", rawMaterialName: "Rice", unitOfMeasurement: "Kilogram", quantity: 0.25 }], + createdAtUtc: "2026-01-01T00:00:00Z", + updatedAtUtc: null, + }; + + server.use(http.get(`*/api/v1/menu-items/${MENU_ITEM_ID}/recipe`, () => HttpResponse.json(recipe))); + + const result = await recipesApi.get(MENU_ITEM_ID); + + expect(result).toEqual(recipe); + }); +}); diff --git a/frontend/tests/vitest.setup.ts b/frontend/tests/vitest.setup.ts index 6c54f0a..0a0040b 100644 --- a/frontend/tests/vitest.setup.ts +++ b/frontend/tests/vitest.setup.ts @@ -2,6 +2,22 @@ import "@testing-library/jest-dom"; import { beforeAll, afterEach, afterAll } from "vitest"; import { server } from "./mocks/server"; +// jsdom doesn't implement the Pointer Events API, which Radix's Select (and anything else built +// on @radix-ui/react-use-previous / pointer capture) calls unconditionally. Without these, any +// test that opens a Select throws "target.hasPointerCapture is not a function". +if (!Element.prototype.hasPointerCapture) { + Element.prototype.hasPointerCapture = () => false; +} +if (!Element.prototype.setPointerCapture) { + Element.prototype.setPointerCapture = () => {}; +} +if (!Element.prototype.releasePointerCapture) { + Element.prototype.releasePointerCapture = () => {}; +} +if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = () => {}; +} + beforeAll(() => server.listen({ onUnhandledRequest: "error" })); afterEach(() => server.resetHandlers()); afterAll(() => server.close());