From f7101800cdf7b6406e6c51a6e08f2a2f9643ec70 Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Fri, 21 Aug 2026 14:10:40 +0530 Subject: [PATCH 1/3] Add scheme data model and migrations --- Distribution/Data/AppDBContext.cs | 49 ++++ .../20260821120000_AddPromotionsSchemes.cs | 129 ++++++++++ ...dSchemeFreeGoodsRuleAndBundleComponents.cs | 170 +++++++++++++ .../Migrations/AppDBContextModelSnapshot.cs | 225 ++++++++++++++++++ Distribution/Models/Item.cs | 3 + Distribution/Models/Scheme.cs | 62 +++++ Distribution/Models/SchemeBundleComponent.cs | 20 ++ Distribution/Models/SchemeRedemption.cs | 36 +++ 8 files changed, 694 insertions(+) create mode 100644 Distribution/Migrations/20260821120000_AddPromotionsSchemes.cs create mode 100644 Distribution/Migrations/20260821130000_AddSchemeFreeGoodsRuleAndBundleComponents.cs create mode 100644 Distribution/Models/Scheme.cs create mode 100644 Distribution/Models/SchemeBundleComponent.cs create mode 100644 Distribution/Models/SchemeRedemption.cs diff --git a/Distribution/Data/AppDBContext.cs b/Distribution/Data/AppDBContext.cs index deda8b6..4398ce2 100644 --- a/Distribution/Data/AppDBContext.cs +++ b/Distribution/Data/AppDBContext.cs @@ -36,6 +36,9 @@ public class AppDBContext(DbContextOptions options) : DbContext(op public DbSet DriverProfiles { get; set; } public DbSet DriverComplianceConfigs { get; set; } public DbSet RideCancelRequests { get; set; } + public DbSet Schemes { get; set; } + public DbSet SchemeRedemptions { get; set; } + public DbSet SchemeBundleComponents { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -90,6 +93,52 @@ public class AppDBContext(DbContextOptions options) : DbContext(op .HasForeignKey(r => r.FK_DriverProfileId) .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasOne(r => r.Scheme) + .WithMany(s => s.Redemptions) + .HasForeignKey(r => r.FK_SchemeId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(r => r.Order) + .WithMany() + .HasForeignKey(r => r.FK_OrderId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(r => r.FreeItem) + .WithMany() + .HasForeignKey(r => r.FK_FreeItemId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(s => s.TriggerItem) + .WithMany() + .HasForeignKey(s => s.FK_TriggerItemId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(s => s.FreeItem) + .WithMany() + .HasForeignKey(s => s.FK_FreeItemId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(c => c.Scheme) + .WithMany(s => s.BundleComponents) + .HasForeignKey(c => c.FK_SchemeId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(c => c.Item) + .WithMany() + .HasForeignKey(c => c.FK_ItemId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasIndex(c => new { c.FK_SchemeId, c.FK_ItemId }) + .IsUnique(); + Seed.RoleAndPermissionSeed.Apply(modelBuilder); } } diff --git a/Distribution/Migrations/20260821120000_AddPromotionsSchemes.cs b/Distribution/Migrations/20260821120000_AddPromotionsSchemes.cs new file mode 100644 index 0000000..72ed49c --- /dev/null +++ b/Distribution/Migrations/20260821120000_AddPromotionsSchemes.cs @@ -0,0 +1,129 @@ +using System; +using Distribution.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Distribution.Migrations +{ + [DbContext(typeof(AppDBContext))] + [Migration("20260821120000_AddPromotionsSchemes")] + /// + public partial class AddPromotionsSchemes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CostPrice", + table: "Items", + type: "decimal(65,30)", + nullable: true); + + migrationBuilder.CreateTable( + name: "Schemes", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + Name = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + RuleType = table.Column(type: "int", nullable: false), + RuleConfig = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + FK_RegionId = table.Column(type: "char(36)", nullable: true, collation: "ascii_general_ci"), + EligibleSegment = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + DateRangeFrom = table.Column(type: "datetime(6)", nullable: false), + DateRangeTo = table.Column(type: "datetime(6)", nullable: false), + AgentLimitExempt = table.Column(type: "tinyint(1)", nullable: false), + BaselineExpectedSales = table.Column(type: "decimal(65,30)", nullable: true), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + IsActive = table.Column(type: "tinyint(1)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Schemes", x => x.Id); + table.ForeignKey( + name: "FK_Schemes_Regions_FK_RegionId", + column: x => x.FK_RegionId, + principalTable: "Regions", + principalColumn: "Id"); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "SchemeRedemptions", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + FK_SchemeId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + FK_OrderId = table.Column(type: "char(36)", nullable: true, collation: "ascii_general_ci"), + FieldInvoiceId = table.Column(type: "char(36)", nullable: true, collation: "ascii_general_ci"), + DiscountAmount = table.Column(type: "decimal(65,30)", nullable: true), + FK_FreeItemId = table.Column(type: "char(36)", nullable: true, collation: "ascii_general_ci"), + FreeQty = table.Column(type: "decimal(65,30)", nullable: true), + FreeItemCostValue = table.Column(type: "decimal(65,30)", nullable: true), + RedeemedAt = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SchemeRedemptions", x => x.Id); + table.ForeignKey( + name: "FK_SchemeRedemptions_Schemes_FK_SchemeId", + column: x => x.FK_SchemeId, + principalTable: "Schemes", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_SchemeRedemptions_Orders_FK_OrderId", + column: x => x.FK_OrderId, + principalTable: "Orders", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_SchemeRedemptions_Items_FK_FreeItemId", + column: x => x.FK_FreeItemId, + principalTable: "Items", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_Schemes_FK_RegionId", + table: "Schemes", + column: "FK_RegionId"); + + migrationBuilder.CreateIndex( + name: "IX_SchemeRedemptions_FK_SchemeId", + table: "SchemeRedemptions", + column: "FK_SchemeId"); + + migrationBuilder.CreateIndex( + name: "IX_SchemeRedemptions_FK_OrderId", + table: "SchemeRedemptions", + column: "FK_OrderId"); + + migrationBuilder.CreateIndex( + name: "IX_SchemeRedemptions_FK_FreeItemId", + table: "SchemeRedemptions", + column: "FK_FreeItemId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "SchemeRedemptions"); + + migrationBuilder.DropTable( + name: "Schemes"); + + migrationBuilder.DropColumn( + name: "CostPrice", + table: "Items"); + } + } +} diff --git a/Distribution/Migrations/20260821130000_AddSchemeFreeGoodsRuleAndBundleComponents.cs b/Distribution/Migrations/20260821130000_AddSchemeFreeGoodsRuleAndBundleComponents.cs new file mode 100644 index 0000000..4c3457e --- /dev/null +++ b/Distribution/Migrations/20260821130000_AddSchemeFreeGoodsRuleAndBundleComponents.cs @@ -0,0 +1,170 @@ +using System; +using Distribution.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Distribution.Migrations +{ + [DbContext(typeof(AppDBContext))] + [Migration("20260821130000_AddSchemeFreeGoodsRuleAndBundleComponents")] + /// + public partial class AddSchemeFreeGoodsRuleAndBundleComponents : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "RuleConfig", + table: "Schemes", + type: "longtext", + nullable: true, + oldClrType: typeof(string), + oldType: "longtext") + .Annotation("MySql:CharSet", "utf8mb4") + .OldAnnotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.AddColumn( + name: "FK_TriggerItemId", + table: "Schemes", + type: "char(36)", + nullable: true, + collation: "ascii_general_ci"); + + migrationBuilder.AddColumn( + name: "TriggerQty", + table: "Schemes", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "FK_FreeItemId", + table: "Schemes", + type: "char(36)", + nullable: true, + collation: "ascii_general_ci"); + + migrationBuilder.AddColumn( + name: "FreeQty", + table: "Schemes", + type: "decimal(65,30)", + nullable: true); + + migrationBuilder.CreateTable( + name: "SchemeBundleComponents", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + FK_SchemeId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + FK_ItemId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + Qty = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SchemeBundleComponents", x => x.Id); + table.ForeignKey( + name: "FK_SchemeBundleComponents_Schemes_FK_SchemeId", + column: x => x.FK_SchemeId, + principalTable: "Schemes", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_SchemeBundleComponents_Items_FK_ItemId", + column: x => x.FK_ItemId, + principalTable: "Items", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_Schemes_FK_TriggerItemId", + table: "Schemes", + column: "FK_TriggerItemId"); + + migrationBuilder.CreateIndex( + name: "IX_Schemes_FK_FreeItemId", + table: "Schemes", + column: "FK_FreeItemId"); + + migrationBuilder.CreateIndex( + name: "IX_SchemeBundleComponents_FK_SchemeId_FK_ItemId", + table: "SchemeBundleComponents", + columns: new[] { "FK_SchemeId", "FK_ItemId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_SchemeBundleComponents_FK_ItemId", + table: "SchemeBundleComponents", + column: "FK_ItemId"); + + migrationBuilder.AddForeignKey( + name: "FK_Schemes_Items_FK_TriggerItemId", + table: "Schemes", + column: "FK_TriggerItemId", + principalTable: "Items", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + name: "FK_Schemes_Items_FK_FreeItemId", + table: "Schemes", + column: "FK_FreeItemId", + principalTable: "Items", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "SchemeBundleComponents"); + + migrationBuilder.DropForeignKey( + name: "FK_Schemes_Items_FK_TriggerItemId", + table: "Schemes"); + + migrationBuilder.DropForeignKey( + name: "FK_Schemes_Items_FK_FreeItemId", + table: "Schemes"); + + migrationBuilder.DropIndex( + name: "IX_Schemes_FK_TriggerItemId", + table: "Schemes"); + + migrationBuilder.DropIndex( + name: "IX_Schemes_FK_FreeItemId", + table: "Schemes"); + + migrationBuilder.DropColumn( + name: "FK_TriggerItemId", + table: "Schemes"); + + migrationBuilder.DropColumn( + name: "TriggerQty", + table: "Schemes"); + + migrationBuilder.DropColumn( + name: "FK_FreeItemId", + table: "Schemes"); + + migrationBuilder.DropColumn( + name: "FreeQty", + table: "Schemes"); + + migrationBuilder.AlterColumn( + name: "RuleConfig", + table: "Schemes", + type: "longtext", + nullable: false, + oldClrType: typeof(string), + oldType: "longtext", + oldNullable: true) + .Annotation("MySql:CharSet", "utf8mb4") + .OldAnnotation("MySql:CharSet", "utf8mb4"); + } + } +} diff --git a/Distribution/Migrations/AppDBContextModelSnapshot.cs b/Distribution/Migrations/AppDBContextModelSnapshot.cs index fe07a1d..1ba6d4d 100644 --- a/Distribution/Migrations/AppDBContextModelSnapshot.cs +++ b/Distribution/Migrations/AppDBContextModelSnapshot.cs @@ -196,6 +196,9 @@ namespace Distribution.Migrations b.Property("CreatedAt") .HasColumnType("datetime(6)"); + b.Property("CostPrice") + .HasColumnType("decimal(65,30)"); + b.Property("Description") .HasColumnType("longtext"); @@ -819,6 +822,135 @@ namespace Distribution.Migrations b.ToTable("Regions"); }); + modelBuilder.Entity("Distribution.Models.Scheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AgentLimitExempt") + .HasColumnType("tinyint(1)"); + + b.Property("BaselineExpectedSales") + .HasColumnType("decimal(65,30)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DateRangeFrom") + .HasColumnType("datetime(6)"); + + b.Property("DateRangeTo") + .HasColumnType("datetime(6)"); + + b.Property("EligibleSegment") + .HasColumnType("longtext"); + + b.Property("FK_FreeItemId") + .HasColumnType("char(36)"); + + b.Property("FK_RegionId") + .HasColumnType("char(36)"); + + b.Property("FK_TriggerItemId") + .HasColumnType("char(36)"); + + b.Property("FreeQty") + .HasColumnType("decimal(65,30)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("RuleConfig") + .HasColumnType("longtext"); + + b.Property("RuleType") + .HasColumnType("int"); + + b.Property("TriggerQty") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_FreeItemId"); + + b.HasIndex("FK_RegionId"); + + b.HasIndex("FK_TriggerItemId"); + + b.ToTable("Schemes"); + }); + + modelBuilder.Entity("Distribution.Models.SchemeBundleComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("FK_ItemId") + .HasColumnType("char(36)"); + + b.Property("FK_SchemeId") + .HasColumnType("char(36)"); + + b.Property("Qty") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_ItemId"); + + b.HasIndex("FK_SchemeId", "FK_ItemId") + .IsUnique(); + + b.ToTable("SchemeBundleComponents"); + }); + + modelBuilder.Entity("Distribution.Models.SchemeRedemption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("DiscountAmount") + .HasColumnType("decimal(65,30)"); + + b.Property("FK_FreeItemId") + .HasColumnType("char(36)"); + + b.Property("FK_OrderId") + .HasColumnType("char(36)"); + + b.Property("FK_SchemeId") + .HasColumnType("char(36)"); + + b.Property("FieldInvoiceId") + .HasColumnType("char(36)"); + + b.Property("FreeItemCostValue") + .HasColumnType("decimal(65,30)"); + + b.Property("FreeQty") + .HasColumnType("decimal(65,30)"); + + b.Property("RedeemedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("FK_FreeItemId"); + + b.HasIndex("FK_OrderId"); + + b.HasIndex("FK_SchemeId"); + + b.ToTable("SchemeRedemptions"); + }); + modelBuilder.Entity("Distribution.Models.RideCancelRequest", b => { b.Property("Id") @@ -2939,6 +3071,25 @@ namespace Distribution.Migrations b.Navigation("Role"); }); + modelBuilder.Entity("Distribution.Models.PriceListItem", b => + { + b.HasOne("Distribution.Models.Item", "Item") + .WithMany() + .HasForeignKey("FK_ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.PriceList", "PriceList") + .WithMany("Items") + .HasForeignKey("FK_PriceListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("PriceList"); + }); + modelBuilder.Entity("Distribution.Models.Route", b => { b.HasOne("Distribution.Models.Region", "Region") @@ -2950,6 +3101,73 @@ namespace Distribution.Migrations b.Navigation("Region"); }); + modelBuilder.Entity("Distribution.Models.Scheme", b => + { + b.HasOne("Distribution.Models.Item", "FreeItem") + .WithMany() + .HasForeignKey("FK_FreeItemId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Distribution.Models.Region", "Region") + .WithMany() + .HasForeignKey("FK_RegionId"); + + b.HasOne("Distribution.Models.Item", "TriggerItem") + .WithMany() + .HasForeignKey("FK_TriggerItemId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("FreeItem"); + + b.Navigation("Region"); + + b.Navigation("TriggerItem"); + }); + + modelBuilder.Entity("Distribution.Models.SchemeBundleComponent", b => + { + b.HasOne("Distribution.Models.Item", "Item") + .WithMany() + .HasForeignKey("FK_ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.Scheme", "Scheme") + .WithMany("BundleComponents") + .HasForeignKey("FK_SchemeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Scheme"); + }); + + modelBuilder.Entity("Distribution.Models.SchemeRedemption", b => + { + b.HasOne("Distribution.Models.Item", "FreeItem") + .WithMany() + .HasForeignKey("FK_FreeItemId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Distribution.Models.Order", "Order") + .WithMany() + .HasForeignKey("FK_OrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Distribution.Models.Scheme", "Scheme") + .WithMany("Redemptions") + .HasForeignKey("FK_SchemeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("FreeItem"); + + b.Navigation("Order"); + + b.Navigation("Scheme"); + }); + modelBuilder.Entity("Distribution.Models.StockBalance", b => { b.HasOne("Distribution.Models.Item", "Item") @@ -3135,6 +3353,13 @@ namespace Distribution.Migrations { b.Navigation("Items"); }); + + modelBuilder.Entity("Distribution.Models.Scheme", b => + { + b.Navigation("BundleComponents"); + + b.Navigation("Redemptions"); + }); #pragma warning restore 612, 618 } } diff --git a/Distribution/Models/Item.cs b/Distribution/Models/Item.cs index 289d948..5aeecd9 100644 --- a/Distribution/Models/Item.cs +++ b/Distribution/Models/Item.cs @@ -26,6 +26,9 @@ namespace Distribution.Models public decimal? SalePrice { get; set; } + /// Cost/purchase price, used to value free-goods scheme redemptions for getSchemeROI. + public decimal? CostPrice { get; set; } + public SyncSource SyncSource { get; set; } = SyncSource.Manual; public int? ExternalErpItemId { get; set; } public DateTime? LastSyncedAt { get; set; } diff --git a/Distribution/Models/Scheme.cs b/Distribution/Models/Scheme.cs new file mode 100644 index 0000000..c00b30a --- /dev/null +++ b/Distribution/Models/Scheme.cs @@ -0,0 +1,62 @@ +namespace Distribution.Models +{ + public enum SchemeRuleType + { + BuyXGetY, + SlabDiscount, + FlatPercent, + FreeBundle + } + + public class Scheme : Template + { + public string Name { get; set; } + public SchemeRuleType RuleType { get; set; } + + /// + /// JSON payload for percentage-based rules only (SlabDiscount tiers, FlatPercent). Free-goods + /// rules (BuyXGetY / FreeBundle) are structured fields/child rows below, not JSON, so they can + /// be managed through their own CRUD endpoints instead of a hand-edited blob. + /// + public string? RuleConfig { get; set; } + + // ── BuyXGetY: trigger item/qty; shares FK_FreeItemId/FreeQty below as the payout. ────────── + + public Guid? FK_TriggerItemId { get; set; } + public Item? TriggerItem { get; set; } + public int? TriggerQty { get; set; } + + // ── Free-goods payout, used by both BuyXGetY and FreeBundle. ───────────────────────────── + + public Guid? FK_FreeItemId { get; set; } + public Item? FreeItem { get; set; } + public decimal? FreeQty { get; set; } + + /// Required component items for a FreeBundle scheme — managed via its own CRUD. + public ICollection BundleComponents { get; set; } = []; + + public Guid? FK_RegionId { get; set; } + public Region? Region { get; set; } + + /// Store segment this scheme applies to (Gold/Silver/Key Account/...); null = all segments. + public string? EligibleSegment { get; set; } + + public DateTime DateRangeFrom { get; set; } + public DateTime DateRangeTo { get; set; } + + /// + /// When true, this scheme's discount is exempt from the per-role discount ceiling in + /// 08_sales_agent_controls.md and stacks on top of it unconditionally. + /// + public bool AgentLimitExempt { get; set; } + + /// + /// Manual baseline (expected sales with no scheme running) used by getSchemeROI. Per the + /// open question in docs/17_promotions_schemes.md, this is entered at scheme creation + /// rather than computed from a trailing-period average. + /// + public decimal? BaselineExpectedSales { get; set; } + + public ICollection Redemptions { get; set; } = []; + } +} diff --git a/Distribution/Models/SchemeBundleComponent.cs b/Distribution/Models/SchemeBundleComponent.cs new file mode 100644 index 0000000..0a446b8 --- /dev/null +++ b/Distribution/Models/SchemeBundleComponent.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Distribution.Models +{ + /// A required item+qty line of a FreeBundle scheme (e.g. "buy A + B, get C free"). + public class SchemeBundleComponent + { + public Guid Id { get; set; } = Guid.NewGuid(); + + public Guid FK_SchemeId { get; set; } + [ForeignKey("FK_SchemeId")] + public Scheme Scheme { get; set; } + + public Guid FK_ItemId { get; set; } + [ForeignKey("FK_ItemId")] + public Item Item { get; set; } + + public int Qty { get; set; } + } +} diff --git a/Distribution/Models/SchemeRedemption.cs b/Distribution/Models/SchemeRedemption.cs new file mode 100644 index 0000000..78dbf7b --- /dev/null +++ b/Distribution/Models/SchemeRedemption.cs @@ -0,0 +1,36 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Distribution.Models +{ + public class SchemeRedemption + { + public Guid Id { get; set; } = Guid.NewGuid(); + + public Guid FK_SchemeId { get; set; } + [ForeignKey("FK_SchemeId")] + public Scheme Scheme { get; set; } + + public Guid? FK_OrderId { get; set; } + [ForeignKey("FK_OrderId")] + public Order? Order { get; set; } + + /// Placeholder for the future field-invoice module — no FieldInvoice entity exists yet. + public Guid? FieldInvoiceId { get; set; } + + public decimal? DiscountAmount { get; set; } + + public Guid? FK_FreeItemId { get; set; } + [ForeignKey("FK_FreeItemId")] + public Item? FreeItem { get; set; } + + public decimal? FreeQty { get; set; } + + /// + /// Cost price of the free item snapshotted at redemption time, so getSchemeROI stays + /// accurate even after the item's cost price later changes. + /// + public decimal? FreeItemCostValue { get; set; } + + public DateTime RedeemedAt { get; set; } = DateTime.Now; + } +} -- 2.52.0 From aabace1bba80f4d7de392becc40452ddc800f6fa Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Fri, 21 Aug 2026 14:14:07 +0530 Subject: [PATCH 2/3] Add scheme APIs and application services --- .../Features/Scheme/ISchemeRepository.cs | 19 + .../Features/Scheme/SchemeController.cs | 139 +++++ Distribution/Features/Scheme/SchemeDTOs.cs | 111 ++++ .../Features/Scheme/SchemeRepository.cs | 67 +++ Distribution/Features/Scheme/SchemeService.cs | 515 ++++++++++++++++++ Distribution/Program.cs | 3 + 6 files changed, 854 insertions(+) create mode 100644 Distribution/Features/Scheme/ISchemeRepository.cs create mode 100644 Distribution/Features/Scheme/SchemeController.cs create mode 100644 Distribution/Features/Scheme/SchemeDTOs.cs create mode 100644 Distribution/Features/Scheme/SchemeRepository.cs create mode 100644 Distribution/Features/Scheme/SchemeService.cs diff --git a/Distribution/Features/Scheme/ISchemeRepository.cs b/Distribution/Features/Scheme/ISchemeRepository.cs new file mode 100644 index 0000000..518e3c5 --- /dev/null +++ b/Distribution/Features/Scheme/ISchemeRepository.cs @@ -0,0 +1,19 @@ +namespace Distribution.Features.Scheme; + +public interface ISchemeRepository +{ + Task GetByIdAsync(Guid id); + Task> GetAllAsync(Guid? regionId = null); + Task AddAsync(Models.Scheme entity); + void Update(Models.Scheme entity); + void Remove(Models.Scheme entity); + + Task> GetRedemptionsAsync(Guid schemeId); + Task AddRedemptionAsync(Models.SchemeRedemption entity); + Task HasRedemptionAsync(Guid schemeId, Guid orderId); + + Task GetBundleComponentAsync(Guid schemeId, Guid itemId); + Task AddBundleComponentAsync(Models.SchemeBundleComponent entity); + void UpdateBundleComponent(Models.SchemeBundleComponent entity); + void RemoveBundleComponent(Models.SchemeBundleComponent entity); +} diff --git a/Distribution/Features/Scheme/SchemeController.cs b/Distribution/Features/Scheme/SchemeController.cs new file mode 100644 index 0000000..6a230eb --- /dev/null +++ b/Distribution/Features/Scheme/SchemeController.cs @@ -0,0 +1,139 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Distribution.Features.Scheme; + +[ApiController] +[Route("api/schemes")] +[Authorize] +public class SchemeController(SchemeService service) : ControllerBase +{ + [HttpGet] + public async Task GetAll([FromQuery] Guid? regionId) => + Ok(await service.GetAllAsync(regionId)); + + [HttpGet("{id:guid}")] + public async Task GetById(Guid id) + { + var result = await service.GetByIdAsync(id); + return result is null ? NotFound() : Ok(result); + } + + [HttpPost] + public async Task Create(CreateSchemeRequest request, CancellationToken ct) + { + try + { + var result = await service.CreateAsync(request, ct); + return CreatedAtAction(nameof(GetById), new { id = result.Id }, result); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + } + + [HttpPut("{id:guid}")] + public async Task Update(Guid id, UpdateSchemeRequest request, CancellationToken ct) + { + try + { + var result = await service.UpdateAsync(id, request, ct); + return result is null ? NotFound() : Ok(result); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + } + + [HttpDelete("{id:guid}")] + public async Task Delete(Guid id, CancellationToken ct) + { + var deleted = await service.DeleteAsync(id, ct); + return deleted ? NoContent() : NotFound(); + } + + [HttpPut("{id:guid}/free-goods-rule")] + public async Task SetFreeGoodsRule(Guid id, SetFreeGoodsRuleRequest request, CancellationToken ct) + { + try + { + var result = await service.SetFreeGoodsRuleAsync(id, request, ct); + return result is null ? NotFound() : Ok(result); + } + catch (InvalidOperationException ex) + { + return Conflict(new { error = ex.Message }); + } + } + + [HttpDelete("{id:guid}/free-goods-rule")] + public async Task ClearFreeGoodsRule(Guid id, CancellationToken ct) + { + var result = await service.ClearFreeGoodsRuleAsync(id, ct); + return result is null ? NotFound() : Ok(result); + } + + [HttpPut("{id:guid}/bundle-components/{itemId:guid}")] + public async Task UpsertBundleComponent(Guid id, Guid itemId, UpsertBundleComponentRequest request, CancellationToken ct) + { + try + { + var result = await service.UpsertBundleComponentAsync(id, itemId, request, ct); + return result is null ? NotFound() : Ok(result); + } + catch (InvalidOperationException ex) + { + return Conflict(new { error = ex.Message }); + } + } + + [HttpDelete("{id:guid}/bundle-components/{itemId:guid}")] + public async Task RemoveBundleComponent(Guid id, Guid itemId, CancellationToken ct) + { + var result = await service.RemoveBundleComponentAsync(id, itemId, ct); + return result is null ? NotFound() : Ok(result); + } + + [HttpGet("eligible")] + public async Task GetEligible([FromQuery] Guid storeId, [FromQuery] Guid orderId) + { + var result = await service.EvaluateEligibilityAsync(storeId, orderId); + return result is null ? NotFound() : Ok(result); + } + + [HttpGet("{id:guid}/spend")] + public async Task GetSpend(Guid id) + { + var result = await service.GetSpendAsync(id); + return result is null ? NotFound() : Ok(result); + } + + [HttpGet("{id:guid}/roi")] + public async Task GetROI(Guid id) + { + var result = await service.GetROIAsync(id); + return result is null ? NotFound() : Ok(result); + } +} + +[ApiController] +[Route("api/orders")] +[Authorize] +public class SchemeOrderController(SchemeService service) : ControllerBase +{ + [HttpPost("{id:guid}/apply-scheme")] + public async Task ApplyScheme(Guid id, ApplySchemeRequest request, CancellationToken ct) + { + try + { + var result = await service.ApplySchemeAsync(id, request.SchemeId, ct); + return result is null ? NotFound() : Ok(result); + } + catch (InvalidOperationException ex) + { + return Conflict(new { error = ex.Message }); + } + } +} diff --git a/Distribution/Features/Scheme/SchemeDTOs.cs b/Distribution/Features/Scheme/SchemeDTOs.cs new file mode 100644 index 0000000..be901c3 --- /dev/null +++ b/Distribution/Features/Scheme/SchemeDTOs.cs @@ -0,0 +1,111 @@ +using Distribution.Models; + +namespace Distribution.Features.Scheme; + +// ── Rule config payloads for percentage-based rules (serialized into Scheme.RuleConfig) ── + +public record SlabDiscountConfig(List Tiers); + +public record SlabTier(decimal MinOrderAmount, decimal DiscountPercent); + +public record FlatPercentConfig(decimal DiscountPercent); + +// ── Requests ──────────────────────────────────────────────────────────── + +public record BundleComponentRequest(Guid ItemId, int Qty); + +/// +/// TriggerItemId/TriggerQty/FreeItemId/FreeQty/BundleComponents are optional so plain discount +/// schemes (SlabDiscount/FlatPercent) can omit them entirely. When RuleType is BuyXGetY, supplying +/// TriggerItemId+TriggerQty+FreeItemId+FreeQty here creates the scheme fully populated in one call +/// instead of a separate PUT .../free-goods-rule. When RuleType is FreeBundle, supplying +/// FreeItemId+FreeQty+BundleComponents does the same. These can still be added/edited later via +/// PUT .../free-goods-rule and PUT .../bundle-components/{itemId}. +/// +public record CreateSchemeRequest( + string Name, + SchemeRuleType RuleType, + string? RuleConfig, + Guid? RegionId, + string? EligibleSegment, + DateTime DateRangeFrom, + DateTime DateRangeTo, + bool AgentLimitExempt, + decimal? BaselineExpectedSales, + Guid? TriggerItemId = null, + int? TriggerQty = null, + Guid? FreeItemId = null, + decimal? FreeQty = null, + List? BundleComponents = null); + +public record UpdateSchemeRequest( + string Name, + string? RuleConfig, + Guid? RegionId, + string? EligibleSegment, + DateTime DateRangeFrom, + DateTime DateRangeTo, + bool AgentLimitExempt, + decimal? BaselineExpectedSales); + +public record ApplySchemeRequest(Guid SchemeId); + +/// +/// Sets the free-goods payout for a scheme. For BuyXGetY, TriggerItemId/TriggerQty are required +/// (what the store must buy); for FreeBundle they are ignored — the required items instead come +/// from the scheme's bundle-components CRUD. FreeItemId/FreeQty is the payout in both cases. +/// +public record SetFreeGoodsRuleRequest(Guid? TriggerItemId, int? TriggerQty, Guid FreeItemId, decimal FreeQty); + +public record UpsertBundleComponentRequest(int Qty); + +// ── Responses ─────────────────────────────────────────────────────────── + +public record BundleComponentResponse(Guid ItemId, string Sku, string ItemName, int Qty); + +public record SchemeResponse( + Guid Id, + string Name, + SchemeRuleType RuleType, + string? RuleConfig, + Guid? TriggerItemId, + string? TriggerItemName, + int? TriggerQty, + Guid? FreeItemId, + string? FreeItemName, + decimal? FreeQty, + List BundleComponents, + Guid? RegionId, + string? RegionName, + string? EligibleSegment, + DateTime DateRangeFrom, + DateTime DateRangeTo, + bool AgentLimitExempt, + decimal? BaselineExpectedSales, + bool IsActive, + DateTime CreatedAt); + +public record SchemeEligibilityResponse( + Guid SchemeId, + string SchemeName, + SchemeRuleType RuleType, + bool Eligible, + string? Reason, + decimal? DiscountAmount, + Guid? FreeItemId, + decimal? FreeQty); + +public record SchemeRedemptionResponse( + Guid Id, + Guid SchemeId, + Guid? OrderId, + decimal? DiscountAmount, + Guid? FreeItemId, + string? FreeItemName, + decimal? FreeQty, + decimal? FreeItemCostValue, + DateTime RedeemedAt); + +public record SchemeSpendResponse(Guid SchemeId, decimal TotalDiscountSpend, decimal TotalFreeGoodsCostSpend, decimal TotalSpend, int RedemptionCount); + +public record SchemeROIResponse(Guid SchemeId, decimal BaselineExpectedSales, decimal ActualSales, decimal IncrementalSales, decimal TotalSpend, decimal? ROI); diff --git a/Distribution/Features/Scheme/SchemeRepository.cs b/Distribution/Features/Scheme/SchemeRepository.cs new file mode 100644 index 0000000..ae7123c --- /dev/null +++ b/Distribution/Features/Scheme/SchemeRepository.cs @@ -0,0 +1,67 @@ +using Distribution.Data; +using Microsoft.EntityFrameworkCore; + +namespace Distribution.Features.Scheme; + +public class SchemeRepository(AppDBContext context) : ISchemeRepository +{ + public async Task GetByIdAsync(Guid id) => + await context.Schemes + .Include(s => s.Region) + .Include(s => s.TriggerItem) + .Include(s => s.FreeItem) + .Include(s => s.BundleComponents).ThenInclude(c => c.Item) + .FirstOrDefaultAsync(s => s.Id == id); + + public async Task> GetAllAsync(Guid? regionId = null) + { + var query = context.Schemes + .Include(s => s.Region) + .Include(s => s.TriggerItem) + .Include(s => s.FreeItem) + .Include(s => s.BundleComponents).ThenInclude(c => c.Item) + .Where(s => s.IsActive); + + if (regionId.HasValue) query = query.Where(s => s.FK_RegionId == regionId); + + return await query.ToListAsync(); + } + + public async Task AddAsync(Models.Scheme entity) => + await context.Schemes.AddAsync(entity); + + public void Update(Models.Scheme entity) => + context.Schemes.Update(entity); + + public void Remove(Models.Scheme entity) + { + entity.IsActive = false; + context.Schemes.Update(entity); + } + + public async Task> GetRedemptionsAsync(Guid schemeId) => + await context.SchemeRedemptions + .Include(r => r.FreeItem) + .Include(r => r.Order) + .Where(r => r.FK_SchemeId == schemeId) + .ToListAsync(); + + public async Task AddRedemptionAsync(Models.SchemeRedemption entity) => + await context.SchemeRedemptions.AddAsync(entity); + + public async Task HasRedemptionAsync(Guid schemeId, Guid orderId) => + await context.SchemeRedemptions.AnyAsync(r => r.FK_SchemeId == schemeId && r.FK_OrderId == orderId); + + public async Task GetBundleComponentAsync(Guid schemeId, Guid itemId) => + await context.SchemeBundleComponents + .FirstOrDefaultAsync(c => c.FK_SchemeId == schemeId && c.FK_ItemId == itemId); + + public async Task AddBundleComponentAsync(Models.SchemeBundleComponent entity) => + await context.SchemeBundleComponents.AddAsync(entity); + + public void UpdateBundleComponent(Models.SchemeBundleComponent entity) => + context.SchemeBundleComponents.Update(entity); + + public void RemoveBundleComponent(Models.SchemeBundleComponent entity) => + context.SchemeBundleComponents.Remove(entity); +} diff --git a/Distribution/Features/Scheme/SchemeService.cs b/Distribution/Features/Scheme/SchemeService.cs new file mode 100644 index 0000000..5d88d34 --- /dev/null +++ b/Distribution/Features/Scheme/SchemeService.cs @@ -0,0 +1,515 @@ +using System.Text.Json; +using Distribution.Features.Item; +using Distribution.Features.Order; +using Distribution.Features.Stock; +using Distribution.Features.Store; +using Distribution.Infra.UoW; +using Distribution.Models; + +namespace Distribution.Features.Scheme; + +public class SchemeService( + ISchemeRepository repo, + IOrderRepository orderRepo, + IItemRepository itemRepo, + IStoreRepository storeRepo, + IStockRepository stockRepo, + IUnitOfWork uow) +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + private static SchemeResponse ToResponse(Models.Scheme s) => + new(s.Id, s.Name, s.RuleType, s.RuleConfig, + s.FK_TriggerItemId, s.TriggerItem?.Name, s.TriggerQty, + s.FK_FreeItemId, s.FreeItem?.Name, s.FreeQty, + s.BundleComponents.Select(c => new BundleComponentResponse(c.FK_ItemId, c.Item.Sku, c.Item.Name, c.Qty)).ToList(), + s.FK_RegionId, s.Region?.Name, s.EligibleSegment, + s.DateRangeFrom, s.DateRangeTo, s.AgentLimitExempt, s.BaselineExpectedSales, s.IsActive, s.CreatedAt); + + private static SchemeRedemptionResponse ToRedemptionResponse(SchemeRedemption r) => + new(r.Id, r.FK_SchemeId, r.FK_OrderId, r.DiscountAmount, r.FK_FreeItemId, r.FreeItem?.Name, + r.FreeQty, r.FreeItemCostValue, r.RedeemedAt); + + // ── CRUD ─────────────────────────────────────────────────────────────── + + public async Task> GetAllAsync(Guid? regionId = null) + { + var schemes = await repo.GetAllAsync(regionId); + return schemes.Select(ToResponse).ToList(); + } + + public async Task GetByIdAsync(Guid id) + { + var scheme = await repo.GetByIdAsync(id); + return scheme is null || !scheme.IsActive ? null : ToResponse(scheme); + } + + /// + /// createScheme(schemeRules, eligibility, dateRange). RuleConfig is only meaningful for + /// SlabDiscount/FlatPercent. For BuyXGetY/FreeBundle, the free-goods payout (and bundle + /// components) can be supplied inline here to create a fully-populated scheme in one call, or + /// added afterwards via SetFreeGoodsRuleAsync / UpsertBundleComponentAsync. + /// + public async Task CreateAsync(CreateSchemeRequest request, CancellationToken ct = default) + { + ValidatePercentRuleConfig(request.RuleType, request.RuleConfig); + if (request.DateRangeFrom > request.DateRangeTo) + throw new InvalidOperationException("DateRangeFrom must not be after DateRangeTo."); + + var hasFreeGoodsRule = request.FreeItemId.HasValue || request.FreeQty.HasValue || + request.TriggerItemId.HasValue || request.TriggerQty.HasValue; + if (hasFreeGoodsRule) + await ValidateFreeGoodsRuleAsync(request.RuleType, request.TriggerItemId, request.TriggerQty, request.FreeItemId, request.FreeQty); + + if (request.BundleComponents is { Count: > 0 }) + { + if (request.RuleType != SchemeRuleType.FreeBundle) + throw new InvalidOperationException("BundleComponents only applies to FreeBundle schemes."); + foreach (var c in request.BundleComponents) + { + if (c.Qty <= 0) + throw new InvalidOperationException("Bundle component Qty must be positive."); + if (await itemRepo.GetByIdAsync(c.ItemId) is null) + throw new InvalidOperationException($"Bundle component item '{c.ItemId}' not found."); + } + } + + var scheme = new Models.Scheme + { + Name = request.Name, + RuleType = request.RuleType, + RuleConfig = request.RuleConfig, + FK_RegionId = request.RegionId, + EligibleSegment = request.EligibleSegment, + DateRangeFrom = request.DateRangeFrom, + DateRangeTo = request.DateRangeTo, + AgentLimitExempt = request.AgentLimitExempt, + BaselineExpectedSales = request.BaselineExpectedSales + }; + + if (hasFreeGoodsRule) + { + scheme.FK_TriggerItemId = request.RuleType == SchemeRuleType.BuyXGetY ? request.TriggerItemId : null; + scheme.TriggerQty = request.RuleType == SchemeRuleType.BuyXGetY ? request.TriggerQty : null; + scheme.FK_FreeItemId = request.FreeItemId; + scheme.FreeQty = request.FreeQty; + } + + await uow.BeginAsync(ct); + try + { + await repo.AddAsync(scheme); + if (request.BundleComponents is { Count: > 0 }) + { + foreach (var c in request.BundleComponents) + await repo.AddBundleComponentAsync(new SchemeBundleComponent { FK_SchemeId = scheme.Id, FK_ItemId = c.ItemId, Qty = c.Qty }); + } + await uow.CommitAsync(ct); + } + catch { await uow.RollbackAsync(ct); throw; } + + return (await GetByIdAsync(scheme.Id))!; + } + + public async Task UpdateAsync(Guid id, UpdateSchemeRequest request, CancellationToken ct = default) + { + var scheme = await repo.GetByIdAsync(id); + if (scheme is null || !scheme.IsActive) return null; + + ValidatePercentRuleConfig(scheme.RuleType, request.RuleConfig); + if (request.DateRangeFrom > request.DateRangeTo) + throw new InvalidOperationException("DateRangeFrom must not be after DateRangeTo."); + + scheme.Name = request.Name; + scheme.RuleConfig = request.RuleConfig; + scheme.FK_RegionId = request.RegionId; + scheme.EligibleSegment = request.EligibleSegment; + scheme.DateRangeFrom = request.DateRangeFrom; + scheme.DateRangeTo = request.DateRangeTo; + scheme.AgentLimitExempt = request.AgentLimitExempt; + scheme.BaselineExpectedSales = request.BaselineExpectedSales; + + await uow.BeginAsync(ct); + try { repo.Update(scheme); await uow.CommitAsync(ct); } + catch { await uow.RollbackAsync(ct); throw; } + + return ToResponse(scheme); + } + + public async Task DeleteAsync(Guid id, CancellationToken ct = default) + { + var scheme = await repo.GetByIdAsync(id); + if (scheme is null || !scheme.IsActive) return false; + + await uow.BeginAsync(ct); + try { repo.Remove(scheme); await uow.CommitAsync(ct); } + catch { await uow.RollbackAsync(ct); throw; } + + return true; + } + + // ── Free-goods rule CRUD (BuyXGetY trigger + shared payout) ────────────── + + public async Task SetFreeGoodsRuleAsync(Guid schemeId, SetFreeGoodsRuleRequest request, CancellationToken ct = default) + { + var scheme = await repo.GetByIdAsync(schemeId); + if (scheme is null || !scheme.IsActive) return null; + + await ValidateFreeGoodsRuleAsync(scheme.RuleType, request.TriggerItemId, request.TriggerQty, request.FreeItemId, request.FreeQty); + + scheme.FK_TriggerItemId = scheme.RuleType == SchemeRuleType.BuyXGetY ? request.TriggerItemId : null; + scheme.TriggerQty = scheme.RuleType == SchemeRuleType.BuyXGetY ? request.TriggerQty : null; + scheme.FK_FreeItemId = request.FreeItemId; + scheme.FreeQty = request.FreeQty; + + await uow.BeginAsync(ct); + try { repo.Update(scheme); await uow.CommitAsync(ct); } + catch { await uow.RollbackAsync(ct); throw; } + + return (await GetByIdAsync(schemeId))!; + } + + public async Task ClearFreeGoodsRuleAsync(Guid schemeId, CancellationToken ct = default) + { + var scheme = await repo.GetByIdAsync(schemeId); + if (scheme is null || !scheme.IsActive) return null; + + scheme.FK_TriggerItemId = null; + scheme.TriggerQty = null; + scheme.FK_FreeItemId = null; + scheme.FreeQty = null; + + await uow.BeginAsync(ct); + try { repo.Update(scheme); await uow.CommitAsync(ct); } + catch { await uow.RollbackAsync(ct); throw; } + + return (await GetByIdAsync(schemeId))!; + } + + // ── Bundle component CRUD (FreeBundle required items) ──────────────────── + + public async Task UpsertBundleComponentAsync(Guid schemeId, Guid itemId, UpsertBundleComponentRequest request, CancellationToken ct = default) + { + var scheme = await repo.GetByIdAsync(schemeId); + if (scheme is null || !scheme.IsActive) return null; + + if (scheme.RuleType != SchemeRuleType.FreeBundle) + throw new InvalidOperationException("Bundle components only apply to FreeBundle schemes."); + if (request.Qty <= 0) + throw new InvalidOperationException("Qty must be positive."); + + var item = await itemRepo.GetByIdAsync(itemId) + ?? throw new InvalidOperationException("Item not found."); + + var existing = await repo.GetBundleComponentAsync(schemeId, itemId); + + await uow.BeginAsync(ct); + try + { + if (existing is null) + { + await repo.AddBundleComponentAsync(new SchemeBundleComponent + { + FK_SchemeId = schemeId, + FK_ItemId = item.Id, + Qty = request.Qty + }); + } + else + { + existing.Qty = request.Qty; + repo.UpdateBundleComponent(existing); + } + await uow.CommitAsync(ct); + } + catch { await uow.RollbackAsync(ct); throw; } + + return (await GetByIdAsync(schemeId))!; + } + + public async Task RemoveBundleComponentAsync(Guid schemeId, Guid itemId, CancellationToken ct = default) + { + var scheme = await repo.GetByIdAsync(schemeId); + if (scheme is null || !scheme.IsActive) return null; + + var existing = await repo.GetBundleComponentAsync(schemeId, itemId); + if (existing is null) return null; + + await uow.BeginAsync(ct); + try { repo.RemoveBundleComponent(existing); await uow.CommitAsync(ct); } + catch { await uow.RollbackAsync(ct); throw; } + + return (await GetByIdAsync(schemeId))!; + } + + // ── evaluateSchemeEligibility(storeId, orderId) ────────────────────────── + + public async Task?> EvaluateEligibilityAsync(Guid storeId, Guid orderId) + { + var store = await storeRepo.GetByIdWithRegionAsync(storeId); + if (store is null || !store.IsActive) return null; + + var order = await orderRepo.GetByIdAsync(orderId); + if (order is null || !order.IsActive) return null; + + var orderItems = await orderRepo.GetItemsByOrderIdAsync(orderId); + var regionId = store.Route?.FK_RegionId; + + var candidates = await repo.GetAllAsync(); + var result = new List(); + + foreach (var scheme in candidates) + { + if (scheme.FK_RegionId.HasValue && scheme.FK_RegionId != regionId) + continue; + if (!string.IsNullOrEmpty(scheme.EligibleSegment) && + !string.Equals(scheme.EligibleSegment, store.Segment, StringComparison.OrdinalIgnoreCase)) + continue; + if (order.OrderDate < scheme.DateRangeFrom || order.OrderDate > scheme.DateRangeTo) + continue; + + result.Add(EvaluateRule(scheme, orderItems)); + } + + return result; + } + + /// + /// Scope-only check (region, segment, date range as of today) with no order to evaluate line + /// items against — used by Store Management's `checkStoreSchemeEligibility(storeId, schemeId)` + /// (12_store_management.md), which has no order context. Returns null if the store or scheme + /// doesn't exist; otherwise (Eligible, Reason) — Eligible here means "in scope for this store", + /// not "this order's items satisfy the rule" (use EvaluateEligibilityAsync for that). + /// + public async Task<(bool Eligible, string? Reason)?> CheckStoreScopeEligibilityAsync(Guid storeId, Guid schemeId) + { + var store = await storeRepo.GetByIdWithRegionAsync(storeId); + if (store is null || !store.IsActive) return null; + + var scheme = await repo.GetByIdAsync(schemeId); + if (scheme is null || !scheme.IsActive) return null; + + var regionId = store.Route?.FK_RegionId; + if (scheme.FK_RegionId.HasValue && scheme.FK_RegionId != regionId) + return (false, "Scheme is not available in the store's region."); + + if (!string.IsNullOrEmpty(scheme.EligibleSegment) && + !string.Equals(scheme.EligibleSegment, store.Segment, StringComparison.OrdinalIgnoreCase)) + return (false, $"Scheme is only available to the '{scheme.EligibleSegment}' segment."); + + var today = DateTime.Now; + if (today < scheme.DateRangeFrom || today > scheme.DateRangeTo) + return (false, "Scheme is not currently active (outside its date range)."); + + return (true, null); + } + + private static SchemeEligibilityResponse EvaluateRule(Models.Scheme scheme, List orderItems) + { + switch (scheme.RuleType) + { + case SchemeRuleType.BuyXGetY: + { + if (scheme.FK_TriggerItemId is null || scheme.TriggerQty is null or <= 0 || + scheme.FK_FreeItemId is null || scheme.FreeQty is null or <= 0) + return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false, + "Scheme has no free-goods rule configured.", null, null, null); + + var boughtQty = orderItems.Where(i => i.FK_ItemId == scheme.FK_TriggerItemId).Sum(i => i.Quantity); + if (boughtQty < scheme.TriggerQty) + return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false, + $"Requires {scheme.TriggerQty} of the trigger item; order has {boughtQty}.", null, null, null); + + var multiplier = boughtQty / scheme.TriggerQty.Value; + return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, true, null, + null, scheme.FK_FreeItemId, scheme.FreeQty * multiplier); + } + case SchemeRuleType.FreeBundle: + { + if (scheme.BundleComponents.Count == 0 || scheme.FK_FreeItemId is null || scheme.FreeQty is null or <= 0) + return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false, + "Scheme has no bundle components or free-goods payout configured.", null, null, null); + + var missing = scheme.BundleComponents + .Where(c => orderItems.Where(i => i.FK_ItemId == c.FK_ItemId).Sum(i => i.Quantity) < c.Qty) + .ToList(); + if (missing.Count > 0) + return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false, + "Order is missing required bundle components.", null, null, null); + + return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, true, null, + null, scheme.FK_FreeItemId, scheme.FreeQty); + } + case SchemeRuleType.FlatPercent: + { + if (string.IsNullOrEmpty(scheme.RuleConfig)) + return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false, "Scheme has no RuleConfig.", null, null, null); + var cfg = JsonSerializer.Deserialize(scheme.RuleConfig, JsonOptions)!; + var orderTotal = orderItems.Sum(i => i.Quantity * i.UnitPrice); + return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, true, null, + Math.Round(orderTotal * cfg.DiscountPercent / 100m, 2), null, null); + } + case SchemeRuleType.SlabDiscount: + { + if (string.IsNullOrEmpty(scheme.RuleConfig)) + return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false, "Scheme has no RuleConfig.", null, null, null); + var cfg = JsonSerializer.Deserialize(scheme.RuleConfig, JsonOptions)!; + var orderTotal = orderItems.Sum(i => i.Quantity * i.UnitPrice); + var tier = cfg.Tiers.Where(t => orderTotal >= t.MinOrderAmount) + .OrderByDescending(t => t.MinOrderAmount).FirstOrDefault(); + if (tier is null) + return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false, + "Order total does not meet any slab threshold.", null, null, null); + + return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, true, null, + Math.Round(orderTotal * tier.DiscountPercent / 100m, 2), null, null); + } + default: + return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false, "Unknown rule type.", null, null, null); + } + } + + // ── applySchemeDiscount(orderId, schemeId) ─────────────────────────────── + + public async Task ApplySchemeAsync(Guid orderId, Guid schemeId, CancellationToken ct = default) + { + var scheme = await repo.GetByIdAsync(schemeId); + if (scheme is null || !scheme.IsActive) return null; + + var order = await orderRepo.GetByIdAsync(orderId); + if (order is null || !order.IsActive) return null; + + if (await repo.HasRedemptionAsync(schemeId, orderId)) + throw new InvalidOperationException("This scheme has already been redeemed against this order."); + + var orderItems = await orderRepo.GetItemsByOrderIdAsync(orderId); + var eligibility = EvaluateRule(scheme, orderItems); + if (!eligibility.Eligible) + throw new InvalidOperationException(eligibility.Reason ?? "Order is not eligible for this scheme."); + + var redemption = new SchemeRedemption + { + FK_SchemeId = schemeId, + FK_OrderId = orderId, + DiscountAmount = eligibility.DiscountAmount + }; + + var newOrderItems = new List(); + + if (eligibility.FreeItemId.HasValue && eligibility.FreeQty is > 0) + { + var freeItem = await itemRepo.GetByIdAsync(eligibility.FreeItemId.Value) + ?? throw new InvalidOperationException("Free item not found."); + + var availableQty = (await stockRepo.GetBalancesByItemAsync(freeItem.Id)).Sum(b => b.Quantity); + if (availableQty < eligibility.FreeQty.Value) + throw new InvalidOperationException($"Insufficient stock of '{freeItem.Sku}' to fulfil the free-goods scheme."); + + newOrderItems.Add(new OrderItem + { + FK_OrderId = orderId, + FK_ItemId = freeItem.Id, + Quantity = (int)eligibility.FreeQty.Value, + UnitPrice = 0m + }); + + redemption.FK_FreeItemId = freeItem.Id; + redemption.FreeQty = eligibility.FreeQty; + redemption.FreeItemCostValue = freeItem.CostPrice ?? 0m; + } + + await uow.BeginAsync(ct); + try + { + if (newOrderItems.Count > 0) await orderRepo.AddItemsAsync(newOrderItems); + await repo.AddRedemptionAsync(redemption); + await uow.CommitAsync(ct); + } + catch { await uow.RollbackAsync(ct); throw; } + + var reloaded = (await repo.GetRedemptionsAsync(schemeId)).First(r => r.Id == redemption.Id); + return ToRedemptionResponse(reloaded); + } + + // ── trackSchemeSpend(schemeId) ─────────────────────────────────────────── + + public async Task GetSpendAsync(Guid schemeId) + { + var scheme = await repo.GetByIdAsync(schemeId); + if (scheme is null) return null; + + var redemptions = await repo.GetRedemptionsAsync(schemeId); + var discountSpend = redemptions.Sum(r => r.DiscountAmount ?? 0m); + var freeGoodsSpend = redemptions.Sum(r => (r.FreeItemCostValue ?? 0m) * (r.FreeQty ?? 0m)); + + return new SchemeSpendResponse(schemeId, discountSpend, freeGoodsSpend, discountSpend + freeGoodsSpend, redemptions.Count); + } + + // ── getSchemeROI(schemeId) ─────────────────────────────────────────────── + + public async Task GetROIAsync(Guid schemeId) + { + var scheme = await repo.GetByIdAsync(schemeId); + if (scheme is null) return null; + + var redemptions = await repo.GetRedemptionsAsync(schemeId); + var spend = await GetSpendAsync(schemeId); + + var actualSales = redemptions + .Where(r => r.Order is not null) + .Select(r => r.Order!) + .Distinct() + .Sum(o => o.TotalAmount); + + var baseline = scheme.BaselineExpectedSales ?? 0m; + var incremental = actualSales - baseline; + decimal? roi = spend!.TotalSpend > 0 ? incremental / spend.TotalSpend : null; + + return new SchemeROIResponse(schemeId, baseline, actualSales, incremental, spend.TotalSpend, roi); + } + + private async Task ValidateFreeGoodsRuleAsync(SchemeRuleType ruleType, Guid? triggerItemId, int? triggerQty, Guid? freeItemId, decimal? freeQty) + { + if (ruleType is not (SchemeRuleType.BuyXGetY or SchemeRuleType.FreeBundle)) + throw new InvalidOperationException($"Free-goods rule only applies to BuyXGetY or FreeBundle schemes, not '{ruleType}'."); + + if (ruleType == SchemeRuleType.BuyXGetY) + { + if (triggerItemId is null || triggerQty is null or <= 0) + throw new InvalidOperationException("BuyXGetY requires TriggerItemId and a positive TriggerQty."); + if (await itemRepo.GetByIdAsync(triggerItemId.Value) is null) + throw new InvalidOperationException("Trigger item not found."); + } + + if (freeItemId is null || freeQty is null or <= 0) + throw new InvalidOperationException("FreeItemId and a positive FreeQty are required."); + if (await itemRepo.GetByIdAsync(freeItemId.Value) is null) + throw new InvalidOperationException("Free item not found."); + } + + private static void ValidatePercentRuleConfig(SchemeRuleType ruleType, string? ruleConfig) + { + if (ruleType is not (SchemeRuleType.SlabDiscount or SchemeRuleType.FlatPercent)) return; + + if (string.IsNullOrWhiteSpace(ruleConfig)) + throw new InvalidOperationException($"RuleConfig is required for RuleType '{ruleType}'."); + + try + { + if (ruleType == SchemeRuleType.SlabDiscount) + { + var slab = JsonSerializer.Deserialize(ruleConfig, JsonOptions) ?? throw new JsonException(); + if (slab.Tiers.Count == 0) + throw new InvalidOperationException("SlabDiscount scheme requires at least one tier."); + } + else + { + _ = JsonSerializer.Deserialize(ruleConfig, JsonOptions) ?? throw new JsonException(); + } + } + catch (JsonException) + { + throw new InvalidOperationException($"RuleConfig is not valid JSON for RuleType '{ruleType}'."); + } + } +} diff --git a/Distribution/Program.cs b/Distribution/Program.cs index a0b44eb..d6001fc 100644 --- a/Distribution/Program.cs +++ b/Distribution/Program.cs @@ -12,6 +12,7 @@ using Distribution.Features.Region; using Distribution.Features.Ride; using Distribution.Features.Role; using Distribution.Features.Route; +using Distribution.Features.Scheme; using Distribution.Features.Store; using Distribution.Features.Store.Kyc; using Distribution.Features.Supplier; @@ -59,6 +60,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -81,6 +83,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); -- 2.52.0 From dc4b1934e1f2d90b59b2f8c1b484e03b411082ff Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Fri, 21 Aug 2026 14:14:14 +0530 Subject: [PATCH 3/3] Wire store scheme eligibility and update docs --- Distribution/Features/Store/StoreService.cs | 9 +- Distribution/docs/12_store_management.md | 24 +- Distribution/docs/17_promotions_schemes.md | 360 ++++++++++++++++++-- Distribution/docs/mainPlan.md | 20 +- 4 files changed, 367 insertions(+), 46 deletions(-) diff --git a/Distribution/Features/Store/StoreService.cs b/Distribution/Features/Store/StoreService.cs index 0849fb9..1683175 100644 --- a/Distribution/Features/Store/StoreService.cs +++ b/Distribution/Features/Store/StoreService.cs @@ -1,10 +1,11 @@ +using Distribution.Features.Scheme; using Distribution.Infra.Authorization; using Distribution.Infra.UoW; using Distribution.Models; namespace Distribution.Features.Store; -public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess) +public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess, SchemeService schemeService) { private const int GlobalScopeRoleLevel = 50; private const int CreditApproverRoleLevel = 20; @@ -217,8 +218,10 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe var store = await repo.GetByIdAsync(id); if (store is null || !store.IsActive) return null; if (!await HasStoreScopeAsync(id)) return null; - // Wired to Step 17 (Promotions & Schemes) once scheme rules exist. - return new SchemeEligibilityResponse(true, null); + + var result = await schemeService.CheckStoreScopeEligibilityAsync(id, schemeId); + if (result is null) return null; + return new SchemeEligibilityResponse(result.Value.Eligible, result.Value.Reason); } public async Task GetDashboardAsync(Guid id, string period) diff --git a/Distribution/docs/12_store_management.md b/Distribution/docs/12_store_management.md index 50922a3..a6676a9 100644 --- a/Distribution/docs/12_store_management.md +++ b/Distribution/docs/12_store_management.md @@ -21,12 +21,18 @@ gap #8 in [00_existing_gaps.md](./00_existing_gaps.md). Credit fields exist info `OneTimeAmount` / `CreditAmount`, which stay as-is; this step adds the real credit-limit + outstanding-balance + KYC-gated-ordering flow on top. -`getStoreVisitHistory` and `checkStoreSchemeEligibility` are read-only integration points into -modules that don't exist yet ([11_route_sales_management.md](./11_route_sales_management.md) for -visits, [17_promotions_schemes.md](./17_promotions_schemes.md) for schemes). Implement them now -with the query shape defined below; wire the actual join once those modules land — return an empty -list / `eligible: true` (no active schemes to check) until then, guarded by a `NotImplementedYet` -style short-circuit, not a broken query. +`getStoreVisitHistory` and `checkStoreSchemeEligibility` were originally read-only integration +points into modules that didn't exist yet ([11_route_sales_management.md](./11_route_sales_management.md) +for visits, [17_promotions_schemes.md](./17_promotions_schemes.md) for schemes) — both shipped as +placeholders (empty list / `eligible: true`) guarded by a `NotImplementedYet`-style short-circuit. + +**Update (2026-08-21, after 17_promotions_schemes.md landed):** `checkStoreSchemeEligibility` is now +wired for real. `StoreService.CheckSchemeEligibilityAsync` calls +`SchemeService.CheckStoreScopeEligibilityAsync(storeId, schemeId)`, which checks the scheme's +region/segment/date-range scope against the store (no order context, since this endpoint takes no +`orderId` — that's a *scope* eligibility check, not the full order-line rule evaluation that +`GET /api/schemes/eligible?storeId=...&orderId=...` does). `getStoreVisitHistory` is still an empty +list — [11_route_sales_management.md](./11_route_sales_management.md) remains Not Started. --- @@ -102,7 +108,7 @@ One migration needed: | GET | /api/stores/{id}/outstanding-balance | stores.view | Returns cached value + CreditLimit + available credit | | PUT | /api/stores/{id}/segment | stores.update | Body: `{ "segment": "Gold" }` | | GET | /api/stores/{id}/visit-history | stores.view | Paginated. Empty list until Step 11 wiring lands | -| GET | /api/stores/{id}/schemes/{schemeId}/eligibility | stores.view | Returns `{ eligible: true, reason: null }` until Step 17 wiring lands | +| GET | /api/stores/{id}/schemes/{schemeId}/eligibility | stores.view | Wired to Step 17 — returns real region/segment/date-range eligibility for the store against that scheme | | GET | /api/stores/{id}/dashboard?period=... | stores.view | Aggregates order count/value for the period from existing Order tables | --- @@ -206,7 +212,9 @@ Features/ StoreController.cs (extended — credit-limit, balance, kyc/verify, segment, visit-history, scheme-eligibility, dashboard endpoints) StoreDTOs.cs (extended — new request/response records) - StoreService.cs (extended — new methods, RoleLevel >= 20 credit-approver check) + StoreService.cs (extended — new methods, RoleLevel >= 20 credit-approver check; + CheckSchemeEligibilityAsync now depends on Features/Scheme's + SchemeService for the real region/segment/date-range check) IStoreRepository.cs (extended — GetOrdersForDashboardAsync) StoreRepository.cs (extended) Kyc/ diff --git a/Distribution/docs/17_promotions_schemes.md b/Distribution/docs/17_promotions_schemes.md index f0a67e4..23a435f 100644 --- a/Distribution/docs/17_promotions_schemes.md +++ b/Distribution/docs/17_promotions_schemes.md @@ -1,70 +1,378 @@ # 17 — Promotions & Trade Scheme Management -**Status**: Not Started -**Depends On**: 01 — Item Catalog, 14 — Pricing & Supplier Master Data +**Status**: Done — migrations applied to the database +**Depends On**: 01 — Item Catalog (Done), 14 — Pricing & Supplier Master Data (Done), 02 — Stock Ledger (Done) --- ## Goal Covers [mainPlan.md §11](./mainPlan.md#11-promotions--trade-scheme-management) -in full — an entirely new subsystem. Once built, `applyPricingAndDiscounts` (§1) and +in full — an entirely new subsystem. `applyPricingAndDiscounts` (§1) and `checkStoreSchemeEligibility` (§4) both read from it. --- ## Background / Context -No scheme/promotion entity exists today. Every reference to "eligible discount" elsewhere in this -doc set (order pricing, field invoices, agent discount limits) currently has no scheme data source -— it only enforces the flat per-role discount ceiling from +No scheme/promotion entity existed before this step. Every reference to "eligible discount" +elsewhere in this doc set (order pricing, field invoices, agent discount limits) had no scheme data +source — it only enforced the flat per-role discount ceiling from [08_sales_agent_controls.md](./08_sales_agent_controls.md). +For an enterprise distributor, free-goods/bundle promotions ("buy 10 get 1 free", "buy A + B get C +free") are operationally different from discount schemes because they move physical stock at zero +revenue, not just reduce a price. That distinction shaped the data model below: `BuyXGetY` and +`FreeBundle` rules are structured columns/child rows (with their own CRUD), not a JSON blob, so a +free-goods rule can be validated (item exists, positive qty, stock available) the same way any other +order line is. `SlabDiscount`/`FlatPercent` stay JSON in `RuleConfig` since they're pure +percentage-off rules with no inventory or fulfilment side effects. + --- ## Data Model Changes ### Scheme +``` +Scheme +├── Id (Guid) — PK, from Template +├── Name (string) +├── RuleType (enum) — BuyXGetY, SlabDiscount, FlatPercent, FreeBundle +├── RuleConfig (string?) — JSON, only used by SlabDiscount/FlatPercent (see below); null +│ for BuyXGetY/FreeBundle +├── FK_TriggerItemId (Guid?) — BuyXGetY only: the item the store must buy +├── TriggerQty (int?) — BuyXGetY only: qty of TriggerItem required to earn one payout +├── FK_FreeItemId (Guid?) — shared payout for BuyXGetY and FreeBundle: item given free +├── FreeQty (decimal?) — shared payout: qty of FreeItem given free +├── FK_RegionId (Guid?) — nullable, null = all regions +├── EligibleSegment (string?) — Store.Segment match (Gold/Silver/Key Account/...); null = all segments +├── DateRangeFrom (DateTime) +├── DateRangeTo (DateTime) +├── AgentLimitExempt (bool) — if true, this scheme's discount stacks on top of the per-role +│ discount ceiling (08_sales_agent_controls.md) unconditionally +├── BaselineExpectedSales (decimal?) — manual baseline used by getSchemeROI (see Open Questions) +├── IsActive, CreatedAt — from Template +``` -`Name`, `RuleType` (`BuyXGetY`, `SlabDiscount`, `FlatPercent`), `RuleConfig` (JSON), `Eligibility` -(store segment / region scope), `DateRangeFrom`, `DateRangeTo`. +`RuleConfig` JSON shape (SlabDiscount/FlatPercent only): +- `FlatPercent` — `{ "discountPercent": 10 }` +- `SlabDiscount` — `{ "tiers": [{ "minOrderAmount": 5000, "discountPercent": 5 }, ...] }`, the + highest tier whose `minOrderAmount` the order total meets or exceeds wins. -### Scheme Redemption +### SchemeBundleComponent +``` +SchemeBundleComponent +├── Id (Guid) — PK +├── FK_SchemeId (Guid) — required, cascade delete with parent Scheme +├── FK_ItemId (Guid) — required +├── Qty (int) — required qty of this item for the FreeBundle rule to trigger +``` +Unique constraint on `(SchemeId, ItemId)`. Only meaningful when `Scheme.RuleType = FreeBundle` — the +order must contain every component in at least its listed `Qty` before the scheme's +`FK_FreeItemId`/`FreeQty` payout is granted. -`SchemeId`, `OrderId` (or `FieldInvoiceId`), `DiscountAmount`, `RedeemedAt` — feeds -`trackSchemeSpend` and `getSchemeROI` without re-scanning every order. +### SchemeRedemption +``` +SchemeRedemption +├── Id (Guid) — PK +├── FK_SchemeId (Guid) — required +├── FK_OrderId (Guid?) — nullable (see FieldInvoiceId below) +├── FieldInvoiceId (Guid?) — placeholder for the future field-invoice module; no FieldInvoice +│ entity exists yet, so this is an unconstrained Guid for now +├── DiscountAmount (decimal?) — set for SlabDiscount/FlatPercent redemptions +├── FK_FreeItemId (Guid?) — set for BuyXGetY/FreeBundle redemptions +├── FreeQty (decimal?) +├── FreeItemCostValue (decimal?) — free item's Item.CostPrice snapshotted at redemption time, so +│ getSchemeROI/trackSchemeSpend stay accurate after CostPrice +│ later changes +├── RedeemedAt (DateTime) +``` +At least one of `DiscountAmount` or `FreeQty` is set per redemption; `Scheme`/`Order` restrict-delete +(a scheme or order with redemptions can't be hard-deleted, and soft-delete leaves history intact). + +### Item (extended) +``` +Item +├── ... +├── CostPrice (decimal?) — new: cost/purchase price, used to value free-goods redemptions +``` + +--- + +## Migrations + +Two migrations: +1. **AddPromotionsSchemes** (applied) — creates `Schemes`, `SchemeRedemptions`; adds `Item.CostPrice`. +2. **AddSchemeFreeGoodsRuleAndBundleComponents** (applied) — makes `Scheme.RuleConfig` nullable, adds + `Scheme.FK_TriggerItemId`/`TriggerQty`/`FK_FreeItemId`/`FreeQty`, creates `SchemeBundleComponents`. + +(Split into two because the free-goods columns were promoted from a JSON blob to structured +columns/child table after the first migration had already been applied to the dev database.) --- ## API Endpoints -| Function reference | Endpoint intent | -|---|---| -| `createScheme(schemeRules, eligibility, dateRange)` | `POST /api/schemes` | -| `evaluateSchemeEligibility(storeId, orderId)` | `GET /api/schemes/eligible?storeId=...&orderId=...` | -| `applySchemeDiscount(orderId, schemeId)` | `POST /api/orders/{id}/apply-scheme` | -| `trackSchemeSpend(schemeId)` | `GET /api/schemes/{id}/spend` | -| `getSchemeROI(schemeId)` | `GET /api/schemes/{id}/roi` | +| Function reference | Endpoint | Notes | +|---|---|---| +| `createScheme(schemeRules, eligibility, dateRange)` | `POST /api/schemes` | `RuleConfig` required only for SlabDiscount/FlatPercent; `TriggerItemId`/`TriggerQty`/`FreeItemId`/`FreeQty`/`BundleComponents` can be supplied inline to fully create a BuyXGetY/FreeBundle scheme in one call | +| — | `GET /api/schemes` | Filterable by region | +| — | `GET /api/schemes/{id}` | Includes trigger/free item, bundle components | +| — | `PUT /api/schemes/{id}` | Updates name/eligibility/date range/`RuleConfig` | +| — | `DELETE /api/schemes/{id}` | Soft delete | +| — | `PUT /api/schemes/{id}/free-goods-rule` | Sets the BuyXGetY trigger + shared free-goods payout (also the payout for FreeBundle) | +| — | `DELETE /api/schemes/{id}/free-goods-rule` | Clears it | +| — | `PUT /api/schemes/{id}/bundle-components/{itemId}` | Upserts one FreeBundle required-item row | +| — | `DELETE /api/schemes/{id}/bundle-components/{itemId}` | Removes one FreeBundle required-item row | +| `evaluateSchemeEligibility(storeId, orderId)` | `GET /api/schemes/eligible?storeId=...&orderId=...` | Returns eligibility for every in-scope scheme, not just one | +| `applySchemeDiscount(orderId, schemeId)` | `POST /api/orders/{id}/apply-scheme` | Adds a zero-price order line for free-goods payouts | +| `trackSchemeSpend(schemeId)` | `GET /api/schemes/{id}/spend` | Discount spend + free-goods cost spend | +| `getSchemeROI(schemeId)` | `GET /api/schemes/{id}/roi` | Uses `BaselineExpectedSales` | + +--- + +## Request / Response Shapes + +### POST `/api/schemes` (BuyXGetY — created fully populated in one call) +```json +{ + "name": "Soap Buy10Get1", + "ruleType": "BuyXGetY", + "ruleConfig": null, + "regionId": null, + "eligibleSegment": "Gold", + "dateRangeFrom": "2026-08-01T00:00:00", + "dateRangeTo": "2026-08-31T23:59:59", + "agentLimitExempt": false, + "baselineExpectedSales": 50000, + "triggerItemId": "", + "triggerQty": 10, + "freeItemId": "", + "freeQty": 1 +} +``` +`triggerItemId`/`triggerQty`/`freeItemId`/`freeQty` are optional on create — omit them for a plain +SlabDiscount/FlatPercent scheme, or omit and set them later via `PUT .../free-goods-rule` if you want +to create the shell first and configure the payout afterwards. + +### POST `/api/schemes` (FreeBundle — created fully populated in one call) +```json +{ + "name": "Soap+Shampoo -> Free Toothbrush", + "ruleType": "FreeBundle", + "ruleConfig": null, + "regionId": null, + "eligibleSegment": null, + "dateRangeFrom": "2026-08-01T00:00:00", + "dateRangeTo": "2026-08-31T23:59:59", + "agentLimitExempt": false, + "baselineExpectedSales": null, + "freeItemId": "", + "freeQty": 1, + "bundleComponents": [ { "itemId": "", "qty": 1 }, { "itemId": "", "qty": 1 } ] +} +``` +`triggerItemId`/`triggerQty` are ignored for FreeBundle — the required items come from +`bundleComponents` instead. + +### PUT `/api/schemes/{id}/free-goods-rule` +```json +{ "triggerItemId": "", "triggerQty": 10, "freeItemId": "", "freeQty": 1 } +``` +Same shape as the inline create fields — use this to add or change the free-goods payout on a +scheme that was created without one. + +### PUT `/api/schemes/{id}/bundle-components/{itemId}` (FreeBundle) +```json +{ "qty": 1 } +``` +Use this to add/edit one bundle component after creation. + +### POST `/api/schemes` (SlabDiscount) +```json +{ + "name": "Volume Discount", + "ruleType": "SlabDiscount", + "ruleConfig": "{\"tiers\":[{\"minOrderAmount\":5000,\"discountPercent\":5},{\"minOrderAmount\":10000,\"discountPercent\":8}]}", + "regionId": null, + "eligibleSegment": null, + "dateRangeFrom": "2026-08-01T00:00:00", + "dateRangeTo": "2026-08-31T23:59:59", + "agentLimitExempt": false, + "baselineExpectedSales": null +} +``` + +### POST `/api/orders/{id}/apply-scheme` +```json +{ "schemeId": "..." } +``` --- ## Business Rules -- A scheme discount stacks with, but does not bypass, the per-role discount ceiling in - [08_sales_agent_controls.md](./08_sales_agent_controls.md) unless explicitly flagged as an - agent-limit-exempt scheme. -- `getSchemeROI` requires a baseline (pre-scheme expected sales) to compare against — confirm data - source before implementing (see Open Questions). +1. A scheme discount stacks with, but does not bypass, the per-role discount ceiling in + [08_sales_agent_controls.md](./08_sales_agent_controls.md) unless `AgentLimitExempt = true`. + (Enforcement against the actual ceiling is deferred — 08_sales_agent_controls.md's discount + ceiling isn't implemented in code yet; this is recorded as the target behavior.) +2. `evaluateSchemeEligibility` matches a scheme to a store/order only if: the scheme's `RegionId` is + null or matches the store's region; `EligibleSegment` is null or matches `Store.Segment`; and the + order's date falls within `[DateRangeFrom, DateRangeTo]`. +3. `BuyXGetY` eligibility: the order must contain at least `TriggerQty` of `TriggerItem`; the payout + scales with how many multiples of `TriggerQty` were bought (`FreeQty × floor(boughtQty / TriggerQty)`). +4. `FreeBundle` eligibility: the order must contain every `SchemeBundleComponent` in at least its + listed `Qty`; payout is the flat `FreeQty` of `FreeItem` (no multiplier). +5. `applySchemeDiscount` adds a zero-price `OrderItem` line for the free-goods payout (`UnitPrice = 0`, + `Quantity = FreeQty`), rather than only recording a discount amount — the free line still needs to + be picked, delivered, and stock-tracked like any other sold line. +6. A free-goods redemption is blocked if the free item's available `StockBalance` (summed across all + locations) is less than the payout `FreeQty` — same out-of-stock guard as a normal sold line. +7. A scheme can only be redeemed once per order (`HasRedemptionAsync` check in `applySchemeDiscount`). +8. `SchemeRedemption.FreeItemCostValue` snapshots `Item.CostPrice` at redemption time, so + `trackSchemeSpend`/`getSchemeROI` stay accurate even after the item's cost price later changes. +9. Free goods are typically still a taxable "deemed supply" at zero net value under GST/VAT-style + regimes — not yet wired into a tax calculation, since no tax subsystem exists in this codebase + yet. Flagged for whoever builds tax/invoicing. +10. `trackSchemeSpend` = sum(`DiscountAmount`) + sum(`FreeItemCostValue × FreeQty`) across all + redemptions. `getSchemeROI` = `(actualSales − BaselineExpectedSales) / totalSpend`, where + `actualSales` is the sum of `Order.TotalAmount` for every distinct order that redeemed the scheme. --- ## Open Questions -- What counts as "incremental sales" for `getSchemeROI` — a manual baseline entered when the scheme - is created, or a trailing-period average computed automatically? +- What counts as "incremental sales" for `getSchemeROI` — **resolved for v1**: a manual + `BaselineExpectedSales` entered at scheme creation, not a trailing-period average. Revisit if + historical order data volume later makes an automatic trailing average preferable. +- Per-role discount ceiling stacking (`AgentLimitExempt`) is unenforced pending + [08_sales_agent_controls.md](./08_sales_agent_controls.md) having an actual ceiling implementation + to stack against. +- Deemed-supply tax treatment for free goods — deferred until a tax/invoicing subsystem exists. --- ## Implementation Notes -> Fill this section in during planning discussion before writing any code. +### File structure created / touched +``` +Features/ + Scheme/ + SchemeController.cs (also hosts SchemeOrderController for POST /api/orders/{id}/apply-scheme) + SchemeDTOs.cs + SchemeService.cs (EvaluateRule — the core eligibility/payout calculator; ApplySchemeAsync + — the free-goods order-line + stock-check + redemption write path) + ISchemeRepository.cs + SchemeRepository.cs + +Models/ + Scheme.cs (new — includes SchemeRuleType enum) + SchemeBundleComponent.cs (new) + SchemeRedemption.cs (new) + Item.cs (extended — CostPrice) + +Data/AppDBContext.cs + add Schemes, SchemeRedemptions, SchemeBundleComponents DbSets; relationships (Scheme→TriggerItem/ + FreeItem restrict-delete, SchemeBundleComponent→Scheme cascade-delete, →Item restrict-delete, + SchemeRedemption→Scheme/Order/FreeItem restrict-delete); unique index on + SchemeBundleComponent(FK_SchemeId, FK_ItemId) + +Migrations/ + AddPromotionsSchemes (applied) + AddSchemeFreeGoodsRuleAndBundleComponents (applied) + +Program.cs + register ISchemeRepository/SchemeRepository, SchemeService +``` + +### Eligibility + payout logic (pseudo) +``` +EvaluateRule(scheme, orderItems): + switch scheme.RuleType: + BuyXGetY: + boughtQty = sum(orderItems where ItemId = scheme.TriggerItemId).Quantity + if boughtQty < scheme.TriggerQty: not eligible + multiplier = boughtQty / scheme.TriggerQty // integer division + → eligible, FreeItemId = scheme.FreeItemId, FreeQty = scheme.FreeQty * multiplier + + FreeBundle: + missing = scheme.BundleComponents where sum(orderItems qty for that item) < component.Qty + if missing.any(): not eligible + → eligible, FreeItemId = scheme.FreeItemId, FreeQty = scheme.FreeQty + + FlatPercent / SlabDiscount: + parse RuleConfig JSON, compute DiscountAmount off orderItems total +``` + +### applySchemeDiscount flow (pseudo) +``` +ApplySchemeAsync(orderId, schemeId): + reject if scheme already redeemed against this order + eligibility = EvaluateRule(scheme, order's items) + reject if not eligible + + if eligibility has a free-goods payout: + check StockBalance across all locations >= FreeQty, else reject + add OrderItem { ItemId = FreeItemId, Quantity = FreeQty, UnitPrice = 0 } + redemption.FreeItemCostValue = freeItem.CostPrice ?? 0 + + save SchemeRedemption { DiscountAmount, FreeItemId, FreeQty, FreeItemCostValue } +``` + +--- + +## Worked Example + +Item `Soap-500ml` has `CostPrice = 12`. Store is in the "Gold" segment. + +**Create the scheme (one call, fully populated):** +``` +POST /api/schemes +{ "name": "Soap Buy10Get1", "ruleType": "BuyXGetY", "ruleConfig": null, + "regionId": null, "eligibleSegment": "Gold", + "dateRangeFrom": "2026-08-01", "dateRangeTo": "2026-08-31", + "agentLimitExempt": false, "baselineExpectedSales": 50000, + "triggerItemId": "", "triggerQty": 10, "freeItemId": "", "freeQty": 1 } +→ Scheme { Id, ... } +``` + +**Store places an order** for 20 units of `Soap-500ml` → `Order { Id, TotalAmount }`. + +**Check eligibility:** +``` +GET /api/schemes/eligible?storeId=...&orderId=... +→ [{ "schemeId": "...", "eligible": true, "freeItemId": "", "freeQty": 2 }] +``` +20 ÷ 10 = 2 multiples → 2 free units. + +**Apply it:** +``` +POST /api/orders/{orderId}/apply-scheme +{ "schemeId": "..." } +``` +Adds an `OrderItem` line (`Soap-500ml`, qty 2, `UnitPrice = 0`) and a `SchemeRedemption` with +`FreeQty = 2`, `FreeItemCostValue = 12`. + +**Spend/ROI:** +``` +GET /api/schemes/{id}/spend +→ { "totalDiscountSpend": 0, "totalFreeGoodsCostSpend": 24, "totalSpend": 24, "redemptionCount": 1 } + +GET /api/schemes/{id}/roi +→ { "baselineExpectedSales": 50000, "actualSales": 62000, "incrementalSales": 12000, + "totalSpend": 24, "roi": 500.0 } +``` + +**FreeBundle variant** ("buy Soap + Shampoo, get a free Toothbrush") — also one call: +``` +POST /api/schemes +{ "name": "Soap+Shampoo -> Free Toothbrush", "ruleType": "FreeBundle", "ruleConfig": null, + "regionId": null, "eligibleSegment": null, + "dateRangeFrom": "2026-08-01", "dateRangeTo": "2026-08-31", + "agentLimitExempt": false, "baselineExpectedSales": null, + "freeItemId": "", "freeQty": 1, + "bundleComponents": [ { "itemId": "", "qty": 1 }, { "itemId": "", "qty": 1 } ] } +``` +Eligibility now requires both Soap ≥1 and Shampoo ≥1 in the order; payout is 1 free toothbrush, +flat (no multiplier). Components/payout can still be edited afterwards via +`PUT .../bundle-components/{itemId}` and `PUT .../free-goods-rule`. diff --git a/Distribution/docs/mainPlan.md b/Distribution/docs/mainPlan.md index 21485bd..8bc3148 100644 --- a/Distribution/docs/mainPlan.md +++ b/Distribution/docs/mainPlan.md @@ -10,10 +10,12 @@ | Role Management | Done (read-only) | 6 roles seeded, no create/update via API | | Region / Route / Vehicle CRUD | Done | Full API surface with permission checks | | Store / Warehouse CRUD | Done | Full API surface with permission checks | +| Store Management (Beyond CRUD) | Done | Credit limit, KYC, segment, dashboard, scheme eligibility; see [12_store_management.md](./12_store_management.md) | | Order CRUD | Done | Scope-aware (user only sees their assigned stores) | | Unit of Work + Repositories | Done | Transaction support throughout | | Soft Deletes | Done | IsActive pattern on all entities | | Price Lists / Suppliers | Done | Region-scoped price lists + supplier master; see [14_pricing_and_suppliers.md](./14_pricing_and_suppliers.md) | +| Promotions & Trade Schemes | Done | BuyXGetY/FreeBundle/SlabDiscount/FlatPercent schemes, free-goods fulfillment, spend/ROI tracking; see [17_promotions_schemes.md](./17_promotions_schemes.md) | ## Known Gaps in Existing System (Pre-Work) @@ -47,12 +49,12 @@ They must be resolved before new feature steps begin because later steps depend | 09 | Fraud Controls & Audit Trail | Not Started | 08 | [09_fraud_controls.md](./09_fraud_controls.md) | | 10 | Region Management (Beyond CRUD) | Not Started | 00 | [10_region_management.md](./10_region_management.md) | | 11 | Route & Sales Visit Management | Not Started | 00 | [11_route_sales_management.md](./11_route_sales_management.md) | -| 12 | Store Management (Beyond CRUD) | In Progress | 00 | [12_store_management.md](./12_store_management.md) | +| 12 | Store Management (Beyond CRUD) | Done | 00 | [12_store_management.md](./12_store_management.md) | | 13 | Fleet Management (Beyond CRUD) | Not Started | 00 | [13_fleet_management.md](./13_fleet_management.md) | | 14 | Pricing & Supplier Master Data | Done | 01 | [14_pricing_and_suppliers.md](./14_pricing_and_suppliers.md) | | 15 | Procurement / Purchasing | Not Started | 01, 14 | [15_procurement.md](./15_procurement.md) | | 16 | Sales Force Automation / CRM | Not Started | 12 | [16_sfa_crm.md](./16_sfa_crm.md) | -| 17 | Promotions & Trade Scheme Management | Not Started | 01, 14 | [17_promotions_schemes.md](./17_promotions_schemes.md) | +| 17 | Promotions & Trade Scheme Management | Done | 01, 14 | [17_promotions_schemes.md](./17_promotions_schemes.md) | | 18 | Returns & Claims Management | Not Started | 05, 06 | [18_returns_claims.md](./18_returns_claims.md) | | 19 | Finance & Accounting | Not Started | 05, 06, 18 | [19_finance_accounting.md](./19_finance_accounting.md) | | 20 | Reporting, Analytics & BI | Not Started | all prior | [20_reporting_analytics.md](./20_reporting_analytics.md) | @@ -83,7 +85,7 @@ here — see `SRS_User_Management_Module.pdf`. | `createOrder(storeId, items[], salesRepId)` | Validates stock/credit, creates a new order in `pending` state. | Partially implemented - `OrderService.CreateAsync` exists, enforces store scope, blocks inactive stores, and applies the unverified-KYC order threshold from config. It still has no stock validation, credit-limit validation, approval flow, or order state machine. | | `validateCreditLimit(storeId, orderAmount)` | Checks store's outstanding balance + new order value against its credit limit. | Not implemented - `Store` now has `CreditLimit` and `OutstandingBalance`, but there is no dedicated validator or order-time credit-limit enforcement yet. | | `checkStockAvailability(items[])` | Confirms requested SKUs/quantities are available at the fulfilling warehouse. | Not implemented — depends on [02_stock_ledger.md](./02_stock_ledger.md). | -| `applyPricingAndDiscounts(orderId)` | Runs the active price list and eligible scheme rules against order lines. | Partially implemented — `OrderService.CreateAsync` resolves the price-list half via `PriceListService.ResolveUnitPriceAsync` (region-scoped → global → `Item.SalePrice`, or a caller-supplied `UnitPrice`); the scheme-discount half still depends on §11 Promotions below. | +| `applyPricingAndDiscounts(orderId)` | Runs the active price list and eligible scheme rules against order lines. | Partially implemented — `OrderService.CreateAsync` resolves the price-list half via `PriceListService.ResolveUnitPriceAsync` (region-scoped → global → `Item.SalePrice`, or a caller-supplied `UnitPrice`). The scheme-discount half now exists as a separate call — `SchemeService.EvaluateEligibilityAsync`/`ApplySchemeAsync` (§11) — but is not yet auto-invoked from `OrderService.CreateAsync`; the caller applies a scheme explicitly via `POST /api/orders/{id}/apply-scheme` after order creation. | | `submitOrderForApproval(orderId)` | Routes the order to the correct approver based on its value/discount threshold. | Not implemented — depends on [08_sales_agent_controls.md](./08_sales_agent_controls.md). | | `approveOrder(orderId, approverId)` / `rejectOrder(orderId, reason)` | Moves order to `approved` or `rejected`, logging the actor. | Not implemented. | | `updateOrderStatus(orderId, status)` | Transitions order through `pending → approved → picked → dispatched → delivered → invoiced`. | Not implemented — `Order` has no status field or state machine today. | @@ -132,7 +134,7 @@ here — see `SRS_User_Management_Module.pdf`. | `classifyStore(storeId, segment)` | Sets/updates the store's segment (key account, general trade, etc.). | Implemented - `PUT /api/stores/{id}/segment` updates the free-text `Store.Segment` field. | | `deactivateStore(storeId, reason)` | Soft-deletes a store, blocking new orders against it. | Partially implemented — `DELETE /api/stores/{id}` soft-deletes via `IsActive`, no reason code captured. | | `getStoreVisitHistory(storeId)` | Returns visit compliance history from Route Management. | Placeholder only - endpoint exists (`GET /api/stores/{id}/visit-history`) but currently returns an empty list until Route/Sales Visit Management lands. | -| `checkStoreSchemeEligibility(storeId, schemeId)` | Evaluates whether a store qualifies for a given promotion. | Placeholder only - endpoint exists (`GET /api/stores/{id}/schemes/{schemeId}/eligibility`) but currently returns a default eligible response until Promotions is implemented. | +| `checkStoreSchemeEligibility(storeId, schemeId)` | Evaluates whether a store qualifies for a given promotion. | Implemented — `GET /api/stores/{id}/schemes/{schemeId}/eligibility` now calls `SchemeService.CheckStoreScopeEligibilityAsync` (region/segment/date-range scope check, no order context). For full order-line rule evaluation against a specific order, use `evaluateSchemeEligibility(storeId, orderId)` instead (`GET /api/schemes/eligible?storeId=...&orderId=...`, §11). | | `getStoreSalesDashboard(storeId, period)` | Store-level sales and outstanding summary. | Partially implemented - `GET /api/stores/{id}/dashboard` aggregates order count, total order value, and last order date for the selected month. It does not yet include full sales/outstanding analytics from Finance. | ### 5. Vehicle / Fleet Management @@ -212,11 +214,11 @@ here — see `SRS_User_Management_Module.pdf`. | Function | What it does | Status | |---|---|---| -| `createScheme(schemeRules, eligibility, dateRange)` | Defines a promotion (buy-X-get-Y, slab discount, etc.). | Not implemented — no scheme entity exists. | -| `evaluateSchemeEligibility(storeId, orderId)` | Checks whether an order qualifies for a scheme. | Not implemented. | -| `applySchemeDiscount(orderId, schemeId)` | Applies the scheme's discount to order pricing. | Not implemented. | -| `trackSchemeSpend(schemeId)` | Aggregates total discount value given under a scheme. | Not implemented. | -| `getSchemeROI(schemeId)` | Compares scheme spend against incremental sales generated. | Not implemented. | +| `createScheme(schemeRules, eligibility, dateRange)` | Defines a promotion (buy-X-get-Y, slab discount, etc.). | Implemented — `POST /api/schemes`. Supports `BuyXGetY`, `FreeBundle`, `SlabDiscount`, `FlatPercent`; free-goods trigger/payout and bundle components can be supplied inline in the same call. See [17_promotions_schemes.md](./17_promotions_schemes.md). | +| `evaluateSchemeEligibility(storeId, orderId)` | Checks whether an order qualifies for a scheme. | Implemented — `GET /api/schemes/eligible?storeId=...&orderId=...`, returns eligibility (and computed discount/free-goods payout) for every in-scope scheme, not just one. | +| `applySchemeDiscount(orderId, schemeId)` | Applies the scheme's discount to order pricing. | Implemented — `POST /api/orders/{id}/apply-scheme`. For free-goods schemes this adds a zero-price `OrderItem` line and checks `StockBalance` availability first; not yet auto-invoked from `createOrder`/`applyPricingAndDiscounts` (§1) — caller applies it explicitly after order creation. | +| `trackSchemeSpend(schemeId)` | Aggregates total discount value given under a scheme. | Implemented — `GET /api/schemes/{id}/spend`, sums `DiscountAmount` plus free-goods cost (`FreeItemCostValue × FreeQty`) across redemptions. | +| `getSchemeROI(schemeId)` | Compares scheme spend against incremental sales generated. | Implemented — `GET /api/schemes/{id}/roi`, using a manually-entered `BaselineExpectedSales` per scheme (no trailing-period auto-baseline yet). | ### 12. Returns & Claims Management -- 2.52.0