diff --git a/CulinaryCommandApp/Data/AppDbContext.cs b/CulinaryCommandApp/Data/AppDbContext.cs index 2fa650b..369070b 100644 --- a/CulinaryCommandApp/Data/AppDbContext.cs +++ b/CulinaryCommandApp/Data/AppDbContext.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using CulinaryCommand.Data.Entities; using CulinaryCommand.Inventory.Entities; +using PO = CulinaryCommand.PurchaseOrder.Entities; namespace CulinaryCommand.Data { @@ -24,6 +25,8 @@ public AppDbContext(DbContextOptions options) public DbSet ManagerLocations => Set(); public DbSet InventoryTransactions => Set(); public DbSet Units => Set(); + public DbSet PurchaseOrders => Set(); + public DbSet PurchaseOrderLines => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) diff --git a/CulinaryCommandApp/Inventory/Entities/InventoryBatch.cs b/CulinaryCommandApp/Inventory/Entities/InventoryBatch.cs new file mode 100644 index 0000000..57c3a09 --- /dev/null +++ b/CulinaryCommandApp/Inventory/Entities/InventoryBatch.cs @@ -0,0 +1,31 @@ +// Allow the same ingredient to exist with multiple expiration dates and quantities. + +using CulinaryCommand.Data.Entities; + +namespace CulinaryCommand.Inventory.Entities +{ + public class InventoryBatch + { + public int Id { get; set; } + + public int LocationId { get; set; } + public Location Location { get; set; } = default!; + + public int IngredientId { get; set; } + public Ingredient Ingredient { get; set; } = default!; + + public DateTime ExpirationDate { get; set; } + + public decimal Quantity { get; set; } + public Unit? Unit { get; set; } + + // auditing + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public string? CreatedByUserId { get; set; } + + // link to Product Oder line for traceability + public int? PurchaseOrderId { get; set; } + public int? PurchaseOrderLineId { get; set; } + } + +} \ No newline at end of file diff --git a/CulinaryCommandApp/Migrations/20251210004840_AddPurchaseOrderEntities.Designer.cs b/CulinaryCommandApp/Migrations/20251210004840_AddPurchaseOrderEntities.Designer.cs new file mode 100644 index 0000000..f7ebb35 --- /dev/null +++ b/CulinaryCommandApp/Migrations/20251210004840_AddPurchaseOrderEntities.Designer.cs @@ -0,0 +1,910 @@ +// +using System; +using CulinaryCommand.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CulinaryCommand.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20251210004840_AddPurchaseOrderEntities")] + partial class AddPurchaseOrderEntities + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("CulinaryCommand.Data.Entities.Company", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("City") + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("CompanyCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasColumnType("longtext"); + + b.Property("Email") + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("LLCName") + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("Phone") + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("State") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("TaxId") + .HasColumnType("longtext"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ZipCode") + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.HasKey("Id"); + + b.ToTable("Companies"); + }); + + modelBuilder.Entity("CulinaryCommand.Data.Entities.Location", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Address") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("City") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("MarginEdgeKey") + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("State") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("ZipCode") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.ToTable("Locations"); + }); + + modelBuilder.Entity("CulinaryCommand.Data.Entities.ManagerLocation", b => + { + b.Property("UserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.HasKey("UserId", "LocationId"); + + b.HasIndex("LocationId"); + + b.ToTable("ManagerLocations"); + }); + + modelBuilder.Entity("CulinaryCommand.Data.Entities.MeasurementUnit", b => + { + b.Property("UnitId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("UnitId")); + + b.Property("Abbreviation") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.HasKey("UnitId"); + + b.ToTable("MeasurementUnits"); + }); + + modelBuilder.Entity("CulinaryCommand.Data.Entities.Recipe", b => + { + b.Property("RecipeId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RecipeId")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("RecipeType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("YieldAmount") + .HasColumnType("decimal(65,30)"); + + b.Property("YieldUnit") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.HasKey("RecipeId"); + + b.HasIndex("LocationId"); + + b.ToTable("Recipes"); + }); + + modelBuilder.Entity("CulinaryCommand.Data.Entities.RecipeIngredient", b => + { + b.Property("RecipeIngredientId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RecipeIngredientId")); + + b.Property("IngredientId") + .HasColumnType("int"); + + b.Property("MeasurementUnitUnitId") + .HasColumnType("int"); + + b.Property("PrepNote") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("Quantity") + .HasColumnType("decimal(65,30)"); + + b.Property("RecipeId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UnitId") + .HasColumnType("int"); + + b.HasKey("RecipeIngredientId"); + + b.HasIndex("IngredientId"); + + b.HasIndex("MeasurementUnitUnitId"); + + b.HasIndex("RecipeId"); + + b.HasIndex("UnitId"); + + b.ToTable("RecipeIngredients"); + }); + + modelBuilder.Entity("CulinaryCommand.Data.Entities.RecipeStep", b => + { + b.Property("StepId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("StepId")); + + b.Property("Instructions") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("RecipeId") + .HasColumnType("int"); + + b.Property("StepNumber") + .HasColumnType("int"); + + b.HasKey("StepId"); + + b.HasIndex("RecipeId"); + + b.ToTable("RecipeSteps"); + }); + + modelBuilder.Entity("CulinaryCommand.Data.Entities.Tasks", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Assigner") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Count") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Date") + .HasColumnType("datetime(6)"); + + b.Property("DueDate") + .HasColumnType("datetime(6)"); + + b.Property("IngredientId") + .HasColumnType("int"); + + b.Property("Kind") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("Notes") + .HasColumnType("longtext"); + + b.Property("Par") + .HasColumnType("int"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("RecipeId") + .HasColumnType("int"); + + b.Property("Station") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("IngredientId"); + + b.HasIndex("LocationId"); + + b.HasIndex("RecipeId"); + + b.HasIndex("UserId"); + + b.ToTable("Tasks"); + }); + + modelBuilder.Entity("CulinaryCommand.Data.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedByUserId") + .HasColumnType("int"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit(1)"); + + b.Property("InviteToken") + .HasColumnType("longtext"); + + b.Property("InviteTokenExpires") + .HasColumnType("datetime(6)"); + + b.Property("IsActive") + .HasColumnType("bit(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("Password") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("Phone") + .HasColumnType("longtext"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("StationsWorked") + .HasColumnType("longtext"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("CulinaryCommand.Data.Entities.UserLocation", b => + { + b.Property("UserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Role") + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.HasKey("UserId", "LocationId"); + + b.HasIndex("LocationId"); + + b.ToTable("UserLocations"); + }); + + modelBuilder.Entity("CulinaryCommand.Inventory.Entities.Ingredient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("IngredientId"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Notes") + .HasColumnType("longtext"); + + b.Property("Price") + .HasColumnType("decimal(65,30)"); + + b.Property("ReorderLevel") + .HasColumnType("decimal(65,30)"); + + b.Property("Sku") + .HasColumnType("longtext"); + + b.Property("StockQuantity") + .HasColumnType("decimal(18, 4)"); + + b.Property("UnitId") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("UnitId"); + + b.ToTable("Ingredients", (string)null); + }); + + modelBuilder.Entity("CulinaryCommand.Inventory.Entities.InventoryTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IngredientId") + .HasColumnType("int"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("StockChange") + .HasColumnType("decimal(65,30)"); + + b.Property("UnitId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("IngredientId"); + + b.HasIndex("UnitId"); + + b.ToTable("InventoryTransactions"); + }); + + modelBuilder.Entity("CulinaryCommand.Inventory.Entities.Unit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Abbreviation") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ConversionFactor") + .HasColumnType("decimal(65,30)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Units"); + }); + + modelBuilder.Entity("CulinaryCommand.PurchaseOrder.Entities.PurchaseOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ExpectedDeliveryDate") + .HasColumnType("datetime(6)"); + + b.Property("IsLocationLocked") + .HasColumnType("bit(1)"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("longtext"); + + b.Property("OrderDate") + .HasColumnType("datetime(6)"); + + b.Property("OrderNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("VendorContact") + .HasColumnType("longtext"); + + b.Property("VendorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("LocationId"); + + b.HasIndex("OrderNumber") + .IsUnique(); + + b.ToTable("PurchaseOrders"); + }); + + modelBuilder.Entity("CulinaryCommand.PurchaseOrder.Entities.PurchaseOrderLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("IngredientId") + .HasColumnType("int"); + + b.Property("PurchaseOrderId") + .HasColumnType("int"); + + b.Property("QuantityOrdered") + .HasPrecision(18, 3) + .HasColumnType("decimal(18,3)"); + + b.Property("QuantityReceived") + .ValueGeneratedOnAdd() + .HasPrecision(18, 3) + .HasColumnType("decimal(18,3)") + .HasDefaultValue(0m); + + b.Property("UnitId") + .HasColumnType("int"); + + b.Property("UnitPrice") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("IngredientId"); + + b.HasIndex("PurchaseOrderId"); + + b.HasIndex("UnitId"); + + b.ToTable("PurchaseOrderLines"); + }); + + modelBuilder.Entity("CulinaryCommand.Data.Entities.Location", b => + { + b.HasOne("CulinaryCommand.Data.Entities.Company", "Company") + .WithMany("Locations") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Company"); + }); + + modelBuilder.Entity("CulinaryCommand.Data.Entities.ManagerLocation", b => + { + b.HasOne("CulinaryCommand.Data.Entities.Location", "Location") + .WithMany("ManagerLocations") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CulinaryCommand.Data.Entities.User", "User") + .WithMany("ManagerLocations") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Location"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CulinaryCommand.Data.Entities.Recipe", b => + { + b.HasOne("CulinaryCommand.Data.Entities.Location", "Location") + .WithMany("Recipes") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("CulinaryCommand.Data.Entities.RecipeIngredient", b => + { + b.HasOne("CulinaryCommand.Inventory.Entities.Ingredient", "Ingredient") + .WithMany() + .HasForeignKey("IngredientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CulinaryCommand.Data.Entities.MeasurementUnit", null) + .WithMany("RecipeIngredients") + .HasForeignKey("MeasurementUnitUnitId"); + + b.HasOne("CulinaryCommand.Data.Entities.Recipe", "Recipe") + .WithMany("RecipeIngredients") + .HasForeignKey("RecipeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CulinaryCommand.Inventory.Entities.Unit", "Unit") + .WithMany() + .HasForeignKey("UnitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Ingredient"); + + b.Navigation("Recipe"); + + b.Navigation("Unit"); + }); + + modelBuilder.Entity("CulinaryCommand.Data.Entities.RecipeStep", b => + { + b.HasOne("CulinaryCommand.Data.Entities.Recipe", "Recipe") + .WithMany("Steps") + .HasForeignKey("RecipeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Recipe"); + }); + + modelBuilder.Entity("CulinaryCommand.Data.Entities.Tasks", b => + { + b.HasOne("CulinaryCommand.Inventory.Entities.Ingredient", "Ingredient") + .WithMany() + .HasForeignKey("IngredientId"); + + b.HasOne("CulinaryCommand.Data.Entities.Location", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CulinaryCommand.Data.Entities.Recipe", "Recipe") + .WithMany() + .HasForeignKey("RecipeId"); + + b.HasOne("CulinaryCommand.Data.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Ingredient"); + + b.Navigation("Location"); + + b.Navigation("Recipe"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CulinaryCommand.Data.Entities.User", b => + { + b.HasOne("CulinaryCommand.Data.Entities.Company", "Company") + .WithMany("Employees") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Company"); + }); + + modelBuilder.Entity("CulinaryCommand.Data.Entities.UserLocation", b => + { + b.HasOne("CulinaryCommand.Data.Entities.Location", "Location") + .WithMany("UserLocations") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CulinaryCommand.Data.Entities.User", "User") + .WithMany("UserLocations") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Location"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CulinaryCommand.Inventory.Entities.Ingredient", b => + { + b.HasOne("CulinaryCommand.Inventory.Entities.Unit", "Unit") + .WithMany("Ingredients") + .HasForeignKey("UnitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Unit"); + }); + + modelBuilder.Entity("CulinaryCommand.Inventory.Entities.InventoryTransaction", b => + { + b.HasOne("CulinaryCommand.Inventory.Entities.Ingredient", "Ingredient") + .WithMany() + .HasForeignKey("IngredientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CulinaryCommand.Inventory.Entities.Unit", "Unit") + .WithMany("InventoryTransaction") + .HasForeignKey("UnitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Ingredient"); + + b.Navigation("Unit"); + }); + + modelBuilder.Entity("CulinaryCommand.PurchaseOrder.Entities.PurchaseOrder", b => + { + b.HasOne("CulinaryCommand.Data.Entities.Location", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("CulinaryCommand.PurchaseOrder.Entities.PurchaseOrderLine", b => + { + b.HasOne("CulinaryCommand.Inventory.Entities.Ingredient", "Ingredient") + .WithMany() + .HasForeignKey("IngredientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("CulinaryCommand.PurchaseOrder.Entities.PurchaseOrder", "PurchaseOrder") + .WithMany("Lines") + .HasForeignKey("PurchaseOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("CulinaryCommand.Inventory.Entities.Unit", "Unit") + .WithMany() + .HasForeignKey("UnitId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Ingredient"); + + b.Navigation("PurchaseOrder"); + + b.Navigation("Unit"); + }); + + modelBuilder.Entity("CulinaryCommand.Data.Entities.Company", b => + { + b.Navigation("Employees"); + + b.Navigation("Locations"); + }); + + modelBuilder.Entity("CulinaryCommand.Data.Entities.Location", b => + { + b.Navigation("ManagerLocations"); + + b.Navigation("Recipes"); + + b.Navigation("UserLocations"); + }); + + modelBuilder.Entity("CulinaryCommand.Data.Entities.MeasurementUnit", b => + { + b.Navigation("RecipeIngredients"); + }); + + modelBuilder.Entity("CulinaryCommand.Data.Entities.Recipe", b => + { + b.Navigation("RecipeIngredients"); + + b.Navigation("Steps"); + }); + + modelBuilder.Entity("CulinaryCommand.Data.Entities.User", b => + { + b.Navigation("ManagerLocations"); + + b.Navigation("UserLocations"); + }); + + modelBuilder.Entity("CulinaryCommand.Inventory.Entities.Unit", b => + { + b.Navigation("Ingredients"); + + b.Navigation("InventoryTransaction"); + }); + + modelBuilder.Entity("CulinaryCommand.PurchaseOrder.Entities.PurchaseOrder", b => + { + b.Navigation("Lines"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/CulinaryCommandApp/Migrations/20251210004840_AddPurchaseOrderEntities.cs b/CulinaryCommandApp/Migrations/20251210004840_AddPurchaseOrderEntities.cs new file mode 100644 index 0000000..cd6a450 --- /dev/null +++ b/CulinaryCommandApp/Migrations/20251210004840_AddPurchaseOrderEntities.cs @@ -0,0 +1,123 @@ +using System; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CulinaryCommand.Migrations +{ + /// + public partial class AddPurchaseOrderEntities : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PurchaseOrders", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + OrderNumber = table.Column(type: "varchar(50)", maxLength: 50, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + LocationId = table.Column(type: "int", nullable: false), + OrderDate = table.Column(type: "datetime(6)", nullable: false), + ExpectedDeliveryDate = table.Column(type: "datetime(6)", nullable: true), + VendorName = table.Column(type: "varchar(256)", maxLength: 256, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + VendorContact = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + Status = table.Column(type: "int", nullable: false), + Notes = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + UpdatedAt = table.Column(type: "datetime(6)", nullable: false), + IsLocationLocked = table.Column(type: "bit(1)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PurchaseOrders", x => x.Id); + table.ForeignKey( + name: "FK_PurchaseOrders_Locations_LocationId", + column: x => x.LocationId, + principalTable: "Locations", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "PurchaseOrderLines", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + PurchaseOrderId = table.Column(type: "int", nullable: false), + IngredientId = table.Column(type: "int", nullable: false), + UnitId = table.Column(type: "int", nullable: false), + QuantityOrdered = table.Column(type: "decimal(18,3)", precision: 18, scale: 3, nullable: false), + UnitPrice = table.Column(type: "decimal(18,2)", precision: 18, scale: 2, nullable: false), + QuantityReceived = table.Column(type: "decimal(18,3)", precision: 18, scale: 3, nullable: true, defaultValue: 0m) + }, + constraints: table => + { + table.PrimaryKey("PK_PurchaseOrderLines", x => x.Id); + table.ForeignKey( + name: "FK_PurchaseOrderLines_Ingredients_IngredientId", + column: x => x.IngredientId, + principalTable: "Ingredients", + principalColumn: "IngredientId", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_PurchaseOrderLines_PurchaseOrders_PurchaseOrderId", + column: x => x.PurchaseOrderId, + principalTable: "PurchaseOrders", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_PurchaseOrderLines_Units_UnitId", + column: x => x.UnitId, + principalTable: "Units", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_PurchaseOrderLines_IngredientId", + table: "PurchaseOrderLines", + column: "IngredientId"); + + migrationBuilder.CreateIndex( + name: "IX_PurchaseOrderLines_PurchaseOrderId", + table: "PurchaseOrderLines", + column: "PurchaseOrderId"); + + migrationBuilder.CreateIndex( + name: "IX_PurchaseOrderLines_UnitId", + table: "PurchaseOrderLines", + column: "UnitId"); + + migrationBuilder.CreateIndex( + name: "IX_PurchaseOrders_LocationId", + table: "PurchaseOrders", + column: "LocationId"); + + migrationBuilder.CreateIndex( + name: "IX_PurchaseOrders_OrderNumber", + table: "PurchaseOrders", + column: "OrderNumber", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PurchaseOrderLines"); + + migrationBuilder.DropTable( + name: "PurchaseOrders"); + } + } +} diff --git a/CulinaryCommandApp/Migrations/AppDbContextModelSnapshot.cs b/CulinaryCommandApp/Migrations/AppDbContextModelSnapshot.cs index b0e930f..a87ef17 100644 --- a/CulinaryCommandApp/Migrations/AppDbContextModelSnapshot.cs +++ b/CulinaryCommandApp/Migrations/AppDbContextModelSnapshot.cs @@ -549,6 +549,103 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Units"); }); + modelBuilder.Entity("CulinaryCommand.PurchaseOrder.Entities.PurchaseOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ExpectedDeliveryDate") + .HasColumnType("datetime(6)"); + + b.Property("IsLocationLocked") + .HasColumnType("bit(1)"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("longtext"); + + b.Property("OrderDate") + .HasColumnType("datetime(6)"); + + b.Property("OrderNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("VendorContact") + .HasColumnType("longtext"); + + b.Property("VendorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("LocationId"); + + b.HasIndex("OrderNumber") + .IsUnique(); + + b.ToTable("PurchaseOrders"); + }); + + modelBuilder.Entity("CulinaryCommand.PurchaseOrder.Entities.PurchaseOrderLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("IngredientId") + .HasColumnType("int"); + + b.Property("PurchaseOrderId") + .HasColumnType("int"); + + b.Property("QuantityOrdered") + .HasPrecision(18, 3) + .HasColumnType("decimal(18,3)"); + + b.Property("QuantityReceived") + .ValueGeneratedOnAdd() + .HasPrecision(18, 3) + .HasColumnType("decimal(18,3)") + .HasDefaultValue(0m); + + b.Property("UnitId") + .HasColumnType("int"); + + b.Property("UnitPrice") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("IngredientId"); + + b.HasIndex("PurchaseOrderId"); + + b.HasIndex("UnitId"); + + b.ToTable("PurchaseOrderLines"); + }); + modelBuilder.Entity("CulinaryCommand.Data.Entities.Location", b => { b.HasOne("CulinaryCommand.Data.Entities.Company", "Company") @@ -720,6 +817,44 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Unit"); }); + modelBuilder.Entity("CulinaryCommand.PurchaseOrder.Entities.PurchaseOrder", b => + { + b.HasOne("CulinaryCommand.Data.Entities.Location", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("CulinaryCommand.PurchaseOrder.Entities.PurchaseOrderLine", b => + { + b.HasOne("CulinaryCommand.Inventory.Entities.Ingredient", "Ingredient") + .WithMany() + .HasForeignKey("IngredientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("CulinaryCommand.PurchaseOrder.Entities.PurchaseOrder", "PurchaseOrder") + .WithMany("Lines") + .HasForeignKey("PurchaseOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("CulinaryCommand.Inventory.Entities.Unit", "Unit") + .WithMany() + .HasForeignKey("UnitId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Ingredient"); + + b.Navigation("PurchaseOrder"); + + b.Navigation("Unit"); + }); + modelBuilder.Entity("CulinaryCommand.Data.Entities.Company", b => { b.Navigation("Employees"); @@ -761,6 +896,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("InventoryTransaction"); }); + + modelBuilder.Entity("CulinaryCommand.PurchaseOrder.Entities.PurchaseOrder", b => + { + b.Navigation("Lines"); + }); #pragma warning restore 612, 618 } } diff --git a/CulinaryCommandApp/Program.cs b/CulinaryCommandApp/Program.cs index 89a9386..23cf109 100644 --- a/CulinaryCommandApp/Program.cs +++ b/CulinaryCommandApp/Program.cs @@ -2,6 +2,7 @@ using CulinaryCommand.Data; using CulinaryCommand.Services; using CulinaryCommand.Inventory.Services; +using CulinaryCommand.PurchaseOrder.Services; using CulinaryCommand.Inventory; using CulinaryCommand.Inventory.Services.Interfaces; using System; // for Version, TimeSpan @@ -78,6 +79,7 @@ string MaskPwd(string s) builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddSingleton(); var app = builder.Build(); diff --git a/CulinaryCommandApp/PurchaseOrder/DTOs/CreatePurchaseOrderDTO.cs b/CulinaryCommandApp/PurchaseOrder/DTOs/CreatePurchaseOrderDTO.cs new file mode 100644 index 0000000..91e12df --- /dev/null +++ b/CulinaryCommandApp/PurchaseOrder/DTOs/CreatePurchaseOrderDTO.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; + +namespace CulinaryCommand.PurchaseOrder.DTOs +{ + public class CreatePurchaseOrderDTO + { + [Required] + public int LocationId { get; set; } + + [Required, StringLength(200)] + public string VendorName { get; set; } = string.Empty; + + [StringLength(200)] + public string? VendorContact { get; set; } + + + public DateTime? ExpectedDeliveryDate { get; set; } + + [StringLength(1000)] + public string? Notes { get; set; } + + [MinLength(1, ErrorMessage = "At least one line is required.")] + public List Lines { get; set; } = new(); + } +} \ No newline at end of file diff --git a/CulinaryCommandApp/PurchaseOrder/DTOs/CreatePurchaseOrderLineDTO.cs b/CulinaryCommandApp/PurchaseOrder/DTOs/CreatePurchaseOrderLineDTO.cs new file mode 100644 index 0000000..59bd6a0 --- /dev/null +++ b/CulinaryCommandApp/PurchaseOrder/DTOs/CreatePurchaseOrderLineDTO.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; + +namespace CulinaryCommand.PurchaseOrder.DTOs +{ + public class CreatePurchaseOrderLineDTO + { + [Required] + public int IngredientId { get; set; } + + [Required] + public int UnitId { get; set; } + + [Range(0.0001, double.MaxValue)] + public decimal QuantityOrdered { get; set; } + + [Range(0, double.MaxValue)] + public decimal UnitPrice { get; set; } + + } +} \ No newline at end of file diff --git a/CulinaryCommandApp/PurchaseOrder/DTOs/PurchaseOrderDTO.cs b/CulinaryCommandApp/PurchaseOrder/DTOs/PurchaseOrderDTO.cs new file mode 100644 index 0000000..d384ff4 --- /dev/null +++ b/CulinaryCommandApp/PurchaseOrder/DTOs/PurchaseOrderDTO.cs @@ -0,0 +1,25 @@ +using CulinaryCommand.PurchaseOrder.Entities; + +namespace CulinaryCommand.PurchaseOrder.DTOs +{ + public class PurchaseOrderDTO + { + public int Id { get; set; } + + public string OrderNumber { get; set; } = string.Empty; + + public int LocationId { get; set; } + + public string VendorName { get; set; } = string.Empty; + + public string? VendorContact { get; set; } + + public DateTime? ExpectedDeliveryDate { get; set; } + + public PurchaseOrderStatus Status { get; set; } + + public string? Notes { get; set; } + + public List Lines { get; set; } = new(); + } +} \ No newline at end of file diff --git a/CulinaryCommandApp/PurchaseOrder/DTOs/PurchaseOrderLineDTO.cs b/CulinaryCommandApp/PurchaseOrder/DTOs/PurchaseOrderLineDTO.cs new file mode 100644 index 0000000..0f0edeb --- /dev/null +++ b/CulinaryCommandApp/PurchaseOrder/DTOs/PurchaseOrderLineDTO.cs @@ -0,0 +1,17 @@ +namespace CulinaryCommand.PurchaseOrder.DTOs +{ + public class PurchaseOrderLineDTO + { + public int Id { get; set; } + + public int IngredientId { get; set; } + + public int UnitId { get; set; } + + public decimal QuantityOrdered { get; set; } + + public decimal QuantityReceived { get; set; } + + public decimal UnitPrice { get; set; } + } +} \ No newline at end of file diff --git a/CulinaryCommandApp/PurchaseOrder/Data/Configurations/PurchaseOrderConfiguration.cs b/CulinaryCommandApp/PurchaseOrder/Data/Configurations/PurchaseOrderConfiguration.cs new file mode 100644 index 0000000..6447627 --- /dev/null +++ b/CulinaryCommandApp/PurchaseOrder/Data/Configurations/PurchaseOrderConfiguration.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using CulinaryCommand.PurchaseOrder.Entities; + +namespace CulinaryCommand.PurchaseOrder.Data.Configurations +{ + public class PurchaseOrderConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(purchase_order => purchase_order.Id); + + builder.HasIndex(purchase_order => purchase_order.OrderNumber).IsUnique(); + + builder.HasOne(purchase_order => purchase_order.Location) + .WithMany() + .HasForeignKey(purchase_order => purchase_order.LocationId) + .OnDelete(DeleteBehavior.Restrict); + + builder.Property(purchase_order => purchase_order.VendorName) + .HasMaxLength(256) + .IsRequired(); + } + } +} \ No newline at end of file diff --git a/CulinaryCommandApp/PurchaseOrder/Data/Configurations/PurchaseOrderLineConfiguration.cs b/CulinaryCommandApp/PurchaseOrder/Data/Configurations/PurchaseOrderLineConfiguration.cs new file mode 100644 index 0000000..75fddeb --- /dev/null +++ b/CulinaryCommandApp/PurchaseOrder/Data/Configurations/PurchaseOrderLineConfiguration.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using CulinaryCommand.PurchaseOrder.Entities; + +namespace CulinaryCommand.PurchaseOrder.Data.Configurations +{ + public class PurchaseOrderLineConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(line => line.Id); + + builder.Property(line => line.QuantityOrdered) + .HasPrecision(18, 3) + .IsRequired(); + + builder.Property(line => line.QuantityReceived) + .HasPrecision(18, 3) + .HasDefaultValue(0m); + + builder.Property(line => line.UnitPrice) + .HasPrecision(18, 2) + .IsRequired(); + + builder.HasOne(line => line.Unit) + .WithMany() + .HasForeignKey(line => line.UnitId) + .IsRequired() + .OnDelete(DeleteBehavior.Restrict); + + builder.HasOne(line => line.Ingredient) + .WithMany() + .HasForeignKey(line => line.IngredientId) + .IsRequired() + .OnDelete(DeleteBehavior.Restrict); + + builder.HasOne(line => line.PurchaseOrder) + .WithMany(purchase_order => purchase_order.Lines) + .HasForeignKey(line => line.PurchaseOrderId) + .IsRequired() + .OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(line => line.PurchaseOrderId); + builder.HasIndex(line => line.IngredientId); + builder.HasIndex(line => line.UnitId); + } + } +} \ No newline at end of file diff --git a/CulinaryCommandApp/PurchaseOrder/Entities/PurchaseOrder.cs b/CulinaryCommandApp/PurchaseOrder/Entities/PurchaseOrder.cs new file mode 100644 index 0000000..7b7980e --- /dev/null +++ b/CulinaryCommandApp/PurchaseOrder/Entities/PurchaseOrder.cs @@ -0,0 +1,51 @@ +// Represents a single purchase from a vendor to a specific restaurant location. + +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using CulinaryCommand.Data.Entities; + +namespace CulinaryCommand.PurchaseOrder.Entities +{ + public class PurchaseOrder + { + public int Id { get; set; } + + [Required, StringLength(50)] + public string OrderNumber { get; set; } = string.Empty; + + [Required] + public int LocationId { get; set; } + + [ForeignKey(nameof(LocationId))] + public Location Location { get; set; } = default!; + + public DateTime OrderDate { get; set; } = DateTime.UtcNow; + public DateTime? ExpectedDeliveryDate { get; set; } + + [Required, StringLength(256)] + public string VendorName { get; set; } = string.Empty; + public string? VendorContact { get; set; } + + public PurchaseOrderStatus Status { get; set; } = PurchaseOrderStatus.Draft; + public ICollection Lines { get; set; } = new List(); + + public string? Notes { get; set; } + + // for audit reasons + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + // flag to prevent changing location after creation + public bool IsLocationLocked { get; set; } = false; + + } + + public enum PurchaseOrderStatus + { + Draft = 0, + Submitted = 1, + PartiallyReceived = 2, + Received = 3, + Cancelled = 4 + } +} diff --git a/CulinaryCommandApp/PurchaseOrder/Entities/PurchaseOrderLine.cs b/CulinaryCommandApp/PurchaseOrder/Entities/PurchaseOrderLine.cs new file mode 100644 index 0000000..745baa4 --- /dev/null +++ b/CulinaryCommandApp/PurchaseOrder/Entities/PurchaseOrderLine.cs @@ -0,0 +1,24 @@ +// Represents a single ingredient on a purchase order +using CulinaryCommand.Inventory.Entities; + +namespace CulinaryCommand.PurchaseOrder.Entities +{ + public class PurchaseOrderLine + { + public int Id { get; set; } + + public int PurchaseOrderId { get; set; } + public PurchaseOrder PurchaseOrder { get; set; } = default!; + + public int IngredientId { get; set; } + public Ingredient Ingredient { get; set; } = default!; + + // FK to unit used for this PO line + public int UnitId { get; set; } + public Unit? Unit { get; set; } + + public decimal QuantityOrdered { get; set; } + public decimal UnitPrice { get; set; } + public decimal QuantityReceived { get; set; } + } +} \ No newline at end of file diff --git a/CulinaryCommandApp/PurchaseOrder/Pages/Create.razor b/CulinaryCommandApp/PurchaseOrder/Pages/Create.razor index fb31b6d..27ecc16 100644 --- a/CulinaryCommandApp/PurchaseOrder/Pages/Create.razor +++ b/CulinaryCommandApp/PurchaseOrder/Pages/Create.razor @@ -1,5 +1,11 @@ @page "/purchase-orders/create" +@using CulinaryCommand.PurchaseOrder.DTOs +@using CulinaryCommand.PurchaseOrder.Services +@using CulinaryCommand.Inventory.Entities +@using Microsoft.EntityFrameworkCore @inject NavigationManager Nav +@inject IPurchaseOrderService PurchaseOrderService +@inject CulinaryCommand.Data.AppDbContext Db

