Compare commits

...

3 Commits

Author SHA1 Message Date
ImanThiyanga 722b1e78ed feat(e2e): add Playwright end-to-end tests for authentication, GRN, production, stock transfers, and adjustments
- Introduced Playwright configuration for e2e testing.
- Implemented authentication tests to validate login functionality.
- Created tests for GRN (Goods Receipt Note) to ensure proper stock handling.
- Developed production run tests to verify lifecycle and stock posting.
- Added stock transfer tests to check movement between warehouses.
- Implemented stock adjustment tests for positive and negative adjustments.
- Established API seeder for test data setup and verification.
- Enhanced utility functions for UI interactions and response handling.
2026-08-05 13:23:07 +05:30
DeepnaPooja 26cf2a146a Merge pull request 'centralize sales flow' (#26) from fix-sales-module into Dev
Reviewed-on: #26
2026-08-04 11:58:10 +00:00
ImanThiyanga 38c7545413 removing migrations 2026-08-04 16:14:12 +05:30
40 changed files with 2757 additions and 7039 deletions
+6
View File
@@ -30,6 +30,12 @@ yarn-error.log*
Thumbs.db
.idea/
# ── Playwright E2E (Testing/e2e) ──────────────────────────────────────
Testing/e2e/playwright-report/
Testing/e2e/test-results/
Testing/e2e/.auth/
Testing/e2e/blob-report/
# ── Migrations ─────────────────────────────────────────────────────────
# Reverted 2026-07-31: excluding new EF Core migrations while
# ErpDbContextModelSnapshot.cs stayed tracked meant every `dotnet ef
File diff suppressed because it is too large Load Diff
@@ -1,22 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Infra.Persistence.Migrations
{
/// <inheritdoc />
public partial class SyncCurrentModel : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -1,139 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ERPCore.Infra.Persistence.Migrations;
public partial class AddBundleSalesModule : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "bundle_sale_templates",
columns: table => new
{
BundleSaleTemplateId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
TemplateCode = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
TemplateName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
},
constraints: table => table.PrimaryKey("PK_bundle_sale_templates", x => x.BundleSaleTemplateId));
migrationBuilder.CreateTable(
name: "bundle_sales",
columns: table => new
{
BundleSaleId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
BundleNo = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
BundleDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
CustomerId = table.Column<int>(type: "integer", nullable: false),
CustomerSnapshotName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
WarehouseId = table.Column<int>(type: "integer", nullable: false),
CashierUserId = table.Column<int>(type: "integer", nullable: false),
BundleSaleTemplateId = table.Column<int>(type: "integer", nullable: false),
BundleName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
BundleCode = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Draft"),
ComponentSubtotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
BundlePrice = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
MarginAmount = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
DiscountTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
TaxTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
GrandTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_bundle_sales", x => x.BundleSaleId);
table.ForeignKey("FK_bundle_sales_bundle_sale_templates_BundleSaleTemplateId", x => x.BundleSaleTemplateId, "bundle_sale_templates", "BundleSaleTemplateId", onDelete: ReferentialAction.Restrict);
table.ForeignKey("FK_bundle_sales_customers_CustomerId", x => x.CustomerId, "customers", "CustomerId", onDelete: ReferentialAction.Restrict);
table.ForeignKey("FK_bundle_sales_users_CashierUserId", x => x.CashierUserId, "users", "UserId", onDelete: ReferentialAction.Restrict);
table.ForeignKey("FK_bundle_sales_warehouses_WarehouseId", x => x.WarehouseId, "warehouses", "WarehouseId", onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "bundle_sale_template_lines",
columns: table => new
{
BundleSaleTemplateLineId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
BundleSaleTemplateId = table.Column<int>(type: "integer", nullable: false),
ItemId = table.Column<int>(type: "integer", nullable: false),
UomId = table.Column<int>(type: "integer", nullable: false),
WarehouseId = table.Column<int>(type: "integer", nullable: false),
Qty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
UnitPrice = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
IncludeInBundle = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
SortOrder = table.Column<int>(type: "integer", nullable: false, defaultValue: 0)
},
constraints: table =>
{
table.PrimaryKey("PK_bundle_sale_template_lines", x => x.BundleSaleTemplateLineId);
table.ForeignKey("FK_bundle_sale_template_lines_bundle_sale_templates_BundleSaleTemplateId", x => x.BundleSaleTemplateId, "bundle_sale_templates", "BundleSaleTemplateId", onDelete: ReferentialAction.Cascade);
table.ForeignKey("FK_bundle_sale_template_lines_items_ItemId", x => x.ItemId, "items", "ItemId", onDelete: ReferentialAction.Restrict);
table.ForeignKey("FK_bundle_sale_template_lines_uoms_UomId", x => x.UomId, "uoms", "UomId", onDelete: ReferentialAction.Restrict);
table.ForeignKey("FK_bundle_sale_template_lines_warehouses_WarehouseId", x => x.WarehouseId, "warehouses", "WarehouseId", onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "bundle_sale_lines",
columns: table => new
{
BundleSaleLineId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
BundleSaleId = table.Column<int>(type: "integer", nullable: false),
ItemId = table.Column<int>(type: "integer", nullable: false),
Description = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Qty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
UomId = table.Column<int>(type: "integer", nullable: false),
WarehouseId = table.Column<int>(type: "integer", nullable: false),
UnitPrice = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
LineTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
IncludeInBundle = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
IsComponent = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
ParentLineId = table.Column<int>(type: "integer", nullable: true),
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_bundle_sale_lines", x => x.BundleSaleLineId);
table.ForeignKey("FK_bundle_sale_lines_bundle_sales_BundleSaleId", x => x.BundleSaleId, "bundle_sales", "BundleSaleId", onDelete: ReferentialAction.Cascade);
table.ForeignKey("FK_bundle_sale_lines_items_ItemId", x => x.ItemId, "items", "ItemId", onDelete: ReferentialAction.Restrict);
table.ForeignKey("FK_bundle_sale_lines_uoms_UomId", x => x.UomId, "uoms", "UomId", onDelete: ReferentialAction.Restrict);
table.ForeignKey("FK_bundle_sale_lines_warehouses_WarehouseId", x => x.WarehouseId, "warehouses", "WarehouseId", onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(name: "IX_bundle_sale_templates_TemplateCode", table: "bundle_sale_templates", column: "TemplateCode", unique: true);
migrationBuilder.CreateIndex(name: "IX_bundle_sales_BundleNo", table: "bundle_sales", column: "BundleNo", unique: true);
migrationBuilder.CreateIndex(name: "IX_bundle_sales_BundleSaleTemplateId", table: "bundle_sales", column: "BundleSaleTemplateId");
migrationBuilder.CreateIndex(name: "IX_bundle_sales_CashierUserId", table: "bundle_sales", column: "CashierUserId");
migrationBuilder.CreateIndex(name: "IX_bundle_sales_CustomerId", table: "bundle_sales", column: "CustomerId");
migrationBuilder.CreateIndex(name: "IX_bundle_sales_Status", table: "bundle_sales", column: "Status");
migrationBuilder.CreateIndex(name: "IX_bundle_sales_WarehouseId", table: "bundle_sales", column: "WarehouseId");
migrationBuilder.CreateIndex(name: "IX_bundle_sale_template_lines_BundleSaleTemplateId", table: "bundle_sale_template_lines", column: "BundleSaleTemplateId");
migrationBuilder.CreateIndex(name: "IX_bundle_sale_template_lines_ItemId", table: "bundle_sale_template_lines", column: "ItemId");
migrationBuilder.CreateIndex(name: "IX_bundle_sale_template_lines_UomId", table: "bundle_sale_template_lines", column: "UomId");
migrationBuilder.CreateIndex(name: "IX_bundle_sale_template_lines_WarehouseId", table: "bundle_sale_template_lines", column: "WarehouseId");
migrationBuilder.CreateIndex(name: "IX_bundle_sale_lines_BundleSaleId", table: "bundle_sale_lines", column: "BundleSaleId");
migrationBuilder.CreateIndex(name: "IX_bundle_sale_lines_ItemId", table: "bundle_sale_lines", column: "ItemId");
migrationBuilder.CreateIndex(name: "IX_bundle_sale_lines_UomId", table: "bundle_sale_lines", column: "UomId");
migrationBuilder.CreateIndex(name: "IX_bundle_sale_lines_WarehouseId", table: "bundle_sale_lines", column: "WarehouseId");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable("bundle_sale_lines");
migrationBuilder.DropTable("bundle_sale_template_lines");
migrationBuilder.DropTable("bundle_sales");
migrationBuilder.DropTable("bundle_sale_templates");
}
}
@@ -1,36 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Infra.Persistence.Migrations;
public partial class AddBundleSalesConcurrencyStamp : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "ConcurrencyStamp",
table: "bundle_sale_templates",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "ConcurrencyStamp",
table: "bundle_sales",
type: "integer",
nullable: false,
defaultValue: 0);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ConcurrencyStamp",
table: "bundle_sales");
migrationBuilder.DropColumn(
name: "ConcurrencyStamp",
table: "bundle_sale_templates");
}
}
@@ -9,11 +9,11 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ERPCore.Infra.Persistence.Migrations
namespace ERPCore.Migrations
{
[DbContext(typeof(ErpDbContext))]
[Migration("20260801025920_InitialCreate")]
partial class InitialCreate
[Migration("20260804111315_a")]
partial class a
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -368,6 +368,269 @@ namespace ERPCore.Infra.Persistence.Migrations
b.ToTable("brands", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSale", b =>
{
b.Property<int>("BundleSaleId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("BundleSaleId"));
b.Property<string>("BundleCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime>("BundleDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("BundleName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("BundleNo")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<decimal>("BundlePrice")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("BundleSaleTemplateId")
.HasColumnType("integer");
b.Property<int>("CashierUserId")
.HasColumnType("integer");
b.Property<decimal>("ComponentSubtotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("ConcurrencyStamp")
.IsConcurrencyToken()
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CustomerId")
.HasColumnType("integer");
b.Property<string>("CustomerSnapshotName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<decimal>("DiscountTotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal>("GrandTotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal>("MarginAmount")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Draft");
b.Property<decimal>("TaxTotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("WarehouseId")
.HasColumnType("integer");
b.HasKey("BundleSaleId");
b.HasIndex("BundleNo")
.IsUnique();
b.HasIndex("BundleSaleTemplateId");
b.HasIndex("CashierUserId");
b.HasIndex("CustomerId");
b.HasIndex("WarehouseId");
b.ToTable("bundle_sales", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleLine", b =>
{
b.Property<int>("BundleSaleLineId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("BundleSaleLineId"));
b.Property<int>("BundleSaleId")
.HasColumnType("integer");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<bool>("IncludeInBundle")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<bool>("IsComponent")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<int>("ItemId")
.HasColumnType("integer");
b.Property<decimal>("LineTotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int?>("ParentLineId")
.HasColumnType("integer");
b.Property<decimal>("Qty")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal>("UnitPrice")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("UomId")
.HasColumnType("integer");
b.Property<int>("WarehouseId")
.HasColumnType("integer");
b.HasKey("BundleSaleLineId");
b.HasIndex("BundleSaleId");
b.HasIndex("ItemId");
b.HasIndex("UomId");
b.HasIndex("WarehouseId");
b.ToTable("bundle_sale_lines", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleTemplate", b =>
{
b.Property<int>("BundleSaleTemplateId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("BundleSaleTemplateId"));
b.Property<int>("ConcurrencyStamp")
.IsConcurrencyToken()
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Active");
b.Property<string>("TemplateCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("TemplateName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("BundleSaleTemplateId");
b.HasIndex("TemplateCode")
.IsUnique();
b.ToTable("bundle_sale_templates", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleTemplateLine", b =>
{
b.Property<int>("BundleSaleTemplateLineId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("BundleSaleTemplateLineId"));
b.Property<int>("BundleSaleTemplateId")
.HasColumnType("integer");
b.Property<bool>("IncludeInBundle")
.HasColumnType("boolean");
b.Property<int>("ItemId")
.HasColumnType("integer");
b.Property<decimal>("Qty")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.Property<decimal>("UnitPrice")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("UomId")
.HasColumnType("integer");
b.Property<int>("WarehouseId")
.HasColumnType("integer");
b.HasKey("BundleSaleTemplateLineId");
b.HasIndex("BundleSaleTemplateId");
b.HasIndex("ItemId");
b.HasIndex("UomId");
b.HasIndex("WarehouseId");
b.ToTable("bundle_sale_template_lines", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
{
b.Property<int>("CategoryId")
@@ -1953,6 +2216,15 @@ namespace ERPCore.Infra.Persistence.Migrations
Label = "Accounts",
SortOrder = 12,
Status = "Active"
},
new
{
NavItemId = 13,
Code = "sales",
Href = "/dashboard/sales",
Label = "Sales",
SortOrder = 13,
Status = "Active"
});
});
@@ -4540,6 +4812,16 @@ namespace ERPCore.Infra.Persistence.Migrations
Status = "Active"
},
new
{
SubNavItemId = 23,
Code = "sales.bundle-sales",
Href = "/dashboard/sales/bundles",
Label = "Bundle Sales",
NavItemId = 13,
SortOrder = 1,
Status = "Active"
},
new
{
SubNavItemId = 17,
Code = "procurement.requisitions",
@@ -5152,6 +5434,111 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Navigation("Warehouse");
});
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSale", b =>
{
b.HasOne("ERPCore.Domain.Entities.BundleSaleTemplate", "BundleSaleTemplate")
.WithMany()
.HasForeignKey("BundleSaleTemplateId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.User", "CashierUser")
.WithMany()
.HasForeignKey("CashierUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Customer", "Customer")
.WithMany()
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
.WithMany()
.HasForeignKey("WarehouseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("BundleSaleTemplate");
b.Navigation("CashierUser");
b.Navigation("Customer");
b.Navigation("Warehouse");
});
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleLine", b =>
{
b.HasOne("ERPCore.Domain.Entities.BundleSale", "BundleSale")
.WithMany("Lines")
.HasForeignKey("BundleSaleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
.WithMany()
.HasForeignKey("ItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Uom", "Uom")
.WithMany()
.HasForeignKey("UomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
.WithMany()
.HasForeignKey("WarehouseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("BundleSale");
b.Navigation("Item");
b.Navigation("Uom");
b.Navigation("Warehouse");
});
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleTemplateLine", b =>
{
b.HasOne("ERPCore.Domain.Entities.BundleSaleTemplate", "BundleSaleTemplate")
.WithMany("Lines")
.HasForeignKey("BundleSaleTemplateId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
.WithMany()
.HasForeignKey("ItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Uom", "Uom")
.WithMany()
.HasForeignKey("UomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
.WithMany()
.HasForeignKey("WarehouseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("BundleSaleTemplate");
b.Navigation("Item");
b.Navigation("Uom");
b.Navigation("Warehouse");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Customer", b =>
{
b.HasOne("ERPCore.Domain.Entities.Warehouse", "DefaultWarehouse")
@@ -6558,6 +6945,16 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Navigation("Quotation");
});
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSale", b =>
{
b.Navigation("Lines");
});
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleTemplate", b =>
{
b.Navigation("Lines");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
{
b.Navigation("SubCategories");
@@ -6,10 +6,10 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace ERPCore.Infra.Persistence.Migrations
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
public partial class a : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
@@ -31,6 +31,25 @@ namespace ERPCore.Infra.Persistence.Migrations
table.PrimaryKey("PK_brands", x => x.BrandId);
});
migrationBuilder.CreateTable(
name: "bundle_sale_templates",
columns: table => new
{
BundleSaleTemplateId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
TemplateCode = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
TemplateName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
ConcurrencyStamp = table.Column<int>(type: "integer", nullable: false, defaultValue: 0)
},
constraints: table =>
{
table.PrimaryKey("PK_bundle_sale_templates", x => x.BundleSaleTemplateId);
});
migrationBuilder.CreateTable(
name: "categories",
columns: table => new
@@ -903,6 +922,61 @@ namespace ERPCore.Infra.Persistence.Migrations
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "bundle_sales",
columns: table => new
{
BundleSaleId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
BundleNo = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
BundleDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
CustomerId = table.Column<int>(type: "integer", nullable: false),
CustomerSnapshotName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
WarehouseId = table.Column<int>(type: "integer", nullable: false),
CashierUserId = table.Column<int>(type: "integer", nullable: false),
BundleSaleTemplateId = table.Column<int>(type: "integer", nullable: false),
BundleName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
BundleCode = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Draft"),
ComponentSubtotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
BundlePrice = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
MarginAmount = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
DiscountTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
TaxTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
GrandTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
ConcurrencyStamp = table.Column<int>(type: "integer", nullable: false, defaultValue: 0)
},
constraints: table =>
{
table.PrimaryKey("PK_bundle_sales", x => x.BundleSaleId);
table.ForeignKey(
name: "FK_bundle_sales_bundle_sale_templates_BundleSaleTemplateId",
column: x => x.BundleSaleTemplateId,
principalTable: "bundle_sale_templates",
principalColumn: "BundleSaleTemplateId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_bundle_sales_customers_CustomerId",
column: x => x.CustomerId,
principalTable: "customers",
principalColumn: "CustomerId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_bundle_sales_users_CashierUserId",
column: x => x.CashierUserId,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_bundle_sales_warehouses_WarehouseId",
column: x => x.WarehouseId,
principalTable: "warehouses",
principalColumn: "WarehouseId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "sales_invoices",
columns: table => new
@@ -1020,6 +1094,50 @@ namespace ERPCore.Infra.Persistence.Migrations
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "bundle_sale_template_lines",
columns: table => new
{
BundleSaleTemplateLineId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
BundleSaleTemplateId = table.Column<int>(type: "integer", nullable: false),
ItemId = table.Column<int>(type: "integer", nullable: false),
UomId = table.Column<int>(type: "integer", nullable: false),
WarehouseId = table.Column<int>(type: "integer", nullable: false),
Qty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
UnitPrice = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
IncludeInBundle = table.Column<bool>(type: "boolean", nullable: false),
SortOrder = table.Column<int>(type: "integer", nullable: false, defaultValue: 0)
},
constraints: table =>
{
table.PrimaryKey("PK_bundle_sale_template_lines", x => x.BundleSaleTemplateLineId);
table.ForeignKey(
name: "FK_bundle_sale_template_lines_bundle_sale_templates_BundleSale~",
column: x => x.BundleSaleTemplateId,
principalTable: "bundle_sale_templates",
principalColumn: "BundleSaleTemplateId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_bundle_sale_template_lines_items_ItemId",
column: x => x.ItemId,
principalTable: "items",
principalColumn: "ItemId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_bundle_sale_template_lines_uoms_UomId",
column: x => x.UomId,
principalTable: "uoms",
principalColumn: "UomId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_bundle_sale_template_lines_warehouses_WarehouseId",
column: x => x.WarehouseId,
principalTable: "warehouses",
principalColumn: "WarehouseId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "item_reorders",
columns: table => new
@@ -1332,6 +1450,53 @@ namespace ERPCore.Infra.Persistence.Migrations
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "bundle_sale_lines",
columns: table => new
{
BundleSaleLineId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
BundleSaleId = table.Column<int>(type: "integer", nullable: false),
ItemId = table.Column<int>(type: "integer", nullable: false),
Description = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Qty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
UomId = table.Column<int>(type: "integer", nullable: false),
WarehouseId = table.Column<int>(type: "integer", nullable: false),
UnitPrice = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
LineTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
IncludeInBundle = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
IsComponent = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
ParentLineId = table.Column<int>(type: "integer", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_bundle_sale_lines", x => x.BundleSaleLineId);
table.ForeignKey(
name: "FK_bundle_sale_lines_bundle_sales_BundleSaleId",
column: x => x.BundleSaleId,
principalTable: "bundle_sales",
principalColumn: "BundleSaleId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_bundle_sale_lines_items_ItemId",
column: x => x.ItemId,
principalTable: "items",
principalColumn: "ItemId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_bundle_sale_lines_uoms_UomId",
column: x => x.UomId,
principalTable: "uoms",
principalColumn: "UomId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_bundle_sale_lines_warehouses_WarehouseId",
column: x => x.WarehouseId,
principalTable: "warehouses",
principalColumn: "WarehouseId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "sales_invoice_lines",
columns: table => new
@@ -2782,7 +2947,8 @@ namespace ERPCore.Infra.Persistence.Migrations
{ 9, "settings", "/dashboard/settings", null, "Settings", 9 },
{ 10, "help", "/dashboard/help", null, "Help", 10 },
{ 11, "ledgers", "/dashboard/ledgers", null, "Ledgers", 11 },
{ 12, "accounts", "/dashboard/accounts", null, "Accounts", 12 }
{ 12, "accounts", "/dashboard/accounts", null, "Accounts", 12 },
{ 13, "sales", "/dashboard/sales", null, "Sales", 13 }
});
migrationBuilder.InsertData(
@@ -2835,7 +3001,8 @@ namespace ERPCore.Infra.Persistence.Migrations
{ 19, "procurement.purchase-orders", "/dashboard/procurement/purchase-orders", null, "Purchase Orders", 4, 3 },
{ 20, "procurement.purchase-returns", "/dashboard/procurement/purchase-returns", null, "Purchase Returns", 4, 4 },
{ 21, "accounts.cheque-books", "/dashboard/accounts/cheque-books", null, "Cheque Books", 12, 2 },
{ 22, "accounts.received-cheques", "/dashboard/accounts/received-cheques", null, "Received Cheques", 12, 3 }
{ 22, "accounts.received-cheques", "/dashboard/accounts/received-cheques", null, "Received Cheques", 12, 3 },
{ 23, "sales.bundle-sales", "/dashboard/sales/bundles", null, "Bundle Sales", 13, 1 }
});
migrationBuilder.InsertData(
@@ -2905,6 +3072,78 @@ namespace ERPCore.Infra.Persistence.Migrations
table: "brands",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_bundle_sale_lines_BundleSaleId",
table: "bundle_sale_lines",
column: "BundleSaleId");
migrationBuilder.CreateIndex(
name: "IX_bundle_sale_lines_ItemId",
table: "bundle_sale_lines",
column: "ItemId");
migrationBuilder.CreateIndex(
name: "IX_bundle_sale_lines_UomId",
table: "bundle_sale_lines",
column: "UomId");
migrationBuilder.CreateIndex(
name: "IX_bundle_sale_lines_WarehouseId",
table: "bundle_sale_lines",
column: "WarehouseId");
migrationBuilder.CreateIndex(
name: "IX_bundle_sale_template_lines_BundleSaleTemplateId",
table: "bundle_sale_template_lines",
column: "BundleSaleTemplateId");
migrationBuilder.CreateIndex(
name: "IX_bundle_sale_template_lines_ItemId",
table: "bundle_sale_template_lines",
column: "ItemId");
migrationBuilder.CreateIndex(
name: "IX_bundle_sale_template_lines_UomId",
table: "bundle_sale_template_lines",
column: "UomId");
migrationBuilder.CreateIndex(
name: "IX_bundle_sale_template_lines_WarehouseId",
table: "bundle_sale_template_lines",
column: "WarehouseId");
migrationBuilder.CreateIndex(
name: "IX_bundle_sale_templates_TemplateCode",
table: "bundle_sale_templates",
column: "TemplateCode",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_bundle_sales_BundleNo",
table: "bundle_sales",
column: "BundleNo",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_bundle_sales_BundleSaleTemplateId",
table: "bundle_sales",
column: "BundleSaleTemplateId");
migrationBuilder.CreateIndex(
name: "IX_bundle_sales_CashierUserId",
table: "bundle_sales",
column: "CashierUserId");
migrationBuilder.CreateIndex(
name: "IX_bundle_sales_CustomerId",
table: "bundle_sales",
column: "CustomerId");
migrationBuilder.CreateIndex(
name: "IX_bundle_sales_WarehouseId",
table: "bundle_sales",
column: "WarehouseId");
migrationBuilder.CreateIndex(
name: "IX_categories_Name",
table: "categories",
@@ -4234,6 +4473,12 @@ namespace ERPCore.Infra.Persistence.Migrations
migrationBuilder.DropTable(
name: "audit_logs");
migrationBuilder.DropTable(
name: "bundle_sale_lines");
migrationBuilder.DropTable(
name: "bundle_sale_template_lines");
migrationBuilder.DropTable(
name: "hr_attendance_records");
@@ -4336,6 +4581,9 @@ namespace ERPCore.Infra.Persistence.Migrations
migrationBuilder.DropTable(
name: "vendor_quotation_lines");
migrationBuilder.DropTable(
name: "bundle_sales");
migrationBuilder.DropTable(
name: "hr_attendance_upload_batches");
@@ -4393,6 +4641,9 @@ namespace ERPCore.Infra.Persistence.Migrations
migrationBuilder.DropTable(
name: "vendor_quotations");
migrationBuilder.DropTable(
name: "bundle_sale_templates");
migrationBuilder.DropTable(
name: "hr_payroll_runs");
@@ -8,7 +8,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ERPCore.Infra.Persistence.Migrations
namespace ERPCore.Migrations
{
[DbContext(typeof(ErpDbContext))]
partial class ErpDbContextModelSnapshot : ModelSnapshot
@@ -17,7 +17,7 @@ namespace ERPCore.Infra.Persistence.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.9")
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -365,6 +365,269 @@ namespace ERPCore.Infra.Persistence.Migrations
b.ToTable("brands", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSale", b =>
{
b.Property<int>("BundleSaleId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("BundleSaleId"));
b.Property<string>("BundleCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime>("BundleDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("BundleName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("BundleNo")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<decimal>("BundlePrice")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("BundleSaleTemplateId")
.HasColumnType("integer");
b.Property<int>("CashierUserId")
.HasColumnType("integer");
b.Property<decimal>("ComponentSubtotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("ConcurrencyStamp")
.IsConcurrencyToken()
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CustomerId")
.HasColumnType("integer");
b.Property<string>("CustomerSnapshotName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<decimal>("DiscountTotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal>("GrandTotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal>("MarginAmount")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Draft");
b.Property<decimal>("TaxTotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("WarehouseId")
.HasColumnType("integer");
b.HasKey("BundleSaleId");
b.HasIndex("BundleNo")
.IsUnique();
b.HasIndex("BundleSaleTemplateId");
b.HasIndex("CashierUserId");
b.HasIndex("CustomerId");
b.HasIndex("WarehouseId");
b.ToTable("bundle_sales", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleLine", b =>
{
b.Property<int>("BundleSaleLineId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("BundleSaleLineId"));
b.Property<int>("BundleSaleId")
.HasColumnType("integer");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<bool>("IncludeInBundle")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<bool>("IsComponent")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<int>("ItemId")
.HasColumnType("integer");
b.Property<decimal>("LineTotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int?>("ParentLineId")
.HasColumnType("integer");
b.Property<decimal>("Qty")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal>("UnitPrice")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("UomId")
.HasColumnType("integer");
b.Property<int>("WarehouseId")
.HasColumnType("integer");
b.HasKey("BundleSaleLineId");
b.HasIndex("BundleSaleId");
b.HasIndex("ItemId");
b.HasIndex("UomId");
b.HasIndex("WarehouseId");
b.ToTable("bundle_sale_lines", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleTemplate", b =>
{
b.Property<int>("BundleSaleTemplateId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("BundleSaleTemplateId"));
b.Property<int>("ConcurrencyStamp")
.IsConcurrencyToken()
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Active");
b.Property<string>("TemplateCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("TemplateName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("BundleSaleTemplateId");
b.HasIndex("TemplateCode")
.IsUnique();
b.ToTable("bundle_sale_templates", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleTemplateLine", b =>
{
b.Property<int>("BundleSaleTemplateLineId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("BundleSaleTemplateLineId"));
b.Property<int>("BundleSaleTemplateId")
.HasColumnType("integer");
b.Property<bool>("IncludeInBundle")
.HasColumnType("boolean");
b.Property<int>("ItemId")
.HasColumnType("integer");
b.Property<decimal>("Qty")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.Property<decimal>("UnitPrice")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("UomId")
.HasColumnType("integer");
b.Property<int>("WarehouseId")
.HasColumnType("integer");
b.HasKey("BundleSaleTemplateLineId");
b.HasIndex("BundleSaleTemplateId");
b.HasIndex("ItemId");
b.HasIndex("UomId");
b.HasIndex("WarehouseId");
b.ToTable("bundle_sale_template_lines", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
{
b.Property<int>("CategoryId")
@@ -1950,6 +2213,15 @@ namespace ERPCore.Infra.Persistence.Migrations
Label = "Accounts",
SortOrder = 12,
Status = "Active"
},
new
{
NavItemId = 13,
Code = "sales",
Href = "/dashboard/sales",
Label = "Sales",
SortOrder = 13,
Status = "Active"
});
});
@@ -4537,6 +4809,16 @@ namespace ERPCore.Infra.Persistence.Migrations
Status = "Active"
},
new
{
SubNavItemId = 23,
Code = "sales.bundle-sales",
Href = "/dashboard/sales/bundles",
Label = "Bundle Sales",
NavItemId = 13,
SortOrder = 1,
Status = "Active"
},
new
{
SubNavItemId = 17,
Code = "procurement.requisitions",
@@ -5149,6 +5431,111 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Navigation("Warehouse");
});
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSale", b =>
{
b.HasOne("ERPCore.Domain.Entities.BundleSaleTemplate", "BundleSaleTemplate")
.WithMany()
.HasForeignKey("BundleSaleTemplateId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.User", "CashierUser")
.WithMany()
.HasForeignKey("CashierUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Customer", "Customer")
.WithMany()
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
.WithMany()
.HasForeignKey("WarehouseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("BundleSaleTemplate");
b.Navigation("CashierUser");
b.Navigation("Customer");
b.Navigation("Warehouse");
});
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleLine", b =>
{
b.HasOne("ERPCore.Domain.Entities.BundleSale", "BundleSale")
.WithMany("Lines")
.HasForeignKey("BundleSaleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
.WithMany()
.HasForeignKey("ItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Uom", "Uom")
.WithMany()
.HasForeignKey("UomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
.WithMany()
.HasForeignKey("WarehouseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("BundleSale");
b.Navigation("Item");
b.Navigation("Uom");
b.Navigation("Warehouse");
});
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleTemplateLine", b =>
{
b.HasOne("ERPCore.Domain.Entities.BundleSaleTemplate", "BundleSaleTemplate")
.WithMany("Lines")
.HasForeignKey("BundleSaleTemplateId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
.WithMany()
.HasForeignKey("ItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Uom", "Uom")
.WithMany()
.HasForeignKey("UomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
.WithMany()
.HasForeignKey("WarehouseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("BundleSaleTemplate");
b.Navigation("Item");
b.Navigation("Uom");
b.Navigation("Warehouse");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Customer", b =>
{
b.HasOne("ERPCore.Domain.Entities.Warehouse", "DefaultWarehouse")
@@ -6555,6 +6942,16 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Navigation("Quotation");
});
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSale", b =>
{
b.Navigation("Lines");
});
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleTemplate", b =>
{
b.Navigation("Lines");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
{
b.Navigation("SubCategories");
+1 -1
View File
@@ -6,7 +6,7 @@
}
},
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCoreDev;Username=postgres;Password=root"
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCoreTest;Username=postgres;Password=root"
},
"AuthHex": {
"BaseUrl": "http://localhost:5011"
+6
View File
@@ -0,0 +1,6 @@
{
"name": "ERPCore",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
@@ -170,6 +170,7 @@ export default function EmployeeDetailPage() {
emergencyContactName: employee.emergencyContactName,
emergencyContactRelationship: employee.emergencyContactRelationship,
emergencyContactPhone: employee.emergencyContactPhone,
hireDate: employee.hireDate,
confirmationDate: employee.confirmationDate,
lastWorkingDate: employee.lastWorkingDate,
departmentId: employee.departmentId,
@@ -1,6 +1,6 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { Suspense, useCallback, useEffect, useMemo, useState } from "react"
import { useParams, useRouter, useSearchParams } from "next/navigation"
import Link from "next/link"
import {
@@ -142,7 +142,7 @@ function stagesNamedIn(detail: string | undefined, stageNodes: Node[]): Set<stri
return new Set(named.map((n) => n.id))
}
export default function TemplateBuilderPage() {
function TemplateBuilderContent() {
const params = useParams<{ id: string }>()
const searchParams = useSearchParams()
const router = useRouter()
@@ -808,3 +808,11 @@ export default function TemplateBuilderPage() {
</div>
)
}
export default function TemplateBuilderPage() {
return (
<Suspense fallback={<Skeleton className="h-[60vh] w-full rounded-2xl" />}>
<TemplateBuilderContent />
</Suspense>
)
}
@@ -1,6 +1,6 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { Suspense, useEffect, useMemo, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, Minus, Plus, Save } from "lucide-react"
@@ -16,6 +16,7 @@ import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { cn } from "@/lib/utils"
import { toast } from "@/components/ui/toast"
@@ -28,7 +29,7 @@ type EditableLine = BundleSaleTemplateLine & { key: string }
const blankLine = (source: BundleSaleTemplateLine): EditableLine => ({ ...source, key: crypto.randomUUID() })
export default function NewBundleSalePage() {
function NewBundleSaleContent() {
const router = useRouter()
const searchParams = useSearchParams()
const templateFromQuery = searchParams.get("templateId")
@@ -286,3 +287,11 @@ export default function NewBundleSalePage() {
</div>
)
}
export default function NewBundleSalePage() {
return (
<Suspense fallback={<Skeleton className="h-48 w-full" />}>
<NewBundleSaleContent />
</Suspense>
)
}
@@ -309,7 +309,6 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Warehouse</div>
<div className="mt-2 font-semibold text-foreground">{warehouse?.name ?? `#${invoice.warehouseId}`}</div>
<div className="text-sm text-muted-foreground">Code: {warehouse?.code ?? invoice.warehouseId}</div>
<div className="text-sm text-muted-foreground">Location: {warehouse?.location ?? "—"}</div>
</div>
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
@@ -20,7 +20,7 @@ function formatHeader(key: string) {
.trim()
}
function formatCell(value: unknown) {
function formatCell(value: unknown): string {
if (value === null || value === undefined) return ""
if (typeof value === "number") return value.toLocaleString("en-LK", { maximumFractionDigits: 2 })
if (typeof value === "string") {
@@ -1,135 +1,22 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Building2, Save } from "lucide-react"
import { Building2 } from "lucide-react"
import { companyApi } from "@/lib/api/company"
import { errorMessage } from "@/lib/error-map"
import { buttonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { CompanyProfile } from "@/types/company"
import { buttonVariants, Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
export default function CompanyProfilePage() {
const [profile, setProfile] = useState<CompanyProfile | null>(null)
const [etag, setEtag] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
useEffect(() => {
companyApi
.getProfile()
.then((res) => {
setProfile(res.data)
setEtag(res.etag)
})
.catch((err) => setError(errorMessage(err)))
}, [])
function patch<K extends keyof CompanyProfile>(key: K, value: CompanyProfile[K]) {
setProfile((prev) => (prev ? { ...prev, [key]: value } : prev))
}
async function save() {
if (!profile || !etag) return
setSaving(true)
setError(null)
try {
const updated = await companyApi.updateProfile(profile, etag)
setProfile(updated.data)
setEtag(updated.etag)
toast.success("Company profile saved", updated.data.legalName)
} catch (err) {
setError(errorMessage(err))
} finally {
setSaving(false)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/settings" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div className="flex flex-col items-center justify-center gap-4 rounded-2xl border p-12 text-center">
<Building2 className="size-10 text-muted-foreground" />
<div>
<h1 className="text-2xl font-bold text-foreground">Company Profile</h1>
<p className="text-base text-muted-foreground">Invoice header, tax details, logo, and bank information.</p>
<h1 className="text-xl font-semibold text-foreground">Company Profile</h1>
<p className="text-base text-muted-foreground">This feature is not available yet.</p>
</div>
<Link href="/dashboard/settings" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
Back to Settings
</Link>
</div>
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
{!error && !profile && <Skeleton className="h-64 w-full" />}
{!error && profile && (
<div className="flex flex-col gap-6 rounded-2xl border p-6">
<div className="flex items-center gap-2">
<Building2 className="size-5 text-primary" />
<h2 className="text-lg font-semibold">Invoice Header</h2>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Field label="Legal Name" value={profile.legalName} onChange={(v) => patch("legalName", v)} />
<Field label="Trade Name" value={profile.tradeName ?? ""} onChange={(v) => patch("tradeName", v)} />
<Field label="Logo URL" value={profile.logoUrl ?? ""} onChange={(v) => patch("logoUrl", v)} />
<Field label="Tax Registration No" value={profile.taxRegistrationNo ?? ""} onChange={(v) => patch("taxRegistrationNo", v)} />
<Field label="VAT Registration No" value={profile.vatRegistrationNo ?? ""} onChange={(v) => patch("vatRegistrationNo", v)} />
<Field label="Phone" value={profile.phone ?? ""} onChange={(v) => patch("phone", v)} />
<Field label="Email" value={profile.email ?? ""} onChange={(v) => patch("email", v)} />
<Field label="City" value={profile.city ?? ""} onChange={(v) => patch("city", v)} />
<Field label="Country" value={profile.country ?? ""} onChange={(v) => patch("country", v)} />
<Field label="Address Line 1" value={profile.addressLine1 ?? ""} onChange={(v) => patch("addressLine1", v)} />
<Field label="Address Line 2" value={profile.addressLine2 ?? ""} onChange={(v) => patch("addressLine2", v)} />
</div>
<div className="border-t" />
<div className="flex items-center gap-2">
<Save className="size-5 text-primary" />
<h2 className="text-lg font-semibold">Bank Details</h2>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Field label="Bank Name" value={profile.bankName ?? ""} onChange={(v) => patch("bankName", v)} />
<Field label="Bank Branch" value={profile.bankBranch ?? ""} onChange={(v) => patch("bankBranch", v)} />
<Field label="Account Name" value={profile.accountName ?? ""} onChange={(v) => patch("accountName", v)} />
<Field label="Account Number" value={profile.accountNumber ?? ""} onChange={(v) => patch("accountNumber", v)} />
<Field label="SWIFT Code" value={profile.swiftCode ?? ""} onChange={(v) => patch("swiftCode", v)} />
<Field label="Footer Note" value={profile.footerNote ?? ""} onChange={(v) => patch("footerNote", v)} />
</div>
<div className="flex justify-end gap-3">
<Link href="/dashboard/settings" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
Cancel
</Link>
<Button size="lg" onClick={save} disabled={saving}>
{saving ? "Saving..." : "Save Profile"}
</Button>
</div>
</div>
)}
</div>
)
}
function Field({
label,
value,
onChange,
}: {
label: string
value: string
onChange: (value: string) => void
}) {
return (
<div className="flex flex-col gap-2">
<Label>{label}</Label>
<Input value={value} onChange={(e) => onChange(e.target.value)} />
</div>
)
}
@@ -1,6 +1,6 @@
"use client"
import { useEffect, useState } from "react"
import { Suspense, useEffect, useState } from "react"
import { useSearchParams } from "next/navigation"
import { Printer } from "lucide-react"
@@ -8,6 +8,7 @@ import { bundleApi } from "@/lib/api/bundles"
import { errorMessage } from "@/lib/error-map"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { BundleSaleSummary } from "@/types/bundles"
@@ -22,7 +23,7 @@ function statusClass(status: BundleSaleSummary["status"]) {
}
}
export default function BundleBatchPrintPage() {
function BundleBatchPrintContent() {
const searchParams = useSearchParams()
const status = searchParams.get("status") ?? "All"
const query = searchParams.get("q") ?? ""
@@ -88,3 +89,11 @@ export default function BundleBatchPrintPage() {
</div>
)
}
export default function BundleBatchPrintPage() {
return (
<Suspense fallback={<Skeleton className="h-48 w-full" />}>
<BundleBatchPrintContent />
</Suspense>
)
}
@@ -1,6 +1,6 @@
"use client"
import { useEffect, useState } from "react"
import { Suspense, useEffect, useState } from "react"
import { useSearchParams } from "next/navigation"
import { Printer } from "lucide-react"
@@ -8,6 +8,7 @@ import { salesApi } from "@/lib/api/sales"
import { errorMessage } from "@/lib/error-map"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { SalesInvoiceSummary } from "@/types/sales"
@@ -22,7 +23,7 @@ function statusClass(status: SalesInvoiceSummary["status"]) {
}
}
export default function SalesInvoiceBatchPrintPage() {
function SalesInvoiceBatchPrintContent() {
const searchParams = useSearchParams()
const status = searchParams.get("status") ?? "All"
const query = searchParams.get("q") ?? ""
@@ -105,3 +106,11 @@ export default function SalesInvoiceBatchPrintPage() {
</div>
)
}
export default function SalesInvoiceBatchPrintPage() {
return (
<Suspense fallback={<Skeleton className="h-48 w-full" />}>
<SalesInvoiceBatchPrintContent />
</Suspense>
)
}
@@ -1,6 +1,6 @@
"use client"
import { useEffect, useState } from "react"
import { Suspense, useEffect, useState } from "react"
import { useSearchParams } from "next/navigation"
import { Printer } from "lucide-react"
@@ -8,6 +8,7 @@ import { salesApi } from "@/lib/api/sales"
import { errorMessage } from "@/lib/error-map"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { SalesSlipSummary } from "@/types/sales"
@@ -22,7 +23,7 @@ function statusClass(status: SalesSlipSummary["status"]) {
}
}
export default function SalesSlipBatchPrintPage() {
function SalesSlipBatchPrintContent() {
const searchParams = useSearchParams()
const status = searchParams.get("status") ?? "All"
const query = searchParams.get("q") ?? ""
@@ -103,3 +104,11 @@ export default function SalesSlipBatchPrintPage() {
</div>
)
}
export default function SalesSlipBatchPrintPage() {
return (
<Suspense fallback={<Skeleton className="h-48 w-full" />}>
<SalesSlipBatchPrintContent />
</Suspense>
)
}
@@ -8,7 +8,7 @@ import { Plus } from "lucide-react"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { EntityStatus, PaginationMeta } from "@/types/common"
import { ApiResult, EntityStatus, PaginationMeta } from "@/types/common"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -27,7 +27,7 @@ interface CodeNamed {
interface Api<T extends CodeNamed> {
list(params: { page: number; pageSize: number }): Promise<{ items: T[]; pagination: PaginationMeta }>
create(request: { code: string; name: string }): Promise<{ value: T }>
create(request: { code: string; name: string }): Promise<ApiResult<T>>
updateStatus(id: number, status: EntityStatus): Promise<void>
}
+14
View File
@@ -0,0 +1,14 @@
# Copy to .env.e2e and fill in real values. Never commit .env.e2e.
# Frontend origin the browser navigates to (Next.js proxies /api/v1/* server-side from here).
E2E_BASE_URL=http://localhost:3000
# Backend origin, used only to build absolute API paths in error messages / docs; all
# actual requests go through E2E_BASE_URL's same-origin /api/v1 proxy.
E2E_API_URL=http://localhost:5224
# Credentials for a real AuthHex-backed user with access to Receiving, Production, and
# Stock modules. AuthHex is an external identity provider (see docs/11-BACKEND-PHASE1.md
# §2.0) - there is no local seed for this account, it must already exist upstream.
E2E_ADMIN_EMAIL=e2e-tester@example.com
E2E_ADMIN_PASSWORD=change-me
+155
View File
@@ -0,0 +1,155 @@
# ERP-Core E2E tests (Playwright)
End-to-end tests for the three phases requested first: **GRN (receiving)**, **Production
runs**, and **Stock movement** (transfers + adjustments), plus one chained scenario that
walks all three in sequence. Sales and Accounts are intentionally out of scope for now.
## Why Playwright, not Selenium
The frontend is Next.js 16 / React 19. Playwright auto-waits for React state updates,
ships trace/video capture on failure, and can drive the backend API directly (used here to
seed test data), which made it a better fit than Selenium for this stack.
## Prerequisites
1. Backend running locally: `cd Backend/ERPCore && dotnet ef database update && dotnet run`
(needs `ASPNETCORE_ENVIRONMENT=Development` set — see the repo's local-env notes — and a
reachable Postgres instance). Defaults to `http://localhost:5224`.
2. Frontend running locally: `cd Frontend/erp-system && npm install && npm run dev`.
Defaults to `http://localhost:3000` and proxies `/api/v1/*` to the backend same-origin.
3. A real login for the tests. **Auth is fronted by an external AuthHex identity provider**
(`Backend/ERPCore/Controllers/AuthController.cs`) — there is no local seed for a user
account, so `E2E_ADMIN_EMAIL`/`E2E_ADMIN_PASSWORD` must be a real, already-provisioned
account with access to Receiving, Production, and Stock.
4. A fresh-ish database is fine: `DataSeeder` (`Backend/ERPCore/Infra/Persistence/DataSeeder.cs`)
seeds the `MAIN`/`SHOP` warehouses, `PCS`/`BOX` UOMs, and a `General Goods` category that
these tests rely on existing. Everything else (vendors, items, purchase orders, a
production template) is created fresh per run by `support/api.ts` with unique
timestamp-suffixed codes, so reruns never collide with previous data.
5. **`E2E_BASE_URL` must use `http://localhost`, not `127.0.0.1` or a LAN IP.** The session
cookie is written with `Secure = true` unconditionally
(`Backend/ERPCore/Infra/Auth/AuthCookieWriter.cs`); Chromium only treats plain-HTTP
`localhost` as a secure-enough origin to accept and resend a `Secure` cookie, so anything
else silently drops the session and every post-login request 401s.
## Setup
```bash
cd Testing/e2e
npm install
npx playwright install --with-deps chromium
cp .env.e2e.example .env.e2e # then fill in E2E_ADMIN_EMAIL / E2E_ADMIN_PASSWORD
```
## Running
```bash
npm run test:e2e # headless, all specs
npm run test:e2e:ui # interactive UI mode — best for first-run locator debugging
npm run test:e2e:headed # headed browser
npm run report # open the last HTML report
```
The `setup` project (`specs/global.setup.ts`) logs in once through the real `/login` form
— the session is an httpOnly cookie, so there's no token to inject — and saves it to
`.auth/admin.json`. Every other spec's `chromium` project reuses that storage state, so
individual specs don't re-authenticate. `auth.spec.ts` is the exception: it explicitly runs
with no stored session so it can exercise the login form itself.
## Layout
```
Testing/e2e/
├── playwright.config.ts
├── support/
│ ├── env.ts # reads .env.e2e, resolves the storageState path
│ └── api.ts # ApiSeeder — creates vendors/items/POs/templates, reads stock on-hand
├── pages/ # Page Object Models (one file per module)
└── specs/
├── global.setup.ts
├── auth.spec.ts
├── grn.spec.ts
├── production.spec.ts
├── stock-transfers.spec.ts
├── stock-adjustments.spec.ts
└── chained-flow.spec.ts # GRN -> Production -> Stock Transfer, one continuous scenario
```
## Coverage vs. what's deferred
18 tests across auth, GRN, production runs, stock transfers/adjustments, and one chained
flow. Deliberately deferred (all would need a second, multi-stage production template or
custom-field scaffolding to exercise, which felt like scope creep for a first pass):
- **Approve & transfer** on a non-terminal stage, and transferring a held-back remainder
from an `Approved` stage — both only apply to a multi-stage graph; the seeded template is
single-stage (entry == terminal) so every run here only ever exercises "Approve & receive".
- **Reject intake** (pulling back delivered upstream WIP) — same reason, needs a
parent→child edge.
- Client-side validation edges inside `StageDrawer`: completing a stage with produced qty
over the staged input, or a scrap qty with no scrap reason selected.
- Adjustment reason-code → ledger-entry tagging spot-check (`GET /stock/ledger`) — the
positive/negative adjustment tests verify on-hand moves correctly but don't inspect the
ledger rows themselves.
**Long-run item-dropdown ceiling.** The GRN/Transfer/Adjustment "new" pages load items via
`itemsApi.list({ pageSize: 200 })` (a fixed page, not paginated further in the UI). Every
spec run mints 2-3 new permanent items through `ApiSeeder.createItem`, and nothing deletes
them. Once a dev database accumulates more than 200 active items, freshly-seeded items stop
appearing in the Item combobox (and if the list sorts ascending by id, it's exactly the
newest ones that fall off) — locators like `getByRole("option", { name: item.name })` will
time out with no visible cause. If that starts happening, the fix is to seed one stable
per-module item once and reuse it across runs instead of minting a fresh one each time
(every assertion here is already delta-based, so that's a drop-in change).
## Known limitation: a real, reproducible hydration bug
Every load of the GRN/Production/Stock pages throws a genuine React hydration error
("Minified React error #418" — text content mismatch between server and client render).
It is **not intermittent** — it fires on every navigation — but its effect is: hydration
recovery blanks the placeholder text of a random subset of that page's Select triggers for
the rest of that page's life, while leaving the sibling `<Label>`/`<FieldLabel>` and the
trigger's `role="combobox"` attribute intact. A `getByRole("combobox", { name: ... })`
lookup is therefore unreliable on these pages; `support/ui.ts`'s `comboboxByLabel()` works
around it by finding the trigger via its stable sibling label + role alone, never its
(possibly-blanked) accessible name. The same file's `retryClick`/`clickToReveal`/
`clickToRevealWithReload`/`submitAndWait` cover two related, separately-confirmed issues:
short-lived disabled/not-yet-mounted trigger buttons (`RunActions.tsx`'s "Cancel run"/
"Return leftover", gated on `run.status`), and stage/document actions whose UI only
reflects an async POST once the response lands — reading stock through the API immediately
after a click can otherwise race the backend commit. This is worth a look on the product
side (root-causing the actual SSR/CSR mismatch would remove the workaround entirely), but
was out of scope for a first E2E pass.
**Backend also can't take concurrent Playwright workers yet.** Reference-data GETs
(`/warehouses`, etc.) intermittently 500 when 2+ workers hit a plain `dotnet run` +
local Postgres backend at once — confirmed by re-running the exact same suite at
`workers: 1` with zero failures. `playwright.config.ts` pins `workers: 1` for that reason;
raise it only against a backend that can actually take concurrent load.
## Known limitation: no `data-testid`s yet
None of the GRN/Production/Stock Transfer/Stock Adjustment components in
`Frontend/erp-system` currently expose `data-testid` attributes, and several form controls
have no accessible name at all (the Qty/Unit cost/Disc%/VAT% `<Input type="number">` cells
in the GRN and Transfer line tables aren't wrapped in a `<label>` or given `aria-label`).
Locators in `pages/` work around this with role/placeholder matching where an accessible
name exists, and row + column-position locators (`row.locator('input[type="number"]').nth(n)`)
where it doesn't — every such case is called out in a comment at the top of the relevant
`pages/*.ts` file, along with the couple of same-text button pairs (e.g. "Cancel run" is
both the trigger and the dialog's confirm label) that needed `.first()`/`.last()` to
disambiguate. If a component's copy or layout changes, run `npm run test:e2e:ui` to see
exactly which locator broke and fix it in `pages/*.ts` — the specs themselves shouldn't need
to change.
**Recommended fast-follow** (not done here, since it's a product-code change rather than a
test-authoring one): add `data-testid` to the Select triggers, the Qty/cost inputs, and the
line-table rows in the receiving/production/stock components. That would let every locator
above swap from role/position matching to exact `data-testid` matching in one pass.
## CI
Not wired up yet — no GitHub Actions workflow exists in this repo. Once these specs are
green locally, add `.github/workflows/e2e.yml` (spin up Postgres + backend + frontend as
services, run `npm run test:e2e`, upload `playwright-report/` as an artifact) as a
follow-up.
+125
View File
@@ -0,0 +1,125 @@
{
"name": "erp-core-e2e",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "erp-core-e2e",
"version": "1.0.0",
"devDependencies": {
"@playwright/test": "^1.49.0",
"@types/node": "^20.0.0",
"dotenv": "^16.4.5",
"typescript": "^5.4.0"
}
},
"node_modules/@playwright/test": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@types/node": {
"version": "20.19.43",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"name": "erp-core-e2e",
"version": "1.0.0",
"private": true,
"description": "Playwright end-to-end tests for GRN, Production Runs, and Stock Movement flows.",
"scripts": {
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed",
"test:e2e:debug": "playwright test --debug",
"report": "playwright show-report"
},
"devDependencies": {
"@playwright/test": "^1.49.0",
"@types/node": "^20.0.0",
"dotenv": "^16.4.5",
"typescript": "^5.4.0"
}
}
+120
View File
@@ -0,0 +1,120 @@
import { Page, expect } from "@playwright/test"
import { clickToReveal, selectOption, comboboxByLabel } from "../support/ui"
// Locators verified against Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx
// and .../grn/[id]/page.tsx. Things that DOM inspection caught and a placeholder-only guess
// would not have:
// - The Qty/Unit cost/Disc%/VAT% <Input type="number"> cells carry no accessible name
// (no htmlFor/aria-label) - located by column position within the row instead.
// - On a PO-based line (line.poLineId set) Item/UOM render as plain text, not a Select -
// fillFirstLine() only touches the item combobox when one is actually present (checked by
// role alone, not name - see below).
// - Every page here intermittently throws a real React hydration error (#418) that blanks a
// random subset of Select triggers' placeholder text for that page's lifetime, WITHOUT
// affecting their sibling <Label> or role="combobox" attribute (support/ui.ts has the full
// writeup). So triggers are located via comboboxByLabel() (label + role, no name lookup)
// instead of getByRole("combobox", { name }) - the row-scoped item/uom/bin combos have no
// adjacent label and are instead found by position, which is equally immune to the bug.
export class GrnNewPage {
constructor(private readonly page: Page) {}
async goto() {
await this.page.goto("/dashboard/receiving/grn/new")
}
async useDirectReceipt() {
await clickToReveal(
this.page.getByRole("button", { name: /direct receipt/i }),
comboboxByLabel(this.page, "Vendor")
)
}
async useAgainstPo() {
await clickToReveal(
this.page.getByRole("button", { name: /against po/i }),
comboboxByLabel(this.page, "Purchase order")
)
}
async selectVendor(name: string) {
await selectOption(this.page, comboboxByLabel(this.page, "Vendor"), name)
}
async selectWarehouse(name: string) {
await selectOption(this.page, comboboxByLabel(this.page, "Warehouse"), name)
}
/** `docNo` is what the PO option renders (`{docNo} — Vendor #{vendorId} ({status})`) - not the numeric id. */
async selectPurchaseOrder(docNo: string) {
await selectOption(this.page, comboboxByLabel(this.page, "Purchase order"), new RegExp(docNo))
}
private firstRow() {
return this.page.locator("table tbody tr").first()
}
/**
* Item and UOM are each only a combobox when the row is NOT tied to a PO line
* (`line.poLineId` gates both cells identically in the source - a PO line renders them as
* plain text instead). Checked per-cell (td:nth(0) for Item, td:nth(1) for UOM) rather than
* "row has any combobox", since the Bin/Hold-status cells always have one regardless of PO
* mode - a row-wide check would false-positive on a PO line and select the wrong control.
* Selecting the app doesn't auto-fill UOM from the chosen item, so a direct-receipt/off-PO
* line needs it set explicitly or submit blocks with "Select a UOM".
*/
async fillFirstLine(opts: { item?: string; uom?: string; qty: number; unitCost?: number }) {
const row = this.firstRow()
const cells = row.locator("td")
if (opts.item) {
const itemCombo = cells.nth(0).getByRole("combobox")
if (await itemCombo.count()) {
await selectOption(this.page, itemCombo, opts.item)
}
}
if (opts.uom) {
const uomCombo = cells.nth(1).getByRole("combobox")
if (await uomCombo.count()) {
await selectOption(this.page, uomCombo, opts.uom)
}
}
const numberInputs = row.locator('input[type="number"]')
await numberInputs.nth(0).fill(String(opts.qty)) // Qty
if (opts.unitCost !== undefined) {
await numberInputs.nth(1).fill(String(opts.unitCost)) // Unit cost
}
}
async submit() {
await this.page.getByRole("button", { name: /create grn/i }).click()
}
}
export class GrnDetailPage {
constructor(private readonly page: Page) {}
async gotoById(grnId: number) {
await this.page.goto(`/dashboard/receiving/grn/${grnId}`)
}
/** GrnStatusBadge/HoldStatusBadge render the raw status string verbatim - exact match avoids
* matching prose like "Confirmed — stock layers created" in the post-confirm success panel. */
async expectStatus(status: "Draft" | "Confirmed") {
await expect(this.page.getByText(status, { exact: true })).toBeVisible()
}
async confirm() {
await this.page.getByRole("button", { name: /confirm grn/i }).click()
}
async releaseFirstOnHoldLine() {
await this.page.getByRole("button", { name: /^release$/i }).first().click()
}
async rejectFirstOnHoldLine() {
await this.page.getByRole("button", { name: /^reject$/i }).first().click()
}
async expectCreateReturnLink() {
await expect(this.page.getByRole("link", { name: /create return/i })).toBeVisible()
}
}
+23
View File
@@ -0,0 +1,23 @@
import { Page, expect } from "@playwright/test"
export class LoginPage {
constructor(private readonly page: Page) {}
async goto() {
await this.page.goto("/login")
}
async login(email: string, password: string) {
await this.page.locator("#email").fill(email)
await this.page.locator("#password").fill(password)
await this.page.getByRole("button", { name: /sign in/i }).click()
}
async expectLoggedIn() {
await expect(this.page).toHaveURL(/\/dashboard/)
}
async expectError() {
await expect(this.page.getByRole("alert")).toBeVisible()
}
}
+135
View File
@@ -0,0 +1,135 @@
import { Page, expect } from "@playwright/test"
import { clickToReveal, clickToRevealWithReload, selectOption, comboboxByLabel, submitAndWait } from "../support/ui"
// Locators verified against Frontend/erp-system/app/dashboard/production/runs/page.tsx,
// .../runs/[id]/page.tsx, .../runs/[id]/StageDrawer.tsx, and .../runs/[id]/RunActions.tsx.
// Key DOM facts that shaped these locators:
// - "Start Run" (the list page's dialog trigger, capital R) and "Start run" (the dialog's
// submit button, lowercase r) both match a case-insensitive /start run/i once the dialog
// is open (the trigger stays mounted behind it) - the submit click is scoped to
// getByRole("dialog") to avoid a strict-mode double match.
// - STAGE_STATUS_LABEL.InProgress is "In Progress" - the same text StageStatusLegend
// always renders on the run detail page, so a run-status assertion of "In Progress"
// collides with the legend. expectStatus() takes the first DOM match, which is always
// the run-header badge (it renders before the legend section).
// - AlertDialogContent's rejectForRework confirmation reuses "Reject for rework" as both
// the trigger and the confirm button's label - first()/last() disambiguates, same as
// cancelRun's "Cancel run" trigger/confirm pair.
// - Every page here intermittently throws a real React hydration error (#418) that blanks a
// random subset of Select triggers' placeholder text for that page's lifetime, without
// affecting their sibling <FieldLabel> or role="combobox" attribute (support/ui.ts has the
// full writeup). Triggers are located via comboboxByLabel() (label + role, no name lookup)
// instead of getByRole("combobox", { name }); the scrap-reason Select has no adjacent
// label, so it's found via its "Scrapped" sibling block instead.
// - Every stage/run action button here fires an async POST that the UI only reflects once the
// response lands (StageDrawer/RunActions' `submit()` wrapper) - submitAndWait() (support/ui.ts)
// waits for that specific response instead of just the click event, so a test reading stock
// right after clicking "Approve & receive" (etc.) doesn't race the backend commit.
export class ProductionRunListPage {
constructor(private readonly page: Page) {}
async goto() {
await this.page.goto("/dashboard/production/runs")
}
async openStartRunDialog() {
await clickToReveal(
this.page.getByRole("button", { name: /start run/i }),
this.page.getByRole("dialog")
)
}
async startRun(opts: { template: string; targetQty: number; warehouse: string }) {
await this.openStartRunDialog()
const dialog = this.page.getByRole("dialog")
await selectOption(this.page, comboboxByLabel(dialog, "Template"), opts.template)
await dialog.locator("#target-qty").fill(String(opts.targetQty))
await selectOption(this.page, comboboxByLabel(dialog, "Warehouse"), opts.warehouse)
await submitAndWait(this.page, dialog.getByRole("button", { name: /^start run$/i }), "/production-runs")
}
}
export class ProductionRunDetailPage {
constructor(private readonly page: Page) {}
async gotoById(runId: number) {
await this.page.goto(`/dashboard/production/runs/${runId}`)
}
async expectStatus(status: RegExp | string) {
await expect(this.page.getByText(status).first()).toBeVisible()
}
/** Opens the StageDrawer for a named stage node on the React Flow canvas. */
async openStage(stageName: string) {
await clickToReveal(
this.page.getByText(stageName, { exact: true }),
this.page.getByRole("button", { name: /save quantities/i })
)
}
/** The StageDrawer is a modal Sheet - run-level actions (Return leftover, Cancel run) sit
* behind it and need it dismissed first. */
async closeStageDrawer() {
await this.page.keyboard.press("Escape")
}
async saveQuantities() {
// updateStageQuantities is a PUT, unlike every other stage action.
await submitAndWait(this.page, this.page.getByRole("button", { name: /save quantities/i }), "/quantities", "PUT")
}
async startStage() {
await submitAndWait(this.page, this.page.getByRole("button", { name: /^start stage$/i }), "/start")
}
async completeStage(opts: { producedQty: number; scrappedQty?: number }) {
await this.page.getByRole("spinbutton", { name: /produced/i }).first().fill(String(opts.producedQty))
if (opts.scrappedQty) {
await this.page.getByRole("spinbutton", { name: /scrapped/i }).first().fill(String(opts.scrappedQty))
const scrapBlock = this.page.getByText("Scrapped", { exact: true }).locator("../..")
await selectOption(this.page, scrapBlock.getByRole("combobox"), /.+/)
}
await submitAndWait(this.page, this.page.getByRole("button", { name: /complete stage/i }), "/complete")
}
async approveAndReceive() {
await submitAndWait(this.page, this.page.getByRole("button", { name: /approve\s*&\s*receive/i }), "/approve")
}
async approveAndTransfer() {
await submitAndWait(this.page, this.page.getByRole("button", { name: /approve\s*&\s*transfer/i }), "/approve")
}
async rejectForRework() {
const button = this.page.getByRole("button", { name: /^reject for rework$/i })
await clickToReveal(button.first(), this.page.getByRole("dialog"))
await submitAndWait(this.page, button.last(), "/reject")
}
async openReturnLeftoverDialog() {
await clickToRevealWithReload(
this.page,
this.page.getByRole("button", { name: /return leftover/i }),
this.page.getByRole("dialog")
)
}
async returnLeftover(opts: { material: string; qty: number; reason: string }) {
await this.openReturnLeftoverDialog()
const dialog = this.page.getByRole("dialog")
await selectOption(this.page, comboboxByLabel(dialog, "Consumed material"), opts.material)
await dialog.locator("#return-qty").fill(String(opts.qty))
await selectOption(this.page, comboboxByLabel(dialog, "Reason"), opts.reason)
await submitAndWait(this.page, dialog.getByRole("button", { name: /return to stock/i }), "/return-leftover")
}
async cancelRun(opts: { reason: string; note?: string }) {
const cancelRunButton = this.page.getByRole("button", { name: /^cancel run$/i })
const dialog = this.page.getByRole("dialog")
await clickToRevealWithReload(this.page, cancelRunButton.first(), dialog)
await selectOption(this.page, comboboxByLabel(dialog, "Reason"), opts.reason)
if (opts.note) await dialog.locator("#cancel-note").fill(opts.note)
await submitAndWait(this.page, cancelRunButton.last(), "/cancel")
}
}
+82
View File
@@ -0,0 +1,82 @@
import { Page, expect } from "@playwright/test"
import { selectOption, comboboxByLabel } from "../support/ui"
// Locators verified against Frontend/erp-system/app/dashboard/stock/transfers/new/page.tsx,
// .../transfers/[id]/page.tsx, and .../stock/adjustments/new/page.tsx.
// Every page here intermittently throws a real React hydration error (#418) that blanks a
// random subset of Select triggers' placeholder text for that page's lifetime, without
// affecting their sibling <Label> or role="combobox" attribute (support/ui.ts has the full
// writeup). Triggers are located via comboboxByLabel() (label + role, no name lookup) instead
// of getByRole("combobox", { name }); the row-scoped item combo has no adjacent label and is
// instead found by position (it's the first combobox in the row).
export class StockTransferNewPage {
constructor(private readonly page: Page) {}
async goto() {
await this.page.goto("/dashboard/stock/transfers/new")
}
async fill(opts: { fromWarehouse: string; toWarehouse: string; item: string; qty: number }) {
await selectOption(this.page, comboboxByLabel(this.page, "From warehouse"), opts.fromWarehouse)
await selectOption(this.page, comboboxByLabel(this.page, "To warehouse"), opts.toWarehouse)
const row = this.page.locator("table tbody tr").first()
await selectOption(this.page, row.getByRole("combobox").first(), opts.item)
// The Qty <Input type="number"> carries no accessible name - it's the only number input in the row.
await row.locator('input[type="number"]').fill(String(opts.qty))
}
async submit() {
await this.page.getByRole("button", { name: /create transfer/i }).click()
}
}
export class StockTransferDetailPage {
constructor(private readonly page: Page) {}
async gotoById(transferId: number) {
await this.page.goto(`/dashboard/stock/transfers/${transferId}`)
}
/**
* TransferStatusBadge renders the raw enum literal ("Draft" | "InTransit" | "Received") -
* exact match, since "Received" is also a substring of the post-receive success panel's
* heading ("Received — destination layers created").
*/
async expectStatus(status: "Draft" | "InTransit" | "Received") {
await expect(this.page.getByText(status, { exact: true })).toBeVisible()
}
async dispatch() {
await this.page.getByRole("button", { name: /^dispatch$/i }).click()
}
async receive() {
await this.page.getByRole("button", { name: /^receive$/i }).click()
}
}
export class StockAdjustmentNewPage {
constructor(private readonly page: Page) {}
async goto() {
await this.page.goto("/dashboard/stock/adjustments/new")
}
async fill(opts: { warehouse: string; reasonCode: string; item: string; qtyDelta: number }) {
await selectOption(this.page, comboboxByLabel(this.page, "Warehouse"), opts.warehouse)
await selectOption(this.page, comboboxByLabel(this.page, "Reason code"), opts.reasonCode)
const row = this.page.locator("table tbody tr").first()
await selectOption(this.page, row.getByRole("combobox").first(), opts.item)
await row.getByPlaceholder(/e\.g\. -15 or 50/i).fill(String(opts.qtyDelta))
}
async submit() {
await this.page.getByRole("button", { name: /post adjustment/i }).click()
}
async expectPosted() {
await expect(this.page.getByRole("button", { name: /new adjustment/i })).toBeVisible()
}
}
+35
View File
@@ -0,0 +1,35 @@
import { defineConfig, devices } from "@playwright/test"
import { env, AUTH_STORAGE_STATE } from "./support/env"
export default defineConfig({
testDir: "./specs",
fullyParallel: false, // specs share warehouse/item reference data via the ledger - keep runs serial per file
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
// Multiple workers hit the local dev backend concurrently across spec files and it can't
// take it: confirmed reference-data GETs (e.g. /warehouses) intermittently 500 under 2+
// workers against a plain `dotnet run` + local Postgres, and pass every time at workers: 1.
// Bump this only against a backend that can actually take concurrent load (a real CI service
// container, not a single dev-mode process).
workers: 1,
reporter: [["html", { open: "never" }], ["list"]],
timeout: 45_000,
expect: { timeout: 10_000 },
use: {
baseURL: env.baseUrl,
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
projects: [
{
name: "setup",
testMatch: /global\.setup\.ts/,
},
{
name: "chromium",
use: { ...devices["Desktop Chrome"], storageState: AUTH_STORAGE_STATE },
dependencies: ["setup"],
},
],
})
+29
View File
@@ -0,0 +1,29 @@
import { test, expect } from "@playwright/test"
import { LoginPage } from "../pages/LoginPage"
import { env } from "../support/env"
// Runs unauthenticated - unlike every other spec, it must not use the "chromium" project's
// saved storageState, since it is exercising the login form itself.
test.use({ storageState: { cookies: [], origins: [] } })
test.describe("Login", () => {
test("valid credentials redirect to the dashboard", async ({ page }) => {
const login = new LoginPage(page)
await login.goto()
await login.login(env.adminEmail, env.adminPassword)
await login.expectLoggedIn()
})
test("invalid password shows an inline error and stays on /login", async ({ page }) => {
const login = new LoginPage(page)
await login.goto()
await login.login(env.adminEmail, "definitely-not-the-password")
await login.expectError()
await expect(page).toHaveURL(/\/login/)
})
test("session-expired redirect shows the amber notice", async ({ page }) => {
await page.goto("/login?next=/dashboard/receiving/grn")
await expect(page.getByText(/session is missing or expired/i)).toBeVisible()
})
})
+112
View File
@@ -0,0 +1,112 @@
import { test, expect, APIRequestContext } from "@playwright/test"
import { ApiSeeder, newApiContext, Warehouse, Item, Uom, Vendor } from "../support/api"
import { GrnNewPage, GrnDetailPage } from "../pages/GrnPages"
import { ProductionRunListPage, ProductionRunDetailPage } from "../pages/ProductionRunPages"
import { StockTransferNewPage, StockTransferDetailPage } from "../pages/StockPages"
// Full cross-module lifecycle: GRN receipt -> Production consumes the received stock and
// produces a finished good -> Stock Transfer moves the finished good to a second warehouse.
// All four modules post to the same StockLayer/StockLedger tables (docs/10 C.9), so this is
// the scenario most likely to catch a regression in one module's ledger posting breaking
// another's downstream read - the thing the per-module suites (grn.spec.ts,
// production.spec.ts, stock-transfers.spec.ts) can't see in isolation.
test.describe("Chained flow: GRN -> Production -> Stock Transfer", () => {
let api: APIRequestContext
let seeder: ApiSeeder
let sourceWarehouse: Warehouse
let destWarehouse: Warehouse
let vendor: Vendor
let uom: Uom
let rawItem: Item
let finishedItem: Item
let templateName: string
test.beforeAll(async () => {
api = await newApiContext()
seeder = new ApiSeeder(api)
sourceWarehouse = await seeder.firstWarehouse()
destWarehouse = await seeder.secondWarehouse()
uom = await seeder.firstUom()
vendor = await seeder.createVendor("Chained Flow Vendor")
rawItem = await seeder.createItem({ namePrefix: "Chained Raw Material" })
finishedItem = await seeder.createItem({ namePrefix: "Chained Finished Good" })
const template = await seeder.createSingleStageTemplate({
rawItemId: rawItem.itemId,
finishedItemId: finishedItem.itemId,
uomId: uom.uomId,
})
templateName = template.name
})
test.afterAll(async () => {
await api.dispose()
})
test("receive raw material, run production, transfer the finished good", async ({ page }) => {
// --- 1. GRN: receive the raw material into the source warehouse -----------------
const grnNew = new GrnNewPage(page)
await grnNew.goto()
await grnNew.useDirectReceipt()
await grnNew.selectVendor(vendor.name)
await grnNew.selectWarehouse(sourceWarehouse.name)
await grnNew.fillFirstLine({ item: rawItem.name, uom: uom.name, qty: 100, unitCost: 20 })
await grnNew.submit()
await expect(page).toHaveURL(/\/dashboard\/receiving\/grn\/\d+/)
const grnDetail = new GrnDetailPage(page)
await grnDetail.expectStatus("Draft")
await grnDetail.confirm()
await grnDetail.expectStatus("Confirmed")
const rawAfterGrn = await seeder.stockOnHand(rawItem.itemId, sourceWarehouse.warehouseId)
expect(rawAfterGrn.onHand).toBeCloseTo(100, 4)
// --- 2. Production: consume the raw material, produce the finished good ---------
const runList = new ProductionRunListPage(page)
await runList.goto()
await runList.startRun({ template: templateName, targetQty: 20, warehouse: sourceWarehouse.name })
await expect(page).toHaveURL(/\/dashboard\/production\/runs\/\d+/)
const runDetail = new ProductionRunDetailPage(page)
await runDetail.expectStatus(/in progress/i)
await runDetail.openStage("Assemble")
await runDetail.saveQuantities()
await runDetail.startStage()
const rawAfterStart = await seeder.stockOnHand(rawItem.itemId, sourceWarehouse.warehouseId)
expect(rawAfterStart.onHand).toBeLessThan(rawAfterGrn.onHand)
await runDetail.completeStage({ producedQty: 20 })
await runDetail.approveAndReceive()
const finishedAfterRun = await seeder.stockOnHand(finishedItem.itemId, sourceWarehouse.warehouseId)
expect(finishedAfterRun.onHand).toBeCloseTo(20, 4)
// --- 3. Stock Transfer: move the finished good to a second warehouse ------------
const transferNew = new StockTransferNewPage(page)
await transferNew.goto()
await transferNew.fill({
fromWarehouse: sourceWarehouse.name,
toWarehouse: destWarehouse.name,
item: finishedItem.name,
qty: 20,
})
await transferNew.submit()
await expect(page).toHaveURL(/\/dashboard\/stock\/transfers\/\d+/)
const transferDetail = new StockTransferDetailPage(page)
await transferDetail.expectStatus("Draft")
await transferDetail.dispatch()
await transferDetail.expectStatus("InTransit")
await transferDetail.receive()
await transferDetail.expectStatus("Received")
// --- 4. Final assertions across the whole chain ----------------------------------
const finishedAtSource = await seeder.stockOnHand(finishedItem.itemId, sourceWarehouse.warehouseId)
const finishedAtDest = await seeder.stockOnHand(finishedItem.itemId, destWarehouse.warehouseId)
expect(finishedAtSource.onHand).toBeCloseTo(0, 4)
expect(finishedAtDest.onHand).toBeCloseTo(20, 4)
})
})
+16
View File
@@ -0,0 +1,16 @@
import { test as setup } from "@playwright/test"
import { LoginPage } from "../pages/LoginPage"
import { env, AUTH_STORAGE_STATE } from "../support/env"
// Runs once before the "chromium" project (see playwright.config.ts `dependencies`). Logs
// in through the real UI form - the session is an httpOnly cookie (docs/11 §2.0), so there
// is no token to inject; driving the form is the only way to obtain it - then saves cookies
// to disk so every other spec starts already authenticated.
setup("authenticate", async ({ page }) => {
const login = new LoginPage(page)
await login.goto()
await login.login(env.adminEmail, env.adminPassword)
await login.expectLoggedIn()
await page.context().storageState({ path: AUTH_STORAGE_STATE })
})
+125
View File
@@ -0,0 +1,125 @@
import { test, expect, APIRequestContext } from "@playwright/test"
import { ApiSeeder, newApiContext, Vendor, Warehouse, Item, Uom } from "../support/api"
import { GrnNewPage, GrnDetailPage } from "../pages/GrnPages"
// GRN receiving flow (Backend/ERPCore/Controllers/GrnsController.cs, Frontend
// app/dashboard/receiving/grn/*). Covers direct + against-PO receipts, confirm posting to
// the stock ledger, and per-line hold-status actions.
test.describe("GRN", () => {
let api: APIRequestContext
let seeder: ApiSeeder
let warehouse: Warehouse
let vendor: Vendor
let uom: Uom
let item: Item
test.beforeAll(async () => {
api = await newApiContext()
seeder = new ApiSeeder(api)
warehouse = await seeder.firstWarehouse()
uom = await seeder.firstUom()
vendor = await seeder.createVendor()
item = await seeder.createItem({ namePrefix: "GRN Test Item" })
})
test.afterAll(async () => {
await api.dispose()
})
test("direct receipt creates a Draft GRN, confirm posts stock", async ({ page }) => {
const grnNew = new GrnNewPage(page)
await grnNew.goto()
await grnNew.useDirectReceipt()
await grnNew.selectVendor(vendor.name)
await grnNew.selectWarehouse(warehouse.name)
await grnNew.fillFirstLine({ item: item.name, uom: uom.name, qty: 10, unitCost: 50 })
await grnNew.submit()
await expect(page).toHaveURL(/\/dashboard\/receiving\/grn\/\d+/)
const grnDetail = new GrnDetailPage(page)
await grnDetail.expectStatus("Draft")
const before = await seeder.stockOnHand(item.itemId, warehouse.warehouseId)
await grnDetail.confirm()
await grnDetail.expectStatus("Confirmed")
const after = await seeder.stockOnHand(item.itemId, warehouse.warehouseId)
expect(after.onHand).toBeCloseTo(before.onHand + 10, 4)
})
test("against-PO receipt pre-fills vendor/warehouse from the PO", async ({ page }) => {
const po = await seeder.createPurchaseOrder({
vendorId: vendor.vendorId,
warehouseId: warehouse.warehouseId,
itemId: item.itemId,
uomId: uom.uomId,
qty: 5,
unitPrice: 40,
})
const grnNew = new GrnNewPage(page)
await grnNew.goto()
await grnNew.useAgainstPo()
await grnNew.selectPurchaseOrder(po.docNo)
await grnNew.fillFirstLine({ item: item.name, qty: 5 })
await grnNew.submit()
await expect(page).toHaveURL(/\/dashboard\/receiving\/grn\/\d+/)
const grnDetail = new GrnDetailPage(page)
await grnDetail.expectStatus("Draft")
})
test("rejecting a Draft GRN's blocked submit (missing warehouse) keeps the user on the form", async ({ page }) => {
const grnNew = new GrnNewPage(page)
await grnNew.goto()
await grnNew.useDirectReceipt()
await grnNew.selectVendor(vendor.name)
// Warehouse intentionally left unselected.
await grnNew.fillFirstLine({ item: item.name, uom: uom.name, qty: 1, unitCost: 10 })
await grnNew.submit()
await expect(page).toHaveURL(/\/dashboard\/receiving\/grn\/new/)
})
test("releasing an on-hold line clears the hold and makes stock available", async ({ page }) => {
const grn = await seeder.receiveStockOnHold({
warehouseId: warehouse.warehouseId,
vendorId: vendor.vendorId,
itemId: item.itemId,
uomId: uom.uomId,
qty: 8,
unitCost: 12,
})
const grnDetail = new GrnDetailPage(page)
await grnDetail.gotoById(grn.grnId)
await grnDetail.expectStatus("Confirmed")
const before = await seeder.stockOnHand(item.itemId, warehouse.warehouseId)
expect(before.available).toBeLessThan(before.onHand) // held stock is on-hand but not available
await grnDetail.releaseFirstOnHoldLine()
const after = await seeder.stockOnHand(item.itemId, warehouse.warehouseId)
expect(after.available).toBeCloseTo(before.available + 8, 4)
})
test("rejecting an on-hold line surfaces a Create Return link", async ({ page }) => {
const grn = await seeder.receiveStockOnHold({
warehouseId: warehouse.warehouseId,
vendorId: vendor.vendorId,
itemId: item.itemId,
uomId: uom.uomId,
qty: 3,
unitCost: 12,
})
const grnDetail = new GrnDetailPage(page)
await grnDetail.gotoById(grn.grnId)
await grnDetail.expectStatus("Confirmed")
await grnDetail.rejectFirstOnHoldLine()
await grnDetail.expectCreateReturnLink()
})
})
+119
View File
@@ -0,0 +1,119 @@
import { test, expect, APIRequestContext } from "@playwright/test"
import { ApiSeeder, newApiContext, Warehouse, Item, Uom, Vendor } from "../support/api"
import { ProductionRunListPage, ProductionRunDetailPage } from "../pages/ProductionRunPages"
// Production run lifecycle (Backend/ERPCore/Controllers/ProductionRunsController.cs,
// Frontend app/dashboard/production/runs/*). Uses a minimal single-stage template (one
// stage that is both entry and terminal - see ApiSeeder.createSingleStageTemplate) so the
// stage-action sequence (start -> complete -> approve & receive) is exercised without
// needing a multi-stage graph.
test.describe("Production runs", () => {
let api: APIRequestContext
let seeder: ApiSeeder
let warehouse: Warehouse
let vendor: Vendor
let uom: Uom
let rawItem: Item
let finishedItem: Item
let templateName: string
test.beforeAll(async () => {
api = await newApiContext()
seeder = new ApiSeeder(api)
warehouse = await seeder.firstWarehouse()
uom = await seeder.firstUom()
vendor = await seeder.createVendor()
rawItem = await seeder.createItem({ namePrefix: "PROD Raw Material" })
finishedItem = await seeder.createItem({ namePrefix: "PROD Finished Good" })
// Give the run something to consume.
await seeder.receiveStock({
warehouseId: warehouse.warehouseId,
vendorId: vendor.vendorId,
itemId: rawItem.itemId,
uomId: uom.uomId,
qty: 100,
unitCost: 20,
})
const template = await seeder.createSingleStageTemplate({
rawItemId: rawItem.itemId,
finishedItemId: finishedItem.itemId,
uomId: uom.uomId,
})
templateName = template.name
})
test.afterAll(async () => {
await api.dispose()
})
test("start run -> complete stage -> approve & receive posts finished-good stock", async ({ page }) => {
const list = new ProductionRunListPage(page)
await list.goto()
await list.startRun({ template: templateName, targetQty: 10, warehouse: warehouse.name })
await expect(page).toHaveURL(/\/dashboard\/production\/runs\/\d+/)
const detail = new ProductionRunDetailPage(page)
await detail.expectStatus(/in progress/i)
await detail.openStage("Assemble")
await detail.saveQuantities()
await detail.startStage()
const before = await seeder.stockOnHand(finishedItem.itemId, warehouse.warehouseId)
await detail.completeStage({ producedQty: 10 })
await detail.approveAndReceive()
const after = await seeder.stockOnHand(finishedItem.itemId, warehouse.warehouseId)
expect(after.onHand).toBeCloseTo(before.onHand + 10, 4)
})
test("cancel run stops further stage actions", async ({ page }) => {
const list = new ProductionRunListPage(page)
await list.goto()
await list.startRun({ template: templateName, targetQty: 5, warehouse: warehouse.name })
await expect(page).toHaveURL(/\/dashboard\/production\/runs\/\d+/)
const detail = new ProductionRunDetailPage(page)
await detail.cancelRun({ reason: "Production Run Cancelled", note: "E2E cancel test" })
await detail.expectStatus(/cancelled/i)
})
test("return leftover raw material posts the unused quantity back to stock", async ({ page }) => {
const list = new ProductionRunListPage(page)
await list.goto()
await list.startRun({ template: templateName, targetQty: 5, warehouse: warehouse.name })
await expect(page).toHaveURL(/\/dashboard\/production\/runs\/\d+/)
const detail = new ProductionRunDetailPage(page)
await detail.openStage("Assemble")
await detail.saveQuantities()
await detail.startStage() // consumes the raw-material FIFO layers, making them returnable
await detail.closeStageDrawer()
const before = await seeder.stockOnHand(rawItem.itemId, warehouse.warehouseId)
await detail.returnLeftover({ material: rawItem.name, qty: 1, reason: "Production Leftover Return" })
const after = await seeder.stockOnHand(rawItem.itemId, warehouse.warehouseId)
expect(after.onHand).toBeCloseTo(before.onHand + 1, 4)
})
test("reject for rework resets the run and increments the rework count", async ({ page }) => {
const list = new ProductionRunListPage(page)
await list.goto()
await list.startRun({ template: templateName, targetQty: 5, warehouse: warehouse.name })
await expect(page).toHaveURL(/\/dashboard\/production\/runs\/\d+/)
const detail = new ProductionRunDetailPage(page)
await detail.openStage("Assemble")
await detail.saveQuantities()
await detail.startStage()
await detail.completeStage({ producedQty: 5 }) // stage -> Done, and terminal (single-stage template)
await detail.rejectForRework()
await detail.expectStatus(/rework #1/i)
})
})
@@ -0,0 +1,65 @@
import { test, expect, APIRequestContext } from "@playwright/test"
import { ApiSeeder, newApiContext, Warehouse, Item, Uom, Vendor } from "../support/api"
import { StockAdjustmentNewPage } from "../pages/StockPages"
// Stock adjustment flow (Backend/ERPCore/Controllers/StockAdjustmentsController.cs,
// Frontend app/dashboard/stock/adjustments/new): posts immediately, no draft state
// (Backend/ERPCore/Dtos/Stock/AdjustmentDtos.cs - QtyDelta is a signed base-UOM delta).
test.describe("Stock adjustments", () => {
let api: APIRequestContext
let seeder: ApiSeeder
let warehouse: Warehouse
let vendor: Vendor
let uom: Uom
let item: Item
test.beforeAll(async () => {
api = await newApiContext()
seeder = new ApiSeeder(api)
warehouse = await seeder.firstWarehouse()
uom = await seeder.firstUom()
vendor = await seeder.createVendor()
item = await seeder.createItem({ namePrefix: "Adjustment Test Item" })
await seeder.receiveStock({
warehouseId: warehouse.warehouseId,
vendorId: vendor.vendorId,
itemId: item.itemId,
uomId: uom.uomId,
qty: 20,
unitCost: 30,
})
})
test.afterAll(async () => {
await api.dispose()
})
test("positive adjustment increases on-hand and shows the posted doc number", async ({ page }) => {
const before = await seeder.stockOnHand(item.itemId, warehouse.warehouseId)
const newPage = new StockAdjustmentNewPage(page)
await newPage.goto()
await newPage.fill({ warehouse: warehouse.name, reasonCode: "System Correction", item: item.name, qtyDelta: 5 })
await newPage.submit()
await newPage.expectPosted()
const after = await seeder.stockOnHand(item.itemId, warehouse.warehouseId)
expect(after.onHand).toBeCloseTo(before.onHand + 5, 4)
})
test("negative adjustment decreases on-hand", async ({ page }) => {
const before = await seeder.stockOnHand(item.itemId, warehouse.warehouseId)
const newPage = new StockAdjustmentNewPage(page)
await newPage.goto()
await newPage.fill({ warehouse: warehouse.name, reasonCode: "Damage", item: item.name, qtyDelta: -3 })
await newPage.submit()
await newPage.expectPosted()
const after = await seeder.stockOnHand(item.itemId, warehouse.warehouseId)
expect(after.onHand).toBeCloseTo(before.onHand - 3, 4)
})
})
+90
View File
@@ -0,0 +1,90 @@
import { test, expect, APIRequestContext } from "@playwright/test"
import { ApiSeeder, newApiContext, Warehouse, Item, Uom, Vendor } from "../support/api"
import { StockTransferNewPage, StockTransferDetailPage } from "../pages/StockPages"
// Stock transfer flow (Backend/ERPCore/Controllers/StockTransfersController.cs, Frontend
// app/dashboard/stock/transfers/*): Draft -> Dispatch -> Receive, moving FIFO layers
// between warehouses.
test.describe("Stock transfers", () => {
let api: APIRequestContext
let seeder: ApiSeeder
let srcWarehouse: Warehouse
let destWarehouse: Warehouse
let vendor: Vendor
let uom: Uom
let item: Item
test.beforeAll(async () => {
api = await newApiContext()
seeder = new ApiSeeder(api)
srcWarehouse = await seeder.firstWarehouse()
destWarehouse = await seeder.secondWarehouse()
uom = await seeder.firstUom()
vendor = await seeder.createVendor()
item = await seeder.createItem({ namePrefix: "Transfer Test Item" })
await seeder.receiveStock({
warehouseId: srcWarehouse.warehouseId,
vendorId: vendor.vendorId,
itemId: item.itemId,
uomId: uom.uomId,
qty: 50,
unitCost: 15,
})
})
test.afterAll(async () => {
await api.dispose()
})
test("create -> dispatch -> receive moves stock between warehouses", async ({ page }) => {
const before = await seeder.stockOnHand(item.itemId, srcWarehouse.warehouseId)
const newPage = new StockTransferNewPage(page)
await newPage.goto()
await newPage.fill({ fromWarehouse: srcWarehouse.name, toWarehouse: destWarehouse.name, item: item.name, qty: 10 })
await newPage.submit()
await expect(page).toHaveURL(/\/dashboard\/stock\/transfers\/\d+/)
const detail = new StockTransferDetailPage(page)
await detail.expectStatus("Draft")
await detail.dispatch()
await detail.expectStatus("InTransit")
const afterDispatch = await seeder.stockOnHand(item.itemId, srcWarehouse.warehouseId)
// Dispatch consumes the source FIFO layers immediately (Backend/ERPCore/Services/Stock/
// StockService.cs: "Dispatch already consumed the source layers, so this stock has left
// onHand") - inTransit is reported for visibility only, not held back from onHand.
expect(afterDispatch.onHand).toBeCloseTo(before.onHand - 10, 4)
await detail.receive()
await detail.expectStatus("Received")
const destAfter = await seeder.stockOnHand(item.itemId, destWarehouse.warehouseId)
expect(destAfter.onHand).toBeGreaterThanOrEqual(10)
})
test("dispatch fails with insufficient stock and the transfer stays Draft", async ({ page }) => {
const newPage = new StockTransferNewPage(page)
await newPage.goto()
await newPage.fill({
fromWarehouse: srcWarehouse.name,
toWarehouse: destWarehouse.name,
item: item.name,
qty: 999_999,
})
await newPage.submit()
await expect(page).toHaveURL(/\/dashboard\/stock\/transfers\/\d+/)
const detail = new StockTransferDetailPage(page)
await detail.expectStatus("Draft")
await detail.dispatch()
// FifoCostingService rejects with 409 STOCK_NEGATIVE_BLOCKED - the frontend surfaces the
// error and leaves the transfer in Draft rather than advancing it.
await detail.expectStatus("Draft")
await expect(page.getByRole("button", { name: /^dispatch$/i })).toBeVisible()
})
})
+209
View File
@@ -0,0 +1,209 @@
import { APIRequestContext, expect, request } from "@playwright/test"
import { env, AUTH_STORAGE_STATE } from "./env"
/**
* Standalone APIRequestContext for use in `test.beforeAll`, where the test-scoped `request`
* fixture isn't available. Reuses the same storageState the "setup" project produced, so it
* is already authenticated. Caller must `.dispose()` it in `afterAll`.
*/
export async function newApiContext(): Promise<APIRequestContext> {
return request.newContext({ baseURL: env.baseUrl, storageState: AUTH_STORAGE_STATE })
}
// Thin wrapper over the same `/api/v1` surface `Frontend/erp-system/lib/api/*.ts` calls,
// used to seed/verify data directly against the backend so specs don't have to build every
// prerequisite (vendors, POs, templates) by driving the UI. `request` must already carry
// the authenticated session cookie - either via the "setup" project's storageState, or by
// passing a context created after `AuthApi.login`.
const API_BASE = "/api/v1"
export interface Warehouse {
warehouseId: number
code: string
name: string
}
export interface Vendor {
vendorId: number
code: string
name: string
}
export interface Item {
itemId: number
sku: string
name: string
baseUomId: number
}
export interface Uom {
uomId: number
name: string
}
export interface Category {
categoryId: number
name: string
}
/** Suffixes every seeded code/SKU with a run-unique token so parallel/rerun specs never collide. */
export function uniqueSuffix(): string {
return `${Date.now()}${Math.floor(Math.random() * 1000)}`
}
export class ApiSeeder {
constructor(private readonly request: APIRequestContext) {}
private async get<T>(path: string): Promise<T> {
const res = await this.request.get(`${API_BASE}${path}`)
expect(res.ok(), `GET ${path} -> ${res.status()}: ${await res.text()}`).toBeTruthy()
return res.json()
}
private async post<T>(path: string, data: unknown): Promise<T> {
const res = await this.request.post(`${API_BASE}${path}`, { data })
expect(res.ok(), `POST ${path} -> ${res.status()}: ${await res.text()}`).toBeTruthy()
return res.json()
}
// --- reference data (relies on DataSeeder's MAIN/SHOP/PCS/BOX/General Goods seed) -----
async firstWarehouse(): Promise<Warehouse> {
const page = await this.get<{ items: Warehouse[] }>("/warehouses?page=1&pageSize=1")
if (!page.items.length) throw new Error("No warehouses found - expected DataSeeder's MAIN warehouse to exist.")
return page.items[0]
}
async secondWarehouse(): Promise<Warehouse> {
const page = await this.get<{ items: Warehouse[] }>("/warehouses?page=1&pageSize=10")
if (page.items.length < 2) throw new Error("Need at least 2 warehouses (DataSeeder seeds MAIN + SHOP).")
return page.items[1]
}
async firstUom(): Promise<Uom> {
const page = await this.get<{ items: Uom[] }>("/uoms?page=1&pageSize=1")
if (!page.items.length) throw new Error("No UOMs found - expected DataSeeder's PCS uom to exist.")
return page.items[0]
}
async firstCategory(): Promise<Category> {
const page = await this.get<{ items: Category[] }>("/categories?page=1&pageSize=1")
if (!page.items.length) throw new Error("No categories found - expected DataSeeder's General Goods category.")
return page.items[0]
}
// --- writes used to build test fixtures -------------------------------------------
async createVendor(namePrefix = "E2E Vendor"): Promise<Vendor> {
const suffix = uniqueSuffix()
return this.post<Vendor>("/vendors", {
code: `E2E-V-${suffix}`,
name: `${namePrefix} ${suffix}`,
currency: "LKR",
})
}
async createItem(opts: { namePrefix?: string; categoryId?: number; baseUomId?: number } = {}): Promise<Item> {
const suffix = uniqueSuffix()
const categoryId = opts.categoryId ?? (await this.firstCategory()).categoryId
const baseUomId = opts.baseUomId ?? (await this.firstUom()).uomId
return this.post<Item>("/items", {
sku: `E2E-SKU-${suffix}`,
name: `${opts.namePrefix ?? "E2E Item"} ${suffix}`,
categoryId,
baseUomId,
stockNature: "Stocked",
trackingMode: "None",
})
}
/** Direct (no-PO) GRN, confirmed immediately, so the item has on-hand stock to test against. */
async receiveStock(opts: { warehouseId: number; vendorId: number; itemId: number; uomId: number; qty: number; unitCost: number }) {
const grn = await this.post<{ grnId: number }>("/grns", {
vendorId: opts.vendorId,
warehouseId: opts.warehouseId,
lines: [
{
itemId: opts.itemId,
uomId: opts.uomId,
qty: opts.qty,
unitCost: opts.unitCost,
discountPct: 0,
vatPct: 0,
holdStatus: "Available",
},
],
})
await this.post(`/grns/${grn.grnId}/confirm`, {})
return grn
}
/** Direct GRN with the line held for inspection, confirmed - gives the detail page a line with Release/Reject actions. */
async receiveStockOnHold(opts: { warehouseId: number; vendorId: number; itemId: number; uomId: number; qty: number; unitCost: number }) {
const grn = await this.post<{ grnId: number; lines: { grnLineId: number }[] }>("/grns", {
vendorId: opts.vendorId,
warehouseId: opts.warehouseId,
lines: [
{
itemId: opts.itemId,
uomId: opts.uomId,
qty: opts.qty,
unitCost: opts.unitCost,
discountPct: 0,
vatPct: 0,
holdStatus: "OnHold",
},
],
})
await this.post(`/grns/${grn.grnId}/confirm`, {})
return grn
}
async createPurchaseOrder(opts: { vendorId: number; warehouseId: number; itemId: number; uomId: number; qty: number; unitPrice: number }) {
return this.post<{ poId: number; docNo: string }>("/purchase-orders", {
vendorId: opts.vendorId,
lines: [
{
itemId: opts.itemId,
uomId: opts.uomId,
warehouseId: opts.warehouseId,
qty: opts.qty,
unitPrice: opts.unitPrice,
tax: 0,
},
],
})
}
/**
* Minimal single-stage template: one stage that is both entry and terminal, one Stock
* input (the raw material) and one item-bearing output (the finished good) - the
* smallest graph ProductionGraphValidator accepts (Backend/ERPCore/Services/Production/
* ProductionGraphValidator.cs: exactly one terminal, terminal has exactly one item output).
*/
async createSingleStageTemplate(opts: { rawItemId: number; finishedItemId: number; uomId: number }) {
const suffix = uniqueSuffix()
return this.post<{ templateId: number; code: string; name: string }>("/production-templates", {
code: `E2E-TPL-${suffix}`,
name: `E2E Template ${suffix}`,
stages: [
{
key: "stage-1",
name: "Assemble",
estimatedMinutes: 10,
posX: 0,
posY: 0,
fieldDefs: [],
inputs: [{ source: "Stock", itemId: opts.rawItemId, uomId: opts.uomId, qtyPerBatch: 1 }],
outputs: [{ key: "out-1", itemId: opts.finishedItemId, name: "Finished good", uomId: opts.uomId, qtyPerBatch: 1 }],
},
],
edges: [],
annotations: [],
})
}
async stockOnHand(itemId: number, warehouseId: number) {
return this.get<{ onHand: number; available: number }>(`/stock/on-hand?itemId=${itemId}&warehouseId=${warehouseId}`)
}
}
+23
View File
@@ -0,0 +1,23 @@
import path from "node:path"
import dotenv from "dotenv"
dotenv.config({ path: path.resolve(__dirname, "../.env.e2e") })
function required(name: string): string {
const value = process.env[name]
if (!value) throw new Error(`Missing required env var ${name} - copy .env.e2e.example to .env.e2e and fill it in.`)
return value
}
export const env = {
baseUrl: process.env.E2E_BASE_URL ?? "http://localhost:3000",
apiUrl: process.env.E2E_API_URL ?? "http://localhost:5224",
get adminEmail() {
return required("E2E_ADMIN_EMAIL")
},
get adminPassword() {
return required("E2E_ADMIN_PASSWORD")
},
}
export const AUTH_STORAGE_STATE = path.resolve(__dirname, "../.auth/admin.json")
+113
View File
@@ -0,0 +1,113 @@
import { Page, Locator } from "@playwright/test"
/**
* Root cause (confirmed via a repro script capturing `page.on("pageerror")`): every load of
* these pages throws a genuine React hydration error ("Minified React error #418" - text
* content mismatch between server and client render) - it is NOT intermittent. What IS
* unpredictable is its effect: hydration recovery blanks the placeholder text of a random
* subset of that page's Select triggers, but leaves everything else (the sibling <Label>/
* <FieldLabel>, the trigger's role="combobox" attribute, the DOM structure) intact. So a
* reload-until-clean strategy never terminates (confirmed: reloading never once produced a
* "clean" load), and a bare `getByRole("combobox", { name: ... })` is unreliable because the
* accessible name it depends on is exactly what gets blanked.
*
* The fix is to stop depending on that name at all: every Select trigger in this app sits as
* an immediate sibling of a stable, always-intact label element, so `comboboxByLabel()` finds
* the trigger via that label + role="combobox" alone. `retryClick`/`selectOption`/
* `clickToReveal` remain useful as defense-in-depth for ordinary timing races (dialogs
* mounting, popups opening) that are unrelated to this hydration bug.
*/
export function comboboxByLabel(scope: Page | Locator, labelText: string): Locator {
return scope.getByText(labelText, { exact: true }).locator("..").getByRole("combobox").first()
}
/**
* `trigger.click()` gets an explicit, short per-attempt timeout deliberately: without one, a
* momentarily-disabled/not-yet-actionable button (e.g. a trigger that's disabled for one tick
* after navigation before client state settles) lets a SINGLE click() call sit and retry
* internally for the whole remaining test timeout, so this loop never reaches a second attempt
* - confirmed happening on "Cancel run" right after starting a run. A short click timeout lets
* the loop actually cycle through multiple real attempts within the test's time budget.
*/
export async function retryClick(trigger: Locator, verify: () => Promise<void>, attempts = 5) {
let lastErr: unknown
for (let i = 0; i < attempts; i++) {
try {
await trigger.click({ timeout: 3000 })
} catch (e) {
lastErr = e
continue
}
try {
await verify()
return
} catch (e) {
lastErr = e
}
}
throw lastErr
}
/**
* Opens a Select trigger and picks an option by name. Deliberately a single click, not a
* retryClick loop: re-clicking an already-open Select trigger toggles it shut, and if the
* option name is ever wrong the popup's portal can end up overlaying the trigger, making
* Playwright's actionability check for a second click hang indefinitely (confirmed while
* testing this file) instead of failing fast. `comboboxByLabel` already makes the trigger
* lookup itself reliable, so a plain click here is both simpler and safer.
*/
export async function selectOption(page: Page, trigger: Locator, optionName: string | RegExp) {
// .first() covers callers that intentionally pass a name matching multiple options (e.g. "pick
// any scrap reason") - for the common single-match case it's a no-op.
const option = page.getByRole("option", { name: optionName }).first()
await trigger.click()
await option.click()
}
/** Clicks a trigger that's expected to reveal `target` (a dialog, a newly-mounted control), retrying the click. */
export async function clickToReveal(trigger: Locator, target: Locator) {
await retryClick(trigger, () => target.waitFor({ state: "visible", timeout: 1500 }))
}
/**
* Same as `clickToReveal`, but escalates to a full `page.reload()` between rounds when the
* trigger itself never becomes actionable within a round - not just "the popup didn't open"
* but "the trigger stayed disabled" or "never rendered at all". Confirmed on the production run
* detail page's "Cancel run"/"Return leftover" buttons (RunActions.tsx, gated on
* `run.status === "InProgress"`): occasionally that gate/enabled-state renders wrong for the
* rest of a page's life - the same class of one-shot render corruption as the hydration bug
* documented above, just hitting a component's disabled/mounted state instead of a Select's
* placeholder text. A reload gets a fresh render attempt; `trigger`/`target` are re-queried
* live each round since Playwright locators aren't tied to a specific DOM snapshot.
*/
export async function clickToRevealWithReload(page: Page, trigger: Locator, target: Locator, reloadAttempts = 3) {
let lastErr: unknown
for (let i = 0; i < reloadAttempts; i++) {
try {
await clickToReveal(trigger, target)
return
} catch (e) {
lastErr = e
if (i < reloadAttempts - 1) await page.reload()
}
}
throw lastErr
}
/**
* Every stage-action / document-action button in this app fires an async POST and only updates
* the DOM once the response comes back (`onActed()`/`onSuccess()` refetch pattern) - Playwright's
* `.click()` resolves as soon as the click event dispatches, NOT once that request settles. A
* test that clicks "Approve & receive" and immediately reads stock through a separate API call
* can race ahead of the backend commit and observe pre-action state (confirmed: production run
* stock checks reading 0 immediately after a click the UI later shows as successful). Wrapping
* the click in `page.waitForResponse` for the specific endpoint makes the helper actually wait
* for the request that matters, not just the DOM event.
*/
export async function submitAndWait(page: Page, trigger: Locator, urlIncludes: string, method: "POST" | "PUT" = "POST") {
const [response] = await Promise.all([
page.waitForResponse((res) => res.url().includes(urlIncludes) && res.request().method() === method),
trigger.click(),
])
return response
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"moduleResolution": "node",
"lib": ["ES2022", "DOM"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"types": ["node", "@playwright/test"]
},
"include": ["**/*.ts"]
}