Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 722b1e78ed | |||
| 26cf2a146a | |||
| 0750773f94 | |||
| 38c7545413 | |||
| 0d60aeef64 |
@@ -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
|
||||
|
||||
-6696
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");
|
||||
}
|
||||
}
|
||||
-36
@@ -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");
|
||||
}
|
||||
}
|
||||
+400
-3
@@ -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");
|
||||
+255
-4
@@ -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");
|
||||
|
||||
+399
-2
@@ -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");
|
||||
@@ -106,6 +106,10 @@ builder.Services.AddScoped<IGrnService, GrnService>();
|
||||
|
||||
// Sales (Phase 1)
|
||||
builder.Services.AddScoped<ISalesPricingService, SalesPricingService>();
|
||||
builder.Services.AddScoped<ISalesDomainService, SalesDomainService>();
|
||||
builder.Services.AddScoped<ISalesPostingService, SalesPostingService>();
|
||||
builder.Services.AddScoped<ISalesDocumentWorkflowService, SalesDocumentWorkflowService>();
|
||||
builder.Services.AddScoped<ISalesMappingService, SalesMappingService>();
|
||||
builder.Services.AddScoped<ISalesInvoiceService, SalesInvoiceService>();
|
||||
builder.Services.AddScoped<ISalesSlipService, SalesSlipService>();
|
||||
builder.Services.AddScoped<IBundleSaleService, BundleSaleService>();
|
||||
|
||||
@@ -22,8 +22,8 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
private readonly IRepository<Uom> _uoms;
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly IRepository<User> _users;
|
||||
private readonly ISalesPricingService _pricing;
|
||||
private readonly IFifoCostingService _fifo;
|
||||
private readonly ISalesDomainService _sales;
|
||||
private readonly ISalesPostingService _posting;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly INumberSequenceService _numbers;
|
||||
private readonly IUnitOfWork _uow;
|
||||
@@ -36,8 +36,8 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
IRepository<Uom> uoms,
|
||||
IRepository<Warehouse> warehouses,
|
||||
IRepository<User> users,
|
||||
ISalesPricingService pricing,
|
||||
IFifoCostingService fifo,
|
||||
ISalesDomainService sales,
|
||||
ISalesPostingService posting,
|
||||
ICurrentUser currentUser,
|
||||
INumberSequenceService numbers,
|
||||
IUnitOfWork uow)
|
||||
@@ -49,8 +49,8 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
_uoms = uoms;
|
||||
_warehouses = warehouses;
|
||||
_users = users;
|
||||
_pricing = pricing;
|
||||
_fifo = fifo;
|
||||
_sales = sales;
|
||||
_posting = posting;
|
||||
_currentUser = currentUser;
|
||||
_numbers = numbers;
|
||||
_uow = uow;
|
||||
@@ -106,27 +106,12 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
return bundle is null ? null : Map(bundle);
|
||||
}
|
||||
|
||||
public async Task<BundleSalePostingCheckDto> CheckPostingAsync(int bundleSaleId, CancellationToken ct = default)
|
||||
{
|
||||
var bundle = await _bundles.Query().AsNoTracking().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct)
|
||||
?? throw new NotFoundException($"Bundle sale {bundleSaleId} was not found.");
|
||||
if (bundle.Status != BundleSaleStatus.Draft)
|
||||
return new BundleSalePostingCheckDto(bundle.BundleSaleId, bundle.BundleNo, bundle.Status, false, Array.Empty<BundleSalePostingIssueDto>());
|
||||
|
||||
var issues = new List<BundleSalePostingIssueDto>();
|
||||
foreach (var line in bundle.Lines.Where(l => l.IncludeInBundle))
|
||||
{
|
||||
var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct);
|
||||
if (available >= line.Qty) continue;
|
||||
var item = await _items.Query().AsNoTracking().Where(x => x.ItemId == line.ItemId).Select(x => new { x.Sku, x.Name }).FirstAsync(ct);
|
||||
issues.Add(new BundleSalePostingIssueDto(line.BundleSaleLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId, line.Qty, available, line.Qty - available));
|
||||
}
|
||||
return new BundleSalePostingCheckDto(bundle.BundleSaleId, bundle.BundleNo, bundle.Status, issues.Count == 0, issues);
|
||||
}
|
||||
public Task<BundleSalePostingCheckDto> CheckPostingAsync(int bundleSaleId, CancellationToken ct = default)
|
||||
=> _posting.CheckBundleAsync(bundleSaleId, ct);
|
||||
|
||||
public async Task<BundleSaleDto> CreateAsync(CreateBundleSaleRequest request, CancellationToken ct = default)
|
||||
{
|
||||
await ValidateReferencesAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, request.BundleSaleTemplateId, ct);
|
||||
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, true, ct);
|
||||
var template = await _templates.Query().AsNoTracking().Include(x => x.Lines)
|
||||
.FirstAsync(x => x.BundleSaleTemplateId == request.BundleSaleTemplateId, ct);
|
||||
var bundle = new BundleSale
|
||||
@@ -143,7 +128,7 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
bundle.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
|
||||
bundle.Lines = await BuildLinesAsync(template, request.Lines, ct);
|
||||
bundle.Lines = await BuildLinesAsync(template, request.WarehouseId, request.Lines, ct);
|
||||
Recalculate(bundle, request.BundlePrice);
|
||||
bundle.BundleCode = $"{bundle.BundleNo}-B";
|
||||
await _bundles.AddAsync(bundle, ct);
|
||||
@@ -159,7 +144,7 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
if (bundle.Status != BundleSaleStatus.Draft)
|
||||
throw new ConflictException($"Bundle sale {bundleSaleId} is {bundle.Status} and cannot be edited.");
|
||||
|
||||
await ValidateReferencesAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, request.BundleSaleTemplateId, ct);
|
||||
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, true, ct);
|
||||
var template = await _templates.Query().AsNoTracking().Include(x => x.Lines)
|
||||
.FirstAsync(x => x.BundleSaleTemplateId == request.BundleSaleTemplateId, ct);
|
||||
bundle.CustomerId = request.CustomerId;
|
||||
@@ -169,7 +154,7 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
bundle.BundleName = request.BundleName;
|
||||
bundle.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
|
||||
bundle.Lines.Clear();
|
||||
foreach (var line in await BuildLinesAsync(template, request.Lines, ct)) bundle.Lines.Add(line);
|
||||
foreach (var line in await BuildLinesAsync(template, request.WarehouseId, request.Lines, ct)) bundle.Lines.Add(line);
|
||||
Recalculate(bundle, request.BundlePrice);
|
||||
bundle.UpdatedAt = DateTime.UtcNow;
|
||||
bundle.ConcurrencyStamp++;
|
||||
@@ -178,32 +163,12 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
}
|
||||
|
||||
public async Task<BundleSaleDto> PostAsync(int bundleSaleId, CancellationToken ct = default)
|
||||
{
|
||||
var bundle = await _bundles.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct)
|
||||
?? throw new NotFoundException($"Bundle sale {bundleSaleId} was not found.");
|
||||
if (bundle.Status != BundleSaleStatus.Draft)
|
||||
throw new ConflictException($"Bundle sale {bundleSaleId} is {bundle.Status} and cannot be posted.");
|
||||
|
||||
var check = await CheckPostingAsync(bundleSaleId, ct);
|
||||
if (!check.CanPost)
|
||||
throw new ConflictException("Resolve stock shortages before posting this bundle sale.");
|
||||
|
||||
var posted = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
foreach (var line in bundle.Lines.Where(l => l.IncludeInBundle))
|
||||
{
|
||||
var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, null, line.Qty, token);
|
||||
var cost = consumed.Count == 0 ? 0m : consumed.Sum(x => x.Qty * x.UnitCost) / consumed.Sum(x => x.Qty);
|
||||
await _fifo.PostLedgerAsync(line.ItemId, line.WarehouseId, null, null, null, _currentUser.AuditUserId,
|
||||
Direction.Out, line.Qty, cost, 0m, nameof(BundleSale), bundle.BundleSaleId, DateTime.UtcNow, token);
|
||||
}
|
||||
bundle.Status = BundleSaleStatus.Posted;
|
||||
bundle.UpdatedAt = DateTime.UtcNow;
|
||||
bundle.ConcurrencyStamp++;
|
||||
return bundle;
|
||||
}, ct);
|
||||
return Map(posted);
|
||||
}
|
||||
await _posting.PostBundleAsync(bundleSaleId, ct);
|
||||
var bundle = await _bundles.Query().AsNoTracking().Include(x => x.Lines)
|
||||
.FirstAsync(x => x.BundleSaleId == bundleSaleId, ct);
|
||||
return Map(bundle);
|
||||
}
|
||||
|
||||
public async Task<BundleSaleDto> CancelAsync(int bundleSaleId, CancellationToken ct = default)
|
||||
{
|
||||
@@ -218,19 +183,8 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
return Map(bundle);
|
||||
}
|
||||
|
||||
private async Task ValidateReferencesAsync(int customerId, int warehouseId, int cashierUserId, int templateId, CancellationToken ct)
|
||||
{
|
||||
if (!await _customers.Query().AnyAsync(x => x.CustomerId == customerId, ct))
|
||||
throw new NotFoundException($"Customer {customerId} was not found.");
|
||||
if (!await _warehouses.Query().AnyAsync(x => x.WarehouseId == warehouseId, ct))
|
||||
throw new NotFoundException($"Warehouse {warehouseId} was not found.");
|
||||
if (!await _users.Query().AnyAsync(x => x.UserId == cashierUserId, ct))
|
||||
throw new NotFoundException($"User {cashierUserId} was not found.");
|
||||
if (!await _templates.Query().AnyAsync(x => x.BundleSaleTemplateId == templateId, ct))
|
||||
throw new NotFoundException($"Bundle template {templateId} was not found.");
|
||||
}
|
||||
|
||||
private async Task<List<BundleSaleLine>> BuildLinesAsync(BundleSaleTemplate template, IReadOnlyList<CreateBundleSaleTemplateLineRequest> requestLines, CancellationToken ct)
|
||||
private async Task<List<BundleSaleLine>> BuildLinesAsync(
|
||||
BundleSaleTemplate template, int warehouseId, IReadOnlyList<CreateBundleSaleTemplateLineRequest> requestLines, CancellationToken ct)
|
||||
{
|
||||
var lines = new List<BundleSaleLine>();
|
||||
var sourceLines = requestLines.Count > 0
|
||||
@@ -248,8 +202,15 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
|
||||
foreach (var r in sourceLines)
|
||||
{
|
||||
if (r.Qty <= 0)
|
||||
throw new DomainException(ErrorCodes.Validation, "Bundle line quantity must be greater than zero.", 422);
|
||||
if (r.WarehouseId != warehouseId)
|
||||
throw new DomainException(ErrorCodes.Validation,
|
||||
$"Bundle line warehouse {r.WarehouseId} must match header warehouse {warehouseId}.", 422);
|
||||
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
|
||||
var resolved = await _pricing.ResolveAsync(r.ItemId, r.WarehouseId, r.UnitPrice, true, ct);
|
||||
await _sales.ValidateSalesLineAsync(warehouseId, r.ItemId, r.UomId, r.WarehouseId, r.Qty, 0m, null, ct);
|
||||
var resolved = await _sales.ResolveLinePriceAsync(r.ItemId, r.WarehouseId, r.UnitPrice, true, ct);
|
||||
var calc = _sales.ComputeLine(r.Qty, 0m, resolved.UnitPrice, SalesDiscountMode.Amount, 0m, 0m, 0m, 0m, false);
|
||||
lines.Add(new BundleSaleLine
|
||||
{
|
||||
ItemId = r.ItemId,
|
||||
@@ -258,7 +219,7 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
UomId = r.UomId,
|
||||
WarehouseId = r.WarehouseId,
|
||||
UnitPrice = resolved.UnitPrice,
|
||||
LineTotal = r.Qty * resolved.UnitPrice,
|
||||
LineTotal = calc.LineTotal,
|
||||
IncludeInBundle = r.IncludeInBundle,
|
||||
IsComponent = true,
|
||||
ParentLineId = null
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
public interface ISalesDocumentWorkflowService
|
||||
{
|
||||
Task<SalesInvoice> LoadEditableInvoiceAsync(int salesInvoiceId, uint expectedRowVersion, CancellationToken ct = default);
|
||||
Task<SalesSlip> LoadEditableSlipAsync(int salesSlipId, uint expectedRowVersion, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Sales;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
public interface ISalesDomainService
|
||||
{
|
||||
Task ValidateSalesHeaderAsync(
|
||||
int customerId,
|
||||
int warehouseId,
|
||||
int? cashierUserId,
|
||||
bool requireCashierUser,
|
||||
CancellationToken ct = default);
|
||||
|
||||
Task ValidateSalesLineAsync(
|
||||
int headerWarehouseId,
|
||||
int lineItemId,
|
||||
int lineUomId,
|
||||
int lineWarehouseId,
|
||||
decimal qty,
|
||||
decimal freeQty,
|
||||
int? parentLineId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
Task<SalesPriceResolution> ResolveLinePriceAsync(
|
||||
int itemId,
|
||||
int warehouseId,
|
||||
decimal? requestedUnitPrice,
|
||||
bool allowManualOverride,
|
||||
CancellationToken ct = default);
|
||||
|
||||
SalesLineComputation ComputeLine(
|
||||
decimal qty,
|
||||
decimal freeQty,
|
||||
decimal unitPrice,
|
||||
SalesDiscountMode discountMode,
|
||||
decimal discountPct,
|
||||
decimal discountValue,
|
||||
decimal discountAmount,
|
||||
decimal taxPct,
|
||||
bool isFreeIssue);
|
||||
|
||||
Task<bool> IsStockedItemAsync(int itemId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public sealed record SalesLineComputation(
|
||||
decimal Gross,
|
||||
decimal DiscountTotal,
|
||||
decimal NetUnitPrice,
|
||||
decimal LineTotal,
|
||||
decimal TaxAmount);
|
||||
@@ -0,0 +1,12 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Dtos.Sales;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
public interface ISalesMappingService
|
||||
{
|
||||
SalesInvoiceTotalsDto MapInvoiceTotals(SalesInvoice invoice);
|
||||
SalesSlipTotalsDto MapSlipTotals(SalesSlip slip);
|
||||
SalesInvoiceDto MapInvoice(SalesInvoice invoice);
|
||||
SalesSlipDto MapSlip(SalesSlip slip);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using ERPCore.Dtos.Sales;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
public interface ISalesPostingService
|
||||
{
|
||||
Task<SalesInvoicePostingCheckDto> CheckInvoiceAsync(int salesInvoiceId, CancellationToken ct = default);
|
||||
Task<SalesSlipPostingCheckDto> CheckSlipAsync(int salesSlipId, CancellationToken ct = default);
|
||||
Task<BundleSalePostingCheckDto> CheckBundleAsync(int bundleSaleId, CancellationToken ct = default);
|
||||
|
||||
Task PostInvoiceAsync(int salesInvoiceId, CancellationToken ct = default);
|
||||
Task PostSlipAsync(int salesSlipId, CancellationToken ct = default);
|
||||
Task PostBundleAsync(int bundleSaleId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
public sealed class SalesDocumentWorkflowService : ISalesDocumentWorkflowService
|
||||
{
|
||||
private readonly IRepository<SalesInvoice> _invoices;
|
||||
private readonly IRepository<SalesSlip> _slips;
|
||||
|
||||
public SalesDocumentWorkflowService(IRepository<SalesInvoice> invoices, IRepository<SalesSlip> slips)
|
||||
{
|
||||
_invoices = invoices;
|
||||
_slips = slips;
|
||||
}
|
||||
|
||||
public async Task<SalesInvoice> LoadEditableInvoiceAsync(int salesInvoiceId, uint expectedRowVersion, CancellationToken ct = default)
|
||||
{
|
||||
var invoice = await _invoices.Query().Include(x => x.Lines)
|
||||
.FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct)
|
||||
?? throw new NotFoundException($"Sales invoice {salesInvoiceId} was not found.");
|
||||
if (invoice.RowVersion != expectedRowVersion)
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The sales invoice was modified by another request.", 412);
|
||||
if (invoice.Status != SalesInvoiceStatus.Draft)
|
||||
throw new ConflictException($"Sales invoice {salesInvoiceId} is {invoice.Status} and cannot be edited.");
|
||||
return invoice;
|
||||
}
|
||||
|
||||
public async Task<SalesSlip> LoadEditableSlipAsync(int salesSlipId, uint expectedRowVersion, CancellationToken ct = default)
|
||||
{
|
||||
var slip = await _slips.Query().Include(x => x.Lines)
|
||||
.FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct)
|
||||
?? throw new NotFoundException($"Sales slip {salesSlipId} was not found.");
|
||||
if (slip.RowVersion != expectedRowVersion)
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The sales slip was modified by another request.", 412);
|
||||
if (slip.Status != SalesSlipStatus.Draft)
|
||||
throw new ConflictException($"Sales slip {salesSlipId} is {slip.Status} and cannot be edited.");
|
||||
return slip;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
public sealed class SalesDomainService : ISalesDomainService
|
||||
{
|
||||
private readonly IRepository<Customer> _customers;
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly IRepository<User> _users;
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IRepository<Uom> _uoms;
|
||||
private readonly ISalesPricingService _pricing;
|
||||
|
||||
public SalesDomainService(
|
||||
IRepository<Customer> customers,
|
||||
IRepository<Warehouse> warehouses,
|
||||
IRepository<User> users,
|
||||
IRepository<Item> items,
|
||||
IRepository<Uom> uoms,
|
||||
ISalesPricingService pricing)
|
||||
{
|
||||
_customers = customers;
|
||||
_warehouses = warehouses;
|
||||
_users = users;
|
||||
_items = items;
|
||||
_uoms = uoms;
|
||||
_pricing = pricing;
|
||||
}
|
||||
|
||||
public async Task ValidateSalesHeaderAsync(int customerId, int warehouseId, int? cashierUserId, bool requireCashierUser, CancellationToken ct = default)
|
||||
{
|
||||
if (!await _customers.Query().AnyAsync(x => x.CustomerId == customerId, ct))
|
||||
throw new NotFoundException($"Customer {customerId} was not found.");
|
||||
if (!await _warehouses.Query().AnyAsync(x => x.WarehouseId == warehouseId, ct))
|
||||
throw new NotFoundException($"Warehouse {warehouseId} was not found.");
|
||||
if (requireCashierUser)
|
||||
{
|
||||
if (cashierUserId is null)
|
||||
throw new DomainException(ErrorCodes.Validation, "Cashier user is required.", 422);
|
||||
if (!await _users.Query().AnyAsync(x => x.UserId == cashierUserId, ct))
|
||||
throw new NotFoundException($"User {cashierUserId} was not found.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ValidateSalesLineAsync(
|
||||
int headerWarehouseId, int lineItemId, int lineUomId, int lineWarehouseId, decimal qty, decimal freeQty, int? parentLineId, CancellationToken ct = default)
|
||||
{
|
||||
if (qty <= 0)
|
||||
throw new DomainException(ErrorCodes.Validation, "Sales line quantity must be greater than zero.", 422);
|
||||
if (freeQty < 0)
|
||||
throw new DomainException(ErrorCodes.Validation, "Sales free quantity cannot be negative.", 422);
|
||||
if (parentLineId is not null && parentLineId <= 0)
|
||||
throw new DomainException(ErrorCodes.Validation, "Parent line id must be positive when supplied.", 422);
|
||||
if (lineWarehouseId != headerWarehouseId)
|
||||
throw new DomainException(ErrorCodes.Validation, $"Sales line warehouse {lineWarehouseId} must match header warehouse {headerWarehouseId}.", 422);
|
||||
if (!await _items.Query().AnyAsync(x => x.ItemId == lineItemId, ct))
|
||||
throw new NotFoundException($"Item {lineItemId} was not found.");
|
||||
if (!await _uoms.Query().AnyAsync(x => x.UomId == lineUomId, ct))
|
||||
throw new NotFoundException($"UOM {lineUomId} was not found.");
|
||||
if (!await _warehouses.Query().AnyAsync(x => x.WarehouseId == lineWarehouseId, ct))
|
||||
throw new NotFoundException($"Warehouse {lineWarehouseId} was not found.");
|
||||
}
|
||||
|
||||
public Task<SalesPriceResolution> ResolveLinePriceAsync(int itemId, int warehouseId, decimal? requestedUnitPrice, bool allowManualOverride, CancellationToken ct = default)
|
||||
=> _pricing.ResolveAsync(itemId, warehouseId, requestedUnitPrice, allowManualOverride, ct);
|
||||
|
||||
public SalesLineComputation ComputeLine(
|
||||
decimal qty,
|
||||
decimal freeQty,
|
||||
decimal unitPrice,
|
||||
SalesDiscountMode discountMode,
|
||||
decimal discountPct,
|
||||
decimal discountValue,
|
||||
decimal discountAmount,
|
||||
decimal taxPct,
|
||||
bool isFreeIssue)
|
||||
{
|
||||
var gross = qty * unitPrice;
|
||||
var discountTotal = isFreeIssue
|
||||
? 0m
|
||||
: CalculateDiscount(gross, discountMode, discountPct, discountValue, discountAmount);
|
||||
var netUnit = qty > 0 ? (gross - discountTotal) / qty : 0m;
|
||||
var lineTotal = gross - discountTotal;
|
||||
var taxAmount = lineTotal * (taxPct / 100m);
|
||||
return new SalesLineComputation(gross, discountTotal, netUnit, lineTotal, taxAmount);
|
||||
}
|
||||
|
||||
public async Task<bool> IsStockedItemAsync(int itemId, CancellationToken ct = default)
|
||||
=> await _items.Query().AsNoTracking()
|
||||
.Where(x => x.ItemId == itemId)
|
||||
.Select(x => x.StockNature == StockNature.Stocked)
|
||||
.FirstAsync(ct);
|
||||
|
||||
private static decimal CalculateDiscount(decimal gross, SalesDiscountMode mode, decimal discountPct, decimal discountValue, decimal legacyDiscountAmount)
|
||||
{
|
||||
var computed = mode == SalesDiscountMode.Amount
|
||||
? discountValue
|
||||
: gross * (discountPct / 100m);
|
||||
|
||||
if (computed <= 0m && legacyDiscountAmount > 0m)
|
||||
computed = legacyDiscountAmount;
|
||||
|
||||
return Math.Min(gross, Math.Max(0m, computed));
|
||||
}
|
||||
}
|
||||
@@ -21,15 +21,18 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IRepository<Uom> _uoms;
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly ISalesPricingService _pricing;
|
||||
private readonly IFifoCostingService _fifo;
|
||||
private readonly ISalesDomainService _sales;
|
||||
private readonly ISalesPostingService _posting;
|
||||
private readonly ISalesMappingService _mapping;
|
||||
private readonly ISalesDocumentWorkflowService _workflow;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly INumberSequenceService _numbers;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public SalesInvoiceService(
|
||||
IRepository<SalesInvoice> invoices, IRepository<Customer> customers, IRepository<Item> items,
|
||||
IRepository<Uom> uoms, IRepository<Warehouse> warehouses, ISalesPricingService pricing, IFifoCostingService fifo,
|
||||
IRepository<Uom> uoms, IRepository<Warehouse> warehouses, ISalesDomainService sales, ISalesPostingService posting, ISalesMappingService mapping,
|
||||
ISalesDocumentWorkflowService workflow,
|
||||
ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow)
|
||||
{
|
||||
_invoices = invoices;
|
||||
@@ -37,8 +40,10 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
_items = items;
|
||||
_uoms = uoms;
|
||||
_warehouses = warehouses;
|
||||
_pricing = pricing;
|
||||
_fifo = fifo;
|
||||
_sales = sales;
|
||||
_posting = posting;
|
||||
_mapping = mapping;
|
||||
_workflow = workflow;
|
||||
_currentUser = currentUser;
|
||||
_numbers = numbers;
|
||||
_uow = uow;
|
||||
@@ -66,47 +71,15 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
{
|
||||
var invoice = await _invoices.Query().AsNoTracking().Include(x => x.Lines)
|
||||
.FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct);
|
||||
return invoice is null ? null : new ETagged<SalesInvoiceDto>(Map(invoice), invoice.RowVersion);
|
||||
return invoice is null ? null : new ETagged<SalesInvoiceDto>(_mapping.MapInvoice(invoice), invoice.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<SalesInvoicePostingCheckDto> CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default)
|
||||
{
|
||||
var invoice = await _invoices.Query().AsNoTracking().Include(x => x.Lines)
|
||||
.FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct)
|
||||
?? throw new NotFoundException($"Sales invoice {salesInvoiceId} was not found.");
|
||||
|
||||
if (invoice.Status != SalesInvoiceStatus.Draft)
|
||||
return new SalesInvoicePostingCheckDto(invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.Status, false, Array.Empty<SalesInvoicePostingIssueDto>());
|
||||
|
||||
var issues = new List<SalesInvoicePostingIssueDto>();
|
||||
foreach (var line in invoice.Lines)
|
||||
{
|
||||
var requestedQty = line.Qty + line.FreeQty;
|
||||
var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct);
|
||||
if (available >= requestedQty) continue;
|
||||
|
||||
var item = await _items.Query().AsNoTracking()
|
||||
.Where(x => x.ItemId == line.ItemId)
|
||||
.Select(x => new { x.Sku, x.Name })
|
||||
.FirstAsync(ct);
|
||||
issues.Add(new SalesInvoicePostingIssueDto(
|
||||
line.SalesInvoiceLineId,
|
||||
line.ItemId,
|
||||
item.Sku,
|
||||
item.Name,
|
||||
line.WarehouseId,
|
||||
requestedQty,
|
||||
available,
|
||||
requestedQty - available,
|
||||
line.IsFreeIssue));
|
||||
}
|
||||
|
||||
return new SalesInvoicePostingCheckDto(invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.Status, issues.Count == 0, issues);
|
||||
}
|
||||
public Task<SalesInvoicePostingCheckDto> CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default)
|
||||
=> _posting.CheckInvoiceAsync(salesInvoiceId, ct);
|
||||
|
||||
public async Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default)
|
||||
{
|
||||
await ValidateReferencesAsync(request.CustomerId, request.WarehouseId, request.Lines, ct);
|
||||
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, null, false, ct);
|
||||
var invoice = new SalesInvoice
|
||||
{
|
||||
InvoiceNo = await _numbers.NextAsync(DocumentTypes.SalesInvoice, ct),
|
||||
@@ -120,62 +93,39 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
};
|
||||
invoice.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
|
||||
invoice.CustomerSnapshotTaxNo = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.TaxRegistrationNo).FirstAsync(ct);
|
||||
invoice.Lines = await BuildLinesAsync(request.Lines, ct);
|
||||
invoice.Lines = await BuildLinesAsync(request.WarehouseId, request.Lines, ct);
|
||||
Recalculate(invoice);
|
||||
|
||||
await _invoices.AddAsync(invoice, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
return new ETagged<SalesInvoiceDto>(Map(invoice), invoice.RowVersion);
|
||||
return new ETagged<SalesInvoiceDto>(_mapping.MapInvoice(invoice), invoice.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<ETagged<SalesInvoiceDto>> UpdateAsync(int salesInvoiceId, UpdateSalesInvoiceRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
||||
{
|
||||
var invoice = await _invoices.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct)
|
||||
?? throw new NotFoundException($"Sales invoice {salesInvoiceId} was not found.");
|
||||
if (invoice.RowVersion != expectedRowVersion)
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The sales invoice was modified by another request.", 412);
|
||||
if (invoice.Status != SalesInvoiceStatus.Draft)
|
||||
throw new ConflictException($"Sales invoice {salesInvoiceId} is {invoice.Status} and cannot be edited.");
|
||||
var invoice = await _workflow.LoadEditableInvoiceAsync(salesInvoiceId, expectedRowVersion, ct);
|
||||
|
||||
await ValidateReferencesAsync(request.CustomerId, request.WarehouseId, request.Lines, ct);
|
||||
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, null, false, ct);
|
||||
invoice.CustomerId = request.CustomerId;
|
||||
invoice.WarehouseId = request.WarehouseId;
|
||||
invoice.InvoiceType = request.InvoiceType;
|
||||
invoice.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
|
||||
invoice.CustomerSnapshotTaxNo = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.TaxRegistrationNo).FirstAsync(ct);
|
||||
invoice.Lines.Clear();
|
||||
foreach (var line in await BuildLinesAsync(request.Lines, ct)) invoice.Lines.Add(line);
|
||||
foreach (var line in await BuildLinesAsync(request.WarehouseId, request.Lines, ct)) invoice.Lines.Add(line);
|
||||
Recalculate(invoice);
|
||||
invoice.UpdatedAt = DateTime.UtcNow;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
return new ETagged<SalesInvoiceDto>(Map(invoice), invoice.RowVersion);
|
||||
return new ETagged<SalesInvoiceDto>(_mapping.MapInvoice(invoice), invoice.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<SalesInvoiceDto> PostAsync(int salesInvoiceId, CancellationToken ct = default)
|
||||
{
|
||||
var invoice = await _invoices.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct)
|
||||
?? throw new NotFoundException($"Sales invoice {salesInvoiceId} was not found.");
|
||||
if (invoice.Status != SalesInvoiceStatus.Draft)
|
||||
throw new ConflictException($"Sales invoice {salesInvoiceId} is {invoice.Status} and cannot be posted.");
|
||||
|
||||
var posted = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
foreach (var line in invoice.Lines)
|
||||
{
|
||||
if (line.Qty <= 0) continue;
|
||||
var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, null, line.Qty + line.FreeQty, token);
|
||||
var cost = consumed.Count == 0 ? 0m : consumed.Sum(x => x.Qty * x.UnitCost) / consumed.Sum(x => x.Qty);
|
||||
await _fifo.PostLedgerAsync(line.ItemId, line.WarehouseId, null, null, null, _currentUser.AuditUserId,
|
||||
Direction.Out, line.Qty + line.FreeQty, cost, 0m, nameof(SalesInvoice), invoice.SalesInvoiceId, DateTime.UtcNow, token);
|
||||
}
|
||||
|
||||
invoice.Status = SalesInvoiceStatus.Posted;
|
||||
invoice.UpdatedAt = DateTime.UtcNow;
|
||||
return invoice;
|
||||
}, ct);
|
||||
|
||||
return Map(posted);
|
||||
}
|
||||
await _posting.PostInvoiceAsync(salesInvoiceId, ct);
|
||||
var invoice = await _invoices.Query().AsNoTracking().Include(x => x.Lines)
|
||||
.FirstAsync(x => x.SalesInvoiceId == salesInvoiceId, ct);
|
||||
return _mapping.MapInvoice(invoice);
|
||||
}
|
||||
|
||||
public async Task<SalesInvoiceDto> CancelAsync(int salesInvoiceId, CancellationToken ct = default)
|
||||
{
|
||||
@@ -186,43 +136,21 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
invoice.Status = SalesInvoiceStatus.Cancelled;
|
||||
invoice.UpdatedAt = DateTime.UtcNow;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
return Map(invoice);
|
||||
return _mapping.MapInvoice(invoice);
|
||||
}
|
||||
|
||||
private async Task ValidateReferencesAsync(int customerId, int warehouseId, List<CreateSalesInvoiceLineRequest> lines, CancellationToken ct)
|
||||
{
|
||||
if (!await _customers.Query().AnyAsync(x => x.CustomerId == customerId, ct))
|
||||
throw new NotFoundException($"Customer {customerId} was not found.");
|
||||
if (!await _warehouses.Query().AnyAsync(x => x.WarehouseId == warehouseId, ct))
|
||||
throw new NotFoundException($"Warehouse {warehouseId} was not found.");
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (!await _items.Query().AnyAsync(x => x.ItemId == line.ItemId, ct))
|
||||
throw new NotFoundException($"Item {line.ItemId} was not found.");
|
||||
if (!await _uoms.Query().AnyAsync(x => x.UomId == line.UomId, ct))
|
||||
throw new NotFoundException($"UOM {line.UomId} was not found.");
|
||||
if (!await _warehouses.Query().AnyAsync(x => x.WarehouseId == line.WarehouseId, ct))
|
||||
throw new NotFoundException($"Warehouse {line.WarehouseId} was not found.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<SalesInvoiceLine>> BuildLinesAsync(List<CreateSalesInvoiceLineRequest> requests, CancellationToken ct)
|
||||
private async Task<List<SalesInvoiceLine>> BuildLinesAsync(int headerWarehouseId, List<CreateSalesInvoiceLineRequest> requests, CancellationToken ct)
|
||||
{
|
||||
var lines = new List<SalesInvoiceLine>();
|
||||
foreach (var r in requests)
|
||||
{
|
||||
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
|
||||
var resolved = await _pricing.ResolveAsync(r.ItemId, r.WarehouseId, r.UnitPrice, r.AllowManualPriceOverride, ct);
|
||||
await _sales.ValidateSalesLineAsync(headerWarehouseId, r.ItemId, r.UomId, r.WarehouseId, r.Qty, r.FreeQty, r.ParentLineId, ct);
|
||||
var resolved = await _sales.ResolveLinePriceAsync(r.ItemId, r.WarehouseId, r.UnitPrice, r.AllowManualPriceOverride, ct);
|
||||
var unitPrice = resolved.UnitPrice;
|
||||
var priceSource = resolved.PriceSource;
|
||||
|
||||
var gross = r.Qty * unitPrice;
|
||||
var discountTotal = r.IsFreeIssue
|
||||
? 0m
|
||||
: CalculateDiscount(gross, r.DiscountMode, r.DiscountPct, r.DiscountValue, r.DiscountAmount);
|
||||
var netUnit = r.Qty > 0 ? (gross - discountTotal) / r.Qty : 0m;
|
||||
var lineTotal = gross - discountTotal;
|
||||
var taxAmount = lineTotal * (r.TaxPct / 100m);
|
||||
var calc = _sales.ComputeLine(r.Qty, r.FreeQty, unitPrice, r.DiscountMode, r.DiscountPct, r.DiscountValue, r.DiscountAmount, r.TaxPct, r.IsFreeIssue);
|
||||
|
||||
lines.Add(new SalesInvoiceLine
|
||||
{
|
||||
@@ -236,12 +164,12 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
BaseCost = unitPrice,
|
||||
PriceSource = priceSource,
|
||||
DiscountPct = r.DiscountPct,
|
||||
DiscountAmount = discountTotal,
|
||||
DiscountAmount = calc.DiscountTotal,
|
||||
DiscountMode = r.DiscountMode,
|
||||
NetUnitPrice = netUnit,
|
||||
LineTotal = lineTotal,
|
||||
NetUnitPrice = calc.NetUnitPrice,
|
||||
LineTotal = calc.LineTotal,
|
||||
TaxPct = r.TaxPct,
|
||||
TaxAmount = taxAmount,
|
||||
TaxAmount = calc.TaxAmount,
|
||||
IsFreeIssue = r.IsFreeIssue,
|
||||
ParentLineId = r.ParentLineId
|
||||
});
|
||||
@@ -261,26 +189,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
invoice.BalanceAmount = invoice.NetPayable;
|
||||
}
|
||||
|
||||
private static decimal CalculateDiscount(decimal gross, SalesDiscountMode mode, decimal discountPct, decimal discountValue, decimal legacyDiscountAmount)
|
||||
{
|
||||
var computed = mode == SalesDiscountMode.Amount
|
||||
? discountValue
|
||||
: gross * (discountPct / 100m);
|
||||
|
||||
if (computed <= 0m && legacyDiscountAmount > 0m)
|
||||
computed = legacyDiscountAmount;
|
||||
|
||||
return Math.Min(gross, Math.Max(0m, computed));
|
||||
}
|
||||
|
||||
private static SalesInvoiceSummaryDto MapSummary(SalesInvoice x) => new(
|
||||
private SalesInvoiceSummaryDto MapSummary(SalesInvoice x) => new(
|
||||
x.SalesInvoiceId, x.InvoiceNo, x.InvoiceDate, x.CustomerId, x.CustomerSnapshotName,
|
||||
x.WarehouseId, x.InvoiceType, x.Status, new SalesInvoiceTotalsDto(
|
||||
x.Subtotal, x.DiscountTotal, x.Lines.Sum(l => l.FreeQty), x.TaxTotal, x.GrandTotal, x.RoundOff, x.NetPayable, x.PaidAmount, x.BalanceAmount), x.CreatedAt);
|
||||
|
||||
private static SalesInvoiceDto Map(SalesInvoice x) => new(
|
||||
x.SalesInvoiceId, x.InvoiceNo, x.InvoiceDate, x.CustomerId, x.CustomerSnapshotName, x.CustomerSnapshotTaxNo,
|
||||
x.WarehouseId, x.InvoiceType, x.Status, x.CreatedBy, x.CreatedAt, x.UpdatedAt,
|
||||
new SalesInvoiceTotalsDto(x.Subtotal, x.DiscountTotal, x.Lines.Sum(l => l.FreeQty), x.TaxTotal, x.GrandTotal, x.RoundOff, x.NetPayable, x.PaidAmount, x.BalanceAmount),
|
||||
x.Lines.Select(l => new SalesInvoiceLineDto(l.SalesInvoiceLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.UomId, l.WarehouseId, l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode, l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId)).ToList());
|
||||
x.WarehouseId, x.InvoiceType, x.Status, _mapping.MapInvoiceTotals(x), x.CreatedAt);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Dtos.Sales;
|
||||
using ERPCore.Services.Interfaces;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
public sealed class SalesMappingService : ISalesMappingService
|
||||
{
|
||||
public SalesInvoiceTotalsDto MapInvoiceTotals(SalesInvoice invoice)
|
||||
=> new(
|
||||
invoice.Subtotal,
|
||||
invoice.DiscountTotal,
|
||||
invoice.Lines.Sum(l => l.FreeQty),
|
||||
invoice.TaxTotal,
|
||||
invoice.GrandTotal,
|
||||
invoice.RoundOff,
|
||||
invoice.NetPayable,
|
||||
invoice.PaidAmount,
|
||||
invoice.BalanceAmount);
|
||||
|
||||
public SalesSlipTotalsDto MapSlipTotals(SalesSlip slip)
|
||||
=> new(
|
||||
slip.Subtotal,
|
||||
slip.DiscountTotal,
|
||||
slip.Lines.Sum(l => l.FreeQty),
|
||||
slip.TaxTotal,
|
||||
slip.GrandTotal,
|
||||
slip.PaidAmount,
|
||||
slip.BalanceAmount);
|
||||
|
||||
public SalesInvoiceDto MapInvoice(SalesInvoice invoice)
|
||||
=> new(
|
||||
invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.InvoiceDate, invoice.CustomerId,
|
||||
invoice.CustomerSnapshotName, invoice.CustomerSnapshotTaxNo, invoice.WarehouseId,
|
||||
invoice.InvoiceType, invoice.Status, invoice.CreatedBy, invoice.CreatedAt, invoice.UpdatedAt,
|
||||
MapInvoiceTotals(invoice),
|
||||
invoice.Lines.Select(l => new SalesInvoiceLineDto(
|
||||
l.SalesInvoiceLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.UomId, l.WarehouseId,
|
||||
l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode,
|
||||
l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId)).ToList());
|
||||
|
||||
public SalesSlipDto MapSlip(SalesSlip slip)
|
||||
=> new(
|
||||
slip.SalesSlipId, slip.SlipNo, slip.SlipDate, slip.CustomerId, slip.CustomerSnapshotName,
|
||||
slip.WarehouseId, slip.CashierUserId, slip.Status, slip.CreatedAt, slip.UpdatedAt,
|
||||
MapSlipTotals(slip),
|
||||
slip.Lines.Select(l => new SalesSlipLineDto(
|
||||
l.SalesSlipLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.UomId, l.WarehouseId,
|
||||
l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode,
|
||||
l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId)).ToList());
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
using ERPCore.Domain;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Sales;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.Services.Stock;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
public sealed class SalesPostingService : ISalesPostingService
|
||||
{
|
||||
private readonly IRepository<SalesInvoice> _invoices;
|
||||
private readonly IRepository<SalesSlip> _slips;
|
||||
private readonly IRepository<BundleSale> _bundles;
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IFifoCostingService _fifo;
|
||||
private readonly ISalesDomainService _sales;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public SalesPostingService(
|
||||
IRepository<SalesInvoice> invoices,
|
||||
IRepository<SalesSlip> slips,
|
||||
IRepository<BundleSale> bundles,
|
||||
IRepository<Item> items,
|
||||
IFifoCostingService fifo,
|
||||
ISalesDomainService sales,
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
_invoices = invoices;
|
||||
_slips = slips;
|
||||
_bundles = bundles;
|
||||
_items = items;
|
||||
_fifo = fifo;
|
||||
_sales = sales;
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<SalesInvoicePostingCheckDto> CheckInvoiceAsync(int salesInvoiceId, CancellationToken ct = default)
|
||||
{
|
||||
var invoice = await _invoices.Query().AsNoTracking().Include(x => x.Lines)
|
||||
.FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct)
|
||||
?? throw new NotFoundException($"Sales invoice {salesInvoiceId} was not found.");
|
||||
|
||||
if (invoice.Status != SalesInvoiceStatus.Draft)
|
||||
return new SalesInvoicePostingCheckDto(invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.Status, false, Array.Empty<SalesInvoicePostingIssueDto>());
|
||||
|
||||
var issues = new List<SalesInvoicePostingIssueDto>();
|
||||
foreach (var line in invoice.Lines)
|
||||
{
|
||||
if (!await _sales.IsStockedItemAsync(line.ItemId, ct))
|
||||
continue;
|
||||
var requestedQty = line.Qty + line.FreeQty;
|
||||
var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct);
|
||||
if (available >= requestedQty) continue;
|
||||
|
||||
var item = await _items.Query().AsNoTracking()
|
||||
.Where(x => x.ItemId == line.ItemId)
|
||||
.Select(x => new { x.Sku, x.Name })
|
||||
.FirstAsync(ct);
|
||||
|
||||
issues.Add(new SalesInvoicePostingIssueDto(
|
||||
line.SalesInvoiceLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId,
|
||||
requestedQty, available, requestedQty - available, line.IsFreeIssue));
|
||||
}
|
||||
|
||||
return new SalesInvoicePostingCheckDto(invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.Status, issues.Count == 0, issues);
|
||||
}
|
||||
|
||||
public async Task<SalesSlipPostingCheckDto> CheckSlipAsync(int salesSlipId, CancellationToken ct = default)
|
||||
{
|
||||
var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines)
|
||||
.FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct)
|
||||
?? throw new NotFoundException($"Sales slip {salesSlipId} was not found.");
|
||||
|
||||
if (slip.Status != SalesSlipStatus.Draft)
|
||||
return new SalesSlipPostingCheckDto(slip.SalesSlipId, slip.SlipNo, slip.Status, false, Array.Empty<SalesSlipPostingIssueDto>());
|
||||
|
||||
var issues = new List<SalesSlipPostingIssueDto>();
|
||||
foreach (var line in slip.Lines)
|
||||
{
|
||||
if (!await _sales.IsStockedItemAsync(line.ItemId, ct))
|
||||
continue;
|
||||
var requestedQty = line.Qty + line.FreeQty;
|
||||
var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct);
|
||||
if (available >= requestedQty) continue;
|
||||
|
||||
var item = await _items.Query().AsNoTracking()
|
||||
.Where(x => x.ItemId == line.ItemId)
|
||||
.Select(x => new { x.Sku, x.Name })
|
||||
.FirstAsync(ct);
|
||||
|
||||
issues.Add(new SalesSlipPostingIssueDto(
|
||||
line.SalesSlipLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId,
|
||||
requestedQty, available, requestedQty - available, line.IsFreeIssue));
|
||||
}
|
||||
|
||||
return new SalesSlipPostingCheckDto(slip.SalesSlipId, slip.SlipNo, slip.Status, issues.Count == 0, issues);
|
||||
}
|
||||
|
||||
public async Task<BundleSalePostingCheckDto> CheckBundleAsync(int bundleSaleId, CancellationToken ct = default)
|
||||
{
|
||||
var bundle = await _bundles.Query().AsNoTracking().Include(x => x.Lines)
|
||||
.FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct)
|
||||
?? throw new NotFoundException($"Bundle sale {bundleSaleId} was not found.");
|
||||
|
||||
if (bundle.Status != BundleSaleStatus.Draft)
|
||||
return new BundleSalePostingCheckDto(bundle.BundleSaleId, bundle.BundleNo, bundle.Status, false, Array.Empty<BundleSalePostingIssueDto>());
|
||||
|
||||
var issues = new List<BundleSalePostingIssueDto>();
|
||||
foreach (var line in bundle.Lines.Where(l => l.IncludeInBundle))
|
||||
{
|
||||
if (!await _sales.IsStockedItemAsync(line.ItemId, ct))
|
||||
continue;
|
||||
var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct);
|
||||
if (available >= line.Qty) continue;
|
||||
|
||||
var item = await _items.Query().AsNoTracking()
|
||||
.Where(x => x.ItemId == line.ItemId)
|
||||
.Select(x => new { x.Sku, x.Name })
|
||||
.FirstAsync(ct);
|
||||
|
||||
issues.Add(new BundleSalePostingIssueDto(line.BundleSaleLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId, line.Qty, available, line.Qty - available));
|
||||
}
|
||||
|
||||
return new BundleSalePostingCheckDto(bundle.BundleSaleId, bundle.BundleNo, bundle.Status, issues.Count == 0, issues);
|
||||
}
|
||||
|
||||
public Task PostInvoiceAsync(int salesInvoiceId, CancellationToken ct = default)
|
||||
=> PostAsync(
|
||||
load: () => _invoices.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct),
|
||||
notFoundMessage: $"Sales invoice {salesInvoiceId} was not found.",
|
||||
statusSelector: x => x.Status,
|
||||
ensureDraftMessage: x => $"Sales invoice {x.SalesInvoiceId} is {x.Status} and cannot be posted.",
|
||||
getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)),
|
||||
setPosted: x => x.Status = SalesInvoiceStatus.Posted,
|
||||
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
|
||||
sourceDocType: nameof(SalesInvoice),
|
||||
getDocId: x => x.SalesInvoiceId,
|
||||
ct: ct);
|
||||
|
||||
public Task PostSlipAsync(int salesSlipId, CancellationToken ct = default)
|
||||
=> PostAsync(
|
||||
load: () => _slips.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct),
|
||||
notFoundMessage: $"Sales slip {salesSlipId} was not found.",
|
||||
statusSelector: x => x.Status,
|
||||
ensureDraftMessage: x => $"Sales slip {x.SalesSlipId} is {x.Status} and cannot be posted.",
|
||||
getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)),
|
||||
setPosted: x => x.Status = SalesSlipStatus.Posted,
|
||||
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
|
||||
sourceDocType: nameof(SalesSlip),
|
||||
getDocId: x => x.SalesSlipId,
|
||||
ct: ct);
|
||||
|
||||
public Task PostBundleAsync(int bundleSaleId, CancellationToken ct = default)
|
||||
=> PostAsync(
|
||||
load: () => _bundles.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct),
|
||||
notFoundMessage: $"Bundle sale {bundleSaleId} was not found.",
|
||||
statusSelector: x => x.Status,
|
||||
ensureDraftMessage: x => $"Bundle sale {x.BundleSaleId} is {x.Status} and cannot be posted.",
|
||||
getLines: x => x.Lines.Where(l => l.IncludeInBundle).Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty, l.Qty, 0m)),
|
||||
setPosted: x => x.Status = BundleSaleStatus.Posted,
|
||||
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
|
||||
sourceDocType: nameof(BundleSale),
|
||||
getDocId: x => x.BundleSaleId,
|
||||
ct: ct);
|
||||
|
||||
private async Task PostAsync<T>(
|
||||
Func<Task<T?>> load,
|
||||
string notFoundMessage,
|
||||
Func<T, object> statusSelector,
|
||||
Func<T, string> ensureDraftMessage,
|
||||
Func<T, IEnumerable<PostingLine>> getLines,
|
||||
Action<T> setPosted,
|
||||
Action<T> setUpdated,
|
||||
string sourceDocType,
|
||||
Func<T, int> getDocId,
|
||||
CancellationToken ct)
|
||||
where T : class
|
||||
{
|
||||
var doc = await load() ?? throw new NotFoundException(notFoundMessage);
|
||||
var status = statusSelector(doc);
|
||||
var statusValue = status?.ToString() ?? string.Empty;
|
||||
if (!string.Equals(statusValue, "Draft", StringComparison.Ordinal))
|
||||
throw new ConflictException(ensureDraftMessage(doc));
|
||||
|
||||
await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
foreach (var line in getLines(doc))
|
||||
{
|
||||
if (line.Qty <= 0) continue;
|
||||
if (!await _sales.IsStockedItemAsync(line.ItemId, token))
|
||||
continue;
|
||||
|
||||
var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, null, line.Qty, token);
|
||||
var cost = consumed.Count == 0 ? 0m : consumed.Sum(x => x.Qty * x.UnitCost) / consumed.Sum(x => x.Qty);
|
||||
await _fifo.PostLedgerAsync(line.ItemId, line.WarehouseId, null, null, null, _currentUser.AuditUserId,
|
||||
Direction.Out, line.Qty, cost, 0m, sourceDocType, getDocId(doc), DateTime.UtcNow, token);
|
||||
}
|
||||
|
||||
setPosted(doc);
|
||||
setUpdated(doc);
|
||||
}, ct);
|
||||
}
|
||||
|
||||
private sealed record PostingLine(int ItemId, int WarehouseId, decimal Qty, decimal PaidQty, decimal FreeQty);
|
||||
}
|
||||
@@ -22,15 +22,18 @@ public sealed class SalesSlipService : ISalesSlipService
|
||||
private readonly IRepository<Uom> _uoms;
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly IRepository<User> _users;
|
||||
private readonly ISalesPricingService _pricing;
|
||||
private readonly IFifoCostingService _fifo;
|
||||
private readonly ISalesDomainService _sales;
|
||||
private readonly ISalesPostingService _posting;
|
||||
private readonly ISalesMappingService _mapping;
|
||||
private readonly ISalesDocumentWorkflowService _workflow;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly INumberSequenceService _numbers;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public SalesSlipService(
|
||||
IRepository<SalesSlip> slips, IRepository<Customer> customers, IRepository<Item> items,
|
||||
IRepository<Uom> uoms, IRepository<Warehouse> warehouses, IRepository<User> users, ISalesPricingService pricing, IFifoCostingService fifo,
|
||||
IRepository<Uom> uoms, IRepository<Warehouse> warehouses, IRepository<User> users, ISalesDomainService sales, ISalesPostingService posting, ISalesMappingService mapping,
|
||||
ISalesDocumentWorkflowService workflow,
|
||||
ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow)
|
||||
{
|
||||
_slips = slips;
|
||||
@@ -39,8 +42,10 @@ public sealed class SalesSlipService : ISalesSlipService
|
||||
_uoms = uoms;
|
||||
_warehouses = warehouses;
|
||||
_users = users;
|
||||
_pricing = pricing;
|
||||
_fifo = fifo;
|
||||
_sales = sales;
|
||||
_posting = posting;
|
||||
_mapping = mapping;
|
||||
_workflow = workflow;
|
||||
_currentUser = currentUser;
|
||||
_numbers = numbers;
|
||||
_uow = uow;
|
||||
@@ -67,7 +72,7 @@ public sealed class SalesSlipService : ISalesSlipService
|
||||
{
|
||||
var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines)
|
||||
.FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct);
|
||||
return slip is null ? null : new ETagged<SalesSlipDto>(Map(slip), slip.RowVersion);
|
||||
return slip is null ? null : new ETagged<SalesSlipDto>(_mapping.MapSlip(slip), slip.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<FreeIssueSummaryDto>> ListFreeIssuesAsync(PageQuery query, SalesSlipStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default)
|
||||
@@ -95,44 +100,12 @@ public sealed class SalesSlipService : ISalesSlipService
|
||||
return slip is null ? null : new ETagged<FreeIssueDto>(MapFreeIssue(slip), slip.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<SalesSlipPostingCheckDto> CheckPostingAsync(int salesSlipId, CancellationToken ct = default)
|
||||
{
|
||||
var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines)
|
||||
.FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct)
|
||||
?? throw new NotFoundException($"Sales slip {salesSlipId} was not found.");
|
||||
|
||||
if (slip.Status != SalesSlipStatus.Draft)
|
||||
return new SalesSlipPostingCheckDto(slip.SalesSlipId, slip.SlipNo, slip.Status, false, Array.Empty<SalesSlipPostingIssueDto>());
|
||||
|
||||
var issues = new List<SalesSlipPostingIssueDto>();
|
||||
foreach (var line in slip.Lines)
|
||||
{
|
||||
var requestedQty = line.Qty + line.FreeQty;
|
||||
var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct);
|
||||
if (available >= requestedQty) continue;
|
||||
|
||||
var item = await _items.Query().AsNoTracking()
|
||||
.Where(x => x.ItemId == line.ItemId)
|
||||
.Select(x => new { x.Sku, x.Name })
|
||||
.FirstAsync(ct);
|
||||
issues.Add(new SalesSlipPostingIssueDto(
|
||||
line.SalesSlipLineId,
|
||||
line.ItemId,
|
||||
item.Sku,
|
||||
item.Name,
|
||||
line.WarehouseId,
|
||||
requestedQty,
|
||||
available,
|
||||
requestedQty - available,
|
||||
line.IsFreeIssue));
|
||||
}
|
||||
|
||||
return new SalesSlipPostingCheckDto(slip.SalesSlipId, slip.SlipNo, slip.Status, issues.Count == 0, issues);
|
||||
}
|
||||
public Task<SalesSlipPostingCheckDto> CheckPostingAsync(int salesSlipId, CancellationToken ct = default)
|
||||
=> _posting.CheckSlipAsync(salesSlipId, ct);
|
||||
|
||||
public async Task<ETagged<SalesSlipDto>> CreateAsync(CreateSalesSlipRequest request, CancellationToken ct = default)
|
||||
{
|
||||
await ValidateReferencesAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, request.Lines, ct);
|
||||
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, true, ct);
|
||||
|
||||
var slip = new SalesSlip
|
||||
{
|
||||
@@ -145,61 +118,38 @@ public sealed class SalesSlipService : ISalesSlipService
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
slip.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
|
||||
slip.Lines = await BuildLinesAsync(request.Lines, ct);
|
||||
slip.Lines = await BuildLinesAsync(request.WarehouseId, request.Lines, ct);
|
||||
Recalculate(slip);
|
||||
|
||||
await _slips.AddAsync(slip, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
return new ETagged<SalesSlipDto>(Map(slip), slip.RowVersion);
|
||||
return new ETagged<SalesSlipDto>(_mapping.MapSlip(slip), slip.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<ETagged<SalesSlipDto>> UpdateAsync(int salesSlipId, UpdateSalesSlipRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
||||
{
|
||||
var slip = await _slips.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct)
|
||||
?? throw new NotFoundException($"Sales slip {salesSlipId} was not found.");
|
||||
if (slip.RowVersion != expectedRowVersion)
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The sales slip was modified by another request.", 412);
|
||||
if (slip.Status != SalesSlipStatus.Draft)
|
||||
throw new ConflictException($"Sales slip {salesSlipId} is {slip.Status} and cannot be edited.");
|
||||
var slip = await _workflow.LoadEditableSlipAsync(salesSlipId, expectedRowVersion, ct);
|
||||
|
||||
await ValidateReferencesAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, request.Lines, ct);
|
||||
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, true, ct);
|
||||
slip.CustomerId = request.CustomerId;
|
||||
slip.WarehouseId = request.WarehouseId;
|
||||
slip.CashierUserId = request.CashierUserId;
|
||||
slip.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
|
||||
slip.Lines.Clear();
|
||||
foreach (var line in await BuildLinesAsync(request.Lines, ct)) slip.Lines.Add(line);
|
||||
foreach (var line in await BuildLinesAsync(request.WarehouseId, request.Lines, ct)) slip.Lines.Add(line);
|
||||
Recalculate(slip);
|
||||
slip.UpdatedAt = DateTime.UtcNow;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
return new ETagged<SalesSlipDto>(Map(slip), slip.RowVersion);
|
||||
return new ETagged<SalesSlipDto>(_mapping.MapSlip(slip), slip.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<SalesSlipDto> PostAsync(int salesSlipId, CancellationToken ct = default)
|
||||
{
|
||||
var slip = await _slips.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct)
|
||||
?? throw new NotFoundException($"Sales slip {salesSlipId} was not found.");
|
||||
if (slip.Status != SalesSlipStatus.Draft)
|
||||
throw new ConflictException($"Sales slip {salesSlipId} is {slip.Status} and cannot be posted.");
|
||||
|
||||
var posted = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
foreach (var line in slip.Lines)
|
||||
{
|
||||
if (line.Qty <= 0 && line.FreeQty <= 0) continue;
|
||||
var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, null, line.Qty + line.FreeQty, token);
|
||||
var cost = consumed.Count == 0 ? 0m : consumed.Sum(x => x.Qty * x.UnitCost) / consumed.Sum(x => x.Qty);
|
||||
await _fifo.PostLedgerAsync(line.ItemId, line.WarehouseId, null, null, null, _currentUser.AuditUserId,
|
||||
Direction.Out, line.Qty + line.FreeQty, cost, 0m, nameof(SalesSlip), slip.SalesSlipId, DateTime.UtcNow, token);
|
||||
}
|
||||
|
||||
slip.Status = SalesSlipStatus.Posted;
|
||||
slip.UpdatedAt = DateTime.UtcNow;
|
||||
return slip;
|
||||
}, ct);
|
||||
|
||||
return Map(posted);
|
||||
}
|
||||
await _posting.PostSlipAsync(salesSlipId, ct);
|
||||
var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines)
|
||||
.FirstAsync(x => x.SalesSlipId == salesSlipId, ct);
|
||||
return _mapping.MapSlip(slip);
|
||||
}
|
||||
|
||||
public async Task<SalesSlipDto> CancelAsync(int salesSlipId, CancellationToken ct = default)
|
||||
{
|
||||
@@ -210,45 +160,21 @@ public sealed class SalesSlipService : ISalesSlipService
|
||||
slip.Status = SalesSlipStatus.Cancelled;
|
||||
slip.UpdatedAt = DateTime.UtcNow;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
return Map(slip);
|
||||
return _mapping.MapSlip(slip);
|
||||
}
|
||||
|
||||
private async Task ValidateReferencesAsync(int customerId, int warehouseId, int cashierUserId, List<CreateSalesSlipLineRequest> lines, CancellationToken ct)
|
||||
{
|
||||
if (!await _customers.Query().AnyAsync(x => x.CustomerId == customerId, ct))
|
||||
throw new NotFoundException($"Customer {customerId} was not found.");
|
||||
if (!await _warehouses.Query().AnyAsync(x => x.WarehouseId == warehouseId, ct))
|
||||
throw new NotFoundException($"Warehouse {warehouseId} was not found.");
|
||||
if (!await _users.Query().AnyAsync(x => x.UserId == cashierUserId, ct))
|
||||
throw new NotFoundException($"User {cashierUserId} was not found.");
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (!await _items.Query().AnyAsync(x => x.ItemId == line.ItemId, ct))
|
||||
throw new NotFoundException($"Item {line.ItemId} was not found.");
|
||||
if (!await _uoms.Query().AnyAsync(x => x.UomId == line.UomId, ct))
|
||||
throw new NotFoundException($"UOM {line.UomId} was not found.");
|
||||
if (!await _warehouses.Query().AnyAsync(x => x.WarehouseId == line.WarehouseId, ct))
|
||||
throw new NotFoundException($"Warehouse {line.WarehouseId} was not found.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<SalesSlipLine>> BuildLinesAsync(List<CreateSalesSlipLineRequest> requests, CancellationToken ct)
|
||||
private async Task<List<SalesSlipLine>> BuildLinesAsync(int headerWarehouseId, List<CreateSalesSlipLineRequest> requests, CancellationToken ct)
|
||||
{
|
||||
var lines = new List<SalesSlipLine>();
|
||||
foreach (var r in requests)
|
||||
{
|
||||
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
|
||||
var resolved = await _pricing.ResolveAsync(r.ItemId, r.WarehouseId, r.UnitPrice, r.AllowManualPriceOverride, ct);
|
||||
await _sales.ValidateSalesLineAsync(headerWarehouseId, r.ItemId, r.UomId, r.WarehouseId, r.Qty, r.FreeQty, r.ParentLineId, ct);
|
||||
var resolved = await _sales.ResolveLinePriceAsync(r.ItemId, r.WarehouseId, r.UnitPrice, r.AllowManualPriceOverride, ct);
|
||||
var unitPrice = resolved.UnitPrice;
|
||||
var priceSource = resolved.PriceSource;
|
||||
|
||||
var gross = r.Qty * unitPrice;
|
||||
var discountTotal = r.IsFreeIssue
|
||||
? 0m
|
||||
: CalculateDiscount(gross, r.DiscountMode, r.DiscountPct, r.DiscountValue, r.DiscountAmount);
|
||||
var netUnit = r.Qty > 0 ? (gross - discountTotal) / r.Qty : 0m;
|
||||
var lineTotal = gross - discountTotal;
|
||||
var taxAmount = lineTotal * (r.TaxPct / 100m);
|
||||
var calc = _sales.ComputeLine(r.Qty, r.FreeQty, unitPrice, r.DiscountMode, r.DiscountPct, r.DiscountValue, r.DiscountAmount, r.TaxPct, r.IsFreeIssue);
|
||||
|
||||
lines.Add(new SalesSlipLine
|
||||
{
|
||||
@@ -263,11 +189,11 @@ public sealed class SalesSlipService : ISalesSlipService
|
||||
PriceSource = priceSource,
|
||||
DiscountMode = r.DiscountMode,
|
||||
DiscountPct = r.DiscountPct,
|
||||
DiscountAmount = discountTotal,
|
||||
NetUnitPrice = netUnit,
|
||||
LineTotal = lineTotal,
|
||||
DiscountAmount = calc.DiscountTotal,
|
||||
NetUnitPrice = calc.NetUnitPrice,
|
||||
LineTotal = calc.LineTotal,
|
||||
TaxPct = r.TaxPct,
|
||||
TaxAmount = taxAmount,
|
||||
TaxAmount = calc.TaxAmount,
|
||||
IsFreeIssue = r.IsFreeIssue,
|
||||
ParentLineId = r.ParentLineId
|
||||
});
|
||||
@@ -285,21 +211,9 @@ public sealed class SalesSlipService : ISalesSlipService
|
||||
slip.BalanceAmount = slip.GrandTotal - slip.PaidAmount;
|
||||
}
|
||||
|
||||
private static decimal CalculateDiscount(decimal gross, SalesDiscountMode mode, decimal discountPct, decimal discountValue, decimal legacyDiscountAmount)
|
||||
{
|
||||
var computed = mode == SalesDiscountMode.Amount
|
||||
? discountValue
|
||||
: gross * (discountPct / 100m);
|
||||
|
||||
if (computed <= 0m && legacyDiscountAmount > 0m)
|
||||
computed = legacyDiscountAmount;
|
||||
|
||||
return Math.Min(gross, Math.Max(0m, computed));
|
||||
}
|
||||
|
||||
private static SalesSlipSummaryDto MapSummary(SalesSlip x) => new(
|
||||
private SalesSlipSummaryDto MapSummary(SalesSlip x) => new(
|
||||
x.SalesSlipId, x.SlipNo, x.SlipDate, x.CustomerId, x.CustomerSnapshotName, x.WarehouseId, x.Status,
|
||||
new SalesSlipTotalsDto(x.Subtotal, x.DiscountTotal, x.Lines.Sum(l => l.FreeQty), x.TaxTotal, x.GrandTotal, x.PaidAmount, x.BalanceAmount), x.CreatedAt);
|
||||
_mapping.MapSlipTotals(x), x.CreatedAt);
|
||||
|
||||
private FreeIssueSummaryDto MapFreeIssueSummary(SalesSlip x)
|
||||
{
|
||||
@@ -352,8 +266,5 @@ public sealed class SalesSlipService : ISalesSlipService
|
||||
x.Lines.Select(l => new SalesSlipLineDto(l.SalesSlipLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.UomId, l.WarehouseId, l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode, l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId)).ToList());
|
||||
}
|
||||
|
||||
private static SalesSlipDto Map(SalesSlip x) => new(
|
||||
x.SalesSlipId, x.SlipNo, x.SlipDate, x.CustomerId, x.CustomerSnapshotName, x.WarehouseId, x.CashierUserId, x.Status, x.CreatedAt, x.UpdatedAt,
|
||||
new SalesSlipTotalsDto(x.Subtotal, x.DiscountTotal, x.Lines.Sum(l => l.FreeQty), x.TaxTotal, x.GrandTotal, x.PaidAmount, x.BalanceAmount),
|
||||
x.Lines.Select(l => new SalesSlipLineDto(l.SalesSlipLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.UomId, l.WarehouseId, l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode, l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId)).ToList());
|
||||
private SalesSlipDto Map(SalesSlip x) => _mapping.MapSlip(x);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Generated
+6
@@ -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>
|
||||
|
||||
@@ -23,12 +23,12 @@ const sections = [
|
||||
href: "/dashboard/sales/free-issues",
|
||||
icon: PackageX,
|
||||
},
|
||||
{
|
||||
title: "Reports",
|
||||
description: "Sales report catalog and query entry point.",
|
||||
href: "/dashboard/sales/reports",
|
||||
icon: FileBarChart,
|
||||
},
|
||||
// {
|
||||
// title: "Reports",
|
||||
// description: "Sales report catalog and query entry point.",
|
||||
// href: "/dashboard/sales/reports",
|
||||
// icon: FileBarChart,
|
||||
// },
|
||||
]
|
||||
|
||||
export default function SalesHubPage() {
|
||||
@@ -42,7 +42,7 @@ export default function SalesHubPage() {
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Sales</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Invoices, slips, free issues, and reporting in one place.
|
||||
Invoices, slips, and free issues in one place.
|
||||
</p>
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ const navItems: {
|
||||
{ title: "Slips", code: "sales.slips", href: "/dashboard/sales/slips", icon: ShoppingCart },
|
||||
{ title: "Bundle Sales", code: "sales.bundle-sales", href: "/dashboard/sales/bundles", icon: Boxes },
|
||||
{ title: "Free Issues", code: "sales.free-issues", href: "/dashboard/sales/free-issues", icon: PackageX },
|
||||
{ title: "Reports", code: "sales.reports", href: "/dashboard/sales/reports", icon: FileBarChart },
|
||||
// { title: "Reports", code: "sales.reports", href: "/dashboard/sales/reports", icon: FileBarChart },
|
||||
],
|
||||
},
|
||||
{ title: "Receiving", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true },
|
||||
@@ -143,7 +143,7 @@ const navItems: {
|
||||
{ title: "Attendance", code: "hrm.attendance", href: "/dashboard/hrm/attendance", icon: CalendarCheck },
|
||||
{ title: "Leave", code: "hrm.leave", href: "/dashboard/hrm/leave", icon: CalendarClock },
|
||||
{ title: "Payroll", code: "hrm.payroll", href: "/dashboard/hrm/payroll", icon: Banknote },
|
||||
{ title: "Reports", code: "hrm.reports", href: "/dashboard/hrm/reports", icon: FileBarChart },
|
||||
// { title: "Reports", code: "hrm.reports", href: "/dashboard/hrm/reports", icon: FileBarChart },
|
||||
{ title: "Settings", code: "hrm.settings", href: "/dashboard/hrm/settings", icon: SlidersHorizontal },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
Generated
+125
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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"],
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -15,6 +15,26 @@ The design stays aligned with the existing backend patterns:
|
||||
|
||||
Returns, credit notes, and sales returns are **out of scope for Phase 1**.
|
||||
|
||||
### Current Implementation Status
|
||||
The Phase 1 core is implemented and wired across the backend and frontend for:
|
||||
- sales invoices
|
||||
- sales slips
|
||||
- free issues as a slip alias
|
||||
- bundle sales
|
||||
- sales posting to stock/FIFO
|
||||
|
||||
Shared backend services now centralize the repeated sales logic:
|
||||
- sales validation and pricing
|
||||
- sales posting checks and FIFO outbound posting
|
||||
- invoice/slip mapping and totals
|
||||
- shared draft edit/load workflow checks
|
||||
|
||||
Still intentionally separate:
|
||||
- bundle pricing and bundle margin behavior
|
||||
- production and GRN as upstream stock/cost sources
|
||||
- reservation/backorder flow
|
||||
- sales reports visibility in the frontend UI, which is currently hidden from the navigation but still implemented in the backend
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 - Basic Standard Sales Module
|
||||
@@ -42,6 +62,22 @@ Implement the minimum sales flow needed for both B2B and B2C:
|
||||
- Stock posting
|
||||
- Basic sales reports
|
||||
|
||||
### Implemented Shared Services
|
||||
- `ISalesDomainService`
|
||||
- header validation
|
||||
- line validation
|
||||
- price resolution
|
||||
- line financial computation
|
||||
- stock-item classification
|
||||
- `ISalesPostingService`
|
||||
- invoice/slip/bundle posting checks
|
||||
- shared FIFO posting for stocked items
|
||||
- `ISalesMappingService`
|
||||
- invoice/slip totals mapping
|
||||
- invoice/slip DTO mapping
|
||||
- `ISalesDocumentWorkflowService`
|
||||
- shared editable-document load and concurrency checks for invoice/slip draft updates
|
||||
|
||||
### Not in Scope for Phase 1
|
||||
- customer groups
|
||||
- price lists
|
||||
@@ -51,6 +87,7 @@ Implement the minimum sales flow needed for both B2B and B2C:
|
||||
- approval workflow
|
||||
- returns and credit notes
|
||||
- advanced customer segmentation
|
||||
- fully unified sales provenance tracing across production, GRN, and sales documents
|
||||
|
||||
### Phase 1 Entity Design
|
||||
|
||||
@@ -211,6 +248,20 @@ When an invoice or slip is posted:
|
||||
- maintain source document traceability
|
||||
- update totals in the same transaction
|
||||
|
||||
Sales document provenance is stored by document family:
|
||||
- `SalesInvoice` / `SalesInvoiceLine`
|
||||
- `SalesSlip` / `SalesSlipLine`
|
||||
- `BundleSale` / `BundleSaleLine`
|
||||
|
||||
Inventory movement provenance is stored in:
|
||||
- `StockLayer`
|
||||
- `StockLedger` via `SourceDocType` / `SourceDocId`
|
||||
- `JournalEntryStub` via `SourceDocType` / `SourceDocId`
|
||||
|
||||
Upstream cost/availability sources remain:
|
||||
- `Grn` / `GrnLine` for inbound purchasing cost
|
||||
- `ProductionRun` and stage tables for finished-goods production cost
|
||||
|
||||
### Phase 1 API Route List
|
||||
- `GET /api/v1/customers`
|
||||
- `GET /api/v1/customers/{id}`
|
||||
@@ -239,6 +290,10 @@ When an invoice or slip is posted:
|
||||
- `GET /api/v1/reports/sales/{reportId}`
|
||||
- `POST /api/v1/reports/sales/query`
|
||||
|
||||
Note:
|
||||
- the sales report backend routes remain implemented
|
||||
- the frontend report entry points are currently hidden from navigation, but the screens and API contracts still exist
|
||||
|
||||
### Phase 1 Folder / Module Plan
|
||||
- `Domain/Entities`
|
||||
- add `Customer`, `SalesInvoice`, `SalesInvoiceLine`, `SalesSlip`, `SalesSlipLine`
|
||||
@@ -389,6 +444,7 @@ Allocation of a payment across invoices.
|
||||
- Verify discounts calculate correctly by percentage and fixed value.
|
||||
- Verify free issue lines post stock and appear in reports.
|
||||
- Verify stock ledger entries are created once per posted document.
|
||||
- Verify the frontend sales hub and sidebar only expose invoice, slip, and free-issue entry points while report pages remain reachable directly.
|
||||
- Verify Phase 1 routes remain stable before Phase 2 is added.
|
||||
|
||||
## Assumptions
|
||||
|
||||
Reference in New Issue
Block a user