Create Purchase Order

@@ -50,8 +56,14 @@
- - + + + + @foreach (var ing in _ingredients) + { + + } +
@@ -59,7 +71,13 @@
- + + + @foreach (var u in _units) + { + + } +
@@ -93,18 +111,36 @@ Cancel
+ + @if (!string.IsNullOrEmpty(_errorMessage)) + { +
+ @_errorMessage +
+ }
@code { private PurchaseOrderModel model = new(); + private string? _errorMessage; + private List _ingredients = new(); + private List _units = new(); - protected override void OnInitialized() + protected override async Task OnInitializedAsync() { model.Date = DateTime.Now; model.Status = "Pending"; model.LineItems.Add(new LineItemModel()); + + _ingredients = await Db.Ingredients + .OrderBy(i => i.Name) + .ToListAsync(); + + _units = await Db.Units + .OrderBy(u => u.Abbreviation) + .ToListAsync(); } private void AddLineItem() @@ -122,10 +158,38 @@ return model.LineItems.Sum(x => x.Subtotal); } - private void HandleSubmit() + private async Task HandleSubmit() { - // TODO: Save to database via service - Nav.NavigateTo("/purchase-orders"); + _errorMessage = null; + + try + { + var request = new CreatePurchaseOrderDTO + { + LocationId = 5, // TODO: replace with selected location + VendorName = model.Supplier, + VendorContact = null, + ExpectedDeliveryDate = model.Date, + Notes = model.Notes, + Lines = model.LineItems + .Select(li => new CreatePurchaseOrderLineDTO + { + IngredientId = li.IngredientId, + UnitId = li.UnitId, + QuantityOrdered = li.Quantity, + UnitPrice = li.UnitPrice + }) + .ToList() + }; + + var created = await PurchaseOrderService.CreateDraftAsync(request); + @* Nav.NavigateTo($"/purchase-orders/{created.Id}"); *@ + Nav.NavigateTo("/purchase-orders"); + } + catch (Exception ex) + { + _errorMessage = ex.Message; + } } private void Cancel() @@ -145,9 +209,9 @@ private class LineItemModel { - public string ItemName { get; set; } = string.Empty; + public int IngredientId { get; set; } public int Quantity { get; set; } = 1; - public string Unit { get; set; } = "ea"; + public int UnitId { get; set; } public decimal UnitPrice { get; set; } public decimal Subtotal => Quantity * UnitPrice; } diff --git a/CulinaryCommandApp/PurchaseOrder/Pages/PurchaseOrderList.razor b/CulinaryCommandApp/PurchaseOrder/Pages/PurchaseOrderList.razor index a5839b6..a07856a 100644 --- a/CulinaryCommandApp/PurchaseOrder/Pages/PurchaseOrderList.razor +++ b/CulinaryCommandApp/PurchaseOrder/Pages/PurchaseOrderList.razor @@ -1,5 +1,7 @@ @inject NavigationManager Nav @inject IJSRuntime Js +@inject CulinaryCommand.Data.AppDbContext Db +@using Microsoft.EntityFrameworkCore @rendermode InteractiveServer

Purchase Orders

@@ -66,37 +68,23 @@ else protected override async Task OnInitializedAsync() { - // Mock data for baseline - replace with actual service call await LoadPurchaseOrders(); } private async Task LoadPurchaseOrders() { - // Simulate loading delay - await Task.Delay(500); - - // Mock data - replace with actual service - items = new List - { - new PurchaseOrderViewModel - { - Id = 1, - PoNumber = "PO-2025-001", - Date = DateTime.Now.AddDays(-5), - Supplier = "ABC Suppliers", - Status = "Pending", - Total = 1250.50m - }, - new PurchaseOrderViewModel + items = await Db.PurchaseOrders + .OrderByDescending(po => po.OrderDate) + .Select(po => new PurchaseOrderViewModel { - Id = 2, - PoNumber = "PO-2025-002", - Date = DateTime.Now.AddDays(-2), - Supplier = "XYZ Distributors", - Status = "Approved", - Total = 890.75m - } - }; + Id = po.Id, + PoNumber = po.OrderNumber, + Date = po.OrderDate, + Supplier = po.VendorName, + Status = po.Status.ToString(), + Total = po.Lines.Sum(l => l.QuantityOrdered * l.UnitPrice) + }) + .ToListAsync(); } private string GetStatusBadgeClass(string status) => status switch @@ -116,12 +104,11 @@ else { if (await Js.InvokeAsync("confirm", "Are you sure you want to delete this purchase order?")) { - // TODO: Call service to delete + // TODO: Call service to delete from database as well items = items?.Where(x => x.Id != id).ToList(); } } - // View model - move to separate file when integrating with actual entities private class PurchaseOrderViewModel { public int Id { get; set; } diff --git a/CulinaryCommandApp/PurchaseOrder/Pages/_Imports.razor b/CulinaryCommandApp/PurchaseOrder/Pages/_Imports.razor index e2d807d..0d6f7ab 100644 --- a/CulinaryCommandApp/PurchaseOrder/Pages/_Imports.razor +++ b/CulinaryCommandApp/PurchaseOrder/Pages/_Imports.razor @@ -13,3 +13,4 @@ @using CulinaryCommand.Components.Layout @using CulinaryCommand.Components.Custom @using CulinaryCommand.Components.Pages +@using CulinaryCommand.PurchaseOrder.Services diff --git a/CulinaryCommandApp/PurchaseOrder/Services/Interfaces/IPurchaseOrderService.cs b/CulinaryCommandApp/PurchaseOrder/Services/Interfaces/IPurchaseOrderService.cs new file mode 100644 index 0000000..2a59d33 --- /dev/null +++ b/CulinaryCommandApp/PurchaseOrder/Services/Interfaces/IPurchaseOrderService.cs @@ -0,0 +1,11 @@ +using System.Threading; +using System.Threading.Tasks; +using CulinaryCommand.PurchaseOrder.DTOs; + +namespace CulinaryCommand.PurchaseOrder.Services +{ + public interface IPurchaseOrderService + { + Task CreateDraftAsync(CreatePurchaseOrderDTO request, CancellationToken cancellationToken = default); + } +} \ No newline at end of file diff --git a/CulinaryCommandApp/PurchaseOrder/Services/PurchaseOrderService.cs b/CulinaryCommandApp/PurchaseOrder/Services/PurchaseOrderService.cs new file mode 100644 index 0000000..be5ea93 --- /dev/null +++ b/CulinaryCommandApp/PurchaseOrder/Services/PurchaseOrderService.cs @@ -0,0 +1,118 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using CulinaryCommand.Data; +using CulinaryCommand.PurchaseOrder.DTOs; +using POEntities = CulinaryCommand.PurchaseOrder.Entities; +using CulinaryCommand.Components; +using System.Threading.Tasks.Dataflow; + +namespace CulinaryCommand.PurchaseOrder.Services +{ + public class PurchaseOrderService : IPurchaseOrderService + { + private readonly AppDbContext _db; + + public PurchaseOrderService(AppDbContext db) + { + _db = db; + } + + public async Task CreateDraftAsync(CreatePurchaseOrderDTO request, CancellationToken cancellationToken = default) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (request.Lines == null || !request.Lines.Any()) + throw new ArgumentException("At least one line is required.", nameof(request)); + + var locationExists = await _db.Locations + .AnyAsync(location => location.Id == request.LocationId, cancellationToken); + + if (!locationExists) + throw new InvalidOperationException("Location not found."); + + var ingredientIds = request.Lines.Select(line => line.IngredientId).Distinct().ToList(); + var unitIds = request.Lines.Select(line => line.UnitId).Distinct().ToList(); + + var existingIngredientIds = await _db.Ingredients + .Where(ingredient => ingredientIds.Contains(ingredient.Id)) + .Select(ingredient => ingredient.Id) + .ToListAsync(cancellationToken); + + var existingUnitIds = await _db.Units + .Where(unit => unitIds.Contains(unit.Id)) + .Select(unit => unit.Id) + .ToListAsync(cancellationToken); + + var missingIngredients = ingredientIds.Except(existingIngredientIds).ToList(); + if (missingIngredients.Any()) + throw new InvalidOperationException ("Some ingredients are invalid: " + string.Join(", ", missingIngredients)); + + var missingUnits = unitIds.Except(existingUnitIds).ToList(); + if (missingUnits.Any()) + throw new InvalidOperationException("Some units are invalid: " + string.Join(", ", missingUnits)); + + var purchaseOrder = new POEntities.PurchaseOrder + { + OrderNumber = GenerateOrderNumber(), + LocationId = request.LocationId, + VendorName = request.VendorName, + VendorContact = request.VendorContact, + ExpectedDeliveryDate = request.ExpectedDeliveryDate, + Notes = request.Notes, + Status = POEntities.PurchaseOrderStatus.Draft, + IsLocationLocked = true, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + foreach (var lineDTO in request.Lines) + { + purchaseOrder.Lines.Add(new POEntities.PurchaseOrderLine + { + IngredientId = lineDTO.IngredientId, + UnitId = lineDTO.UnitId, + QuantityOrdered = lineDTO.QuantityOrdered, + QuantityReceived = 0m, + UnitPrice = lineDTO.UnitPrice + }); + } + + _db.PurchaseOrders.Add(purchaseOrder); + await _db.SaveChangesAsync(cancellationToken); + + // map back to DTO + return new PurchaseOrderDTO + { + Id = purchaseOrder.Id, + OrderNumber = purchaseOrder.OrderNumber, + LocationId = purchaseOrder.LocationId, + VendorName = purchaseOrder.VendorName, + VendorContact = purchaseOrder.VendorContact, + ExpectedDeliveryDate = purchaseOrder.ExpectedDeliveryDate, + Status = purchaseOrder.Status, + Notes = purchaseOrder.Notes, + Lines = purchaseOrder.Lines.Select(line => new PurchaseOrderLineDTO + { + Id = line.Id, + IngredientId = line.IngredientId, + UnitId = line.UnitId, + QuantityOrdered = line.QuantityOrdered, + QuantityReceived = line.QuantityReceived, + UnitPrice = line.UnitPrice + }).ToList() + }; + } + + // helper function to generate an order number + private static string GenerateOrderNumber() + { + var timestamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss"); + var random = new Random().Next(1000, 9999); + return $"PO-{timestamp}-{random}"; + } + } +} \ No newline at end of file diff --git a/CulinaryCommandApp/appsettings.json b/CulinaryCommandApp/appsettings.json index 67597ed..db25040 100644 --- a/CulinaryCommandApp/appsettings.json +++ b/CulinaryCommandApp/appsettings.json @@ -1,6 +1,6 @@ { "ConnectionStrings": { - "DefualtConnection": "" + "DefaultConnection": "" }, "Logging": { "LogLevel": {