Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d7ee83828c | |||
| eb7b2691df | |||
| 1a0fb4603e | |||
| 5fc5ef59ac | |||
| d1fe164ea2 | |||
| 02f47bd485 | |||
| 45554ceb9a | |||
| 5d0ea3f035 | |||
| 26cf2a146a | |||
| 0750773f94 | |||
| 38c7545413 | |||
| 0d60aeef64 | |||
| a414dfc4ea | |||
| 3cccaf4c63 | |||
| b7bd8dca5c |
-6696
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
-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");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
|
||||
@@ -129,6 +129,8 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
|
||||
> **Fields not explicitly spelled out verbatim in GL's reference** (its own numeric-id column names for `ChequeBook`/`ChequePage`, and `ReceivedCheque`'s JSON id field) are built from the request-body field names GL *does* document plus this project's consistent `<entity>Id` convention, flagged in `types/general-ledger.ts`'s comments — `chequeNo`/`chequeBookNo` (both explicitly documented as the identifying route values) are used for keys/URLs throughout instead, sidestepping the guess entirely wherever possible. **Not done:** live smoke test against a running GL instance — this entire module is unverified against real Cheque Management data. Verified: `tsc --noEmit`/`eslint` clean on every touched file; `npm run build` compiles successfully (Turbopack), its full-project TypeScript check still blocked only by the pre-existing, unrelated `hrm/employees/[id]` error.
|
||||
>
|
||||
> **2026-07-20 (3) — General Ledger report corrected again: `accountId` dropped entirely, not just made direct-entry.** The GL service's own contract changed (confirmed against its updated docs): `GeneralLedger`'s `accountId` is now optional, and the *omitted* case is the real General Ledger (every postable account together, each with its own running balance, sorted by `accountCode` then `entryDate`) — supplying `accountId` is a separate "Account Ledger" (single account + descendants) mode this page doesn't use. Superseding the same-day entry above: the numeric Account ID input is gone, `reportsApi.generalLedger()` dropped the `accountId` parameter, and the page now fetches on `periodStart`/`periodEnd` alone with sensible defaults — auto-fetching on page load like every other report screen (this also resolves the earlier-reported "no network call when landing on the page," which was the now-removed account-required gate). Result rows are grouped into per-account sections in the table (a header row wherever `accountCode` changes), matching the API's per-account running-balance reset. No frontend change was needed for the same-day `BalanceSheet` response addition (a synthetic `"Current Year Earnings"` equity row) — the existing generic row renderer already displays whatever rows come back. Verified: `tsc --noEmit` clean, `npx eslint app/dashboard/ledgers lib/api/general-ledger.ts` produces zero output, `npm run build` succeeds.
|
||||
>
|
||||
> **2026-08-05 — Cheque Management status/type fields were rendering as raw integers, not names (user-reported + confirmed with GL's own `06_Enums_Reference.md`).** That doc's key fact: GL has no global `JsonStringEnumConverter`. A JSON-**body** enum field (e.g. the Issue-cheque form's `payeeType`) is independently declared `string` server-side and parsed via `Enum.TryParse`, and a query-string enum filter binds natively by name — both already correct here, unaffected. But `ChequeBook.status`, `ChequePage.issueStatus`, `ChequePage.payeeType`, `ReceivedCheque.receivedFromType`, and `ReceivedCheque.status` are genuine enum-typed properties on GL's own **response** DTOs, backed by real `integer` DB columns — with no converter, GL's JSON serializes each one as its raw number (`1`/`2`/`3`/...), not its name. This wasn't just a cosmetic label bug: every list badge, the dialogs' status-based available-actions logic, and any `===` comparison against this frontend's own string enums (`ChequeBookStatus.Active`, etc.) would have silently mismatched against these numbers. Fixed at the API boundary, not scattered across every consumer: added five `*_BY_CODE` lookup maps to `types/general-ledger.ts` (one per affected field, keyed by the exact integers `06_Enums_Reference.md` documents), and applied them in `lib/api/general-ledger.ts` via new `Raw*` types (describing GL's actual `number`/`number | null` response shape for these fields) plus `mapChequeBook`/`mapChequePage`/`mapReceivedCheque` helpers wired into every `chequeBooksApi`/`chequePagesApi`/`receivedChequesApi` method that returns one — so every page/dialog/badge map keeps working against the same string values as before, unchanged. Cross-checked every other enum in that doc's "Persisted enums" table against this frontend (`JournalEntryStatus`/`PeriodStatus`/`TaxCalculationBasis`/`TaxAppliesTo`/`DepreciationMethod`/`FixedAssetStatus`/`AuditCategory`/`AuditAction`) — none are consumed anywhere in this app, confirming Cheque Management was the complete fix, not a partial one. Verified: `tsc --noEmit`/`eslint` clean on both touched files.
|
||||
|
||||
## 7. UX states
|
||||
- [~] Loading / empty / error states on every list — done for the GRN list/create/detail screens; other screens still unbuilt
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { AlertTriangle, Ban, Plus, Save, Send, Trash2 } from "lucide-react"
|
||||
|
||||
import { AlertTriangle, ArrowLeft, Ban, Check, Plus, Save, Trash2 } from "lucide-react"
|
||||
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
@@ -195,10 +194,12 @@ export default function PurchaseOrderDetailPage() {
|
||||
const updated = await purchaseOrdersApi.submit(po.poId)
|
||||
setPo(updated)
|
||||
setLines(toDraftLines(updated))
|
||||
toast.success("Purchase order submitted", `${updated.docNo} — now ${updated.status} and locked for editing.`)
|
||||
toast.success("Purchase order approved", `${updated.docNo} — now ${updated.status} and locked for editing.`)
|
||||
toast.success("Purchase order approved", `${updated.docNo} — now ${updated.status} and locked for editing.`)
|
||||
} catch (err) {
|
||||
setSaveError(errorMessage(err))
|
||||
toast.error("Could not submit purchase order", errorMessage(err))
|
||||
toast.error("Could not approve purchase order", errorMessage(err))
|
||||
toast.error("Could not approve purchase order", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -283,9 +284,12 @@ export default function PurchaseOrderDetailPage() {
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{po.status === "Draft" && (
|
||||
<>
|
||||
<Button variant="outline" size="lg" onClick={handleSubmitPo} disabled={submitting || deleting}>
|
||||
<Send className="size-5" />
|
||||
{submitting ? "Submitting…" : "Submit"}
|
||||
<Button variant="success" size="lg" onClick={handleSubmitPo} disabled={submitting || deleting}>
|
||||
<Check className="size-5" />
|
||||
{submitting ? "Approving…" : "Approve"}
|
||||
<Button variant="success" size="lg" onClick={handleSubmitPo} disabled={submitting || deleting}>
|
||||
<Check className="size-5" />
|
||||
{submitting ? "Approving…" : "Approve"}
|
||||
</Button>
|
||||
<Button variant="destructive" size="lg" onClick={handleDelete} disabled={deleting || submitting}>
|
||||
<Trash2 className="size-5" />
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Suspense, useEffect, useState } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { Plus, Trash2 } from "lucide-react"
|
||||
import { ArrowLeft, ExternalLink, Plus, Trash2 } from "lucide-react"
|
||||
|
||||
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||
import { requisitionsApi } from "@/lib/api/requisitions"
|
||||
@@ -15,13 +15,22 @@ import { uomsApi } from "@/lib/api/uoms"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validatePoLine } from "@/lib/validations/procurement"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { generateVendorCode } from "@/lib/vendor-code"
|
||||
import { CreatePoLineInput } from "@/types/procurement"
|
||||
import { ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { FieldError } from "@/components/ui/field"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
@@ -73,22 +82,83 @@ function NewPurchaseOrderContent() {
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const [vendorDialogOpen, setVendorDialogOpen] = useState(false)
|
||||
const [vName, setVName] = useState("")
|
||||
const [vTerms, setVTerms] = useState("")
|
||||
const [vTaxReg, setVTaxReg] = useState("")
|
||||
const [vCurrency, setVCurrency] = useState("LKR")
|
||||
const [vErrors, setVErrors] = useState<Record<string, string>>({})
|
||||
const [vSubmitting, setVSubmitting] = useState(false)
|
||||
|
||||
const generatedVendorCode = vName.trim() ? generateVendorCode(vName, (vendors ?? []).map((v) => v.code)) : ""
|
||||
|
||||
function loadItems() {
|
||||
return itemsApi.list({ pageSize: 200, status: "Active" }).then((it) => setItems(it.items))
|
||||
}
|
||||
function loadVendors() {
|
||||
return vendorsApi.list({ pageSize: 200, status: "Active" }).then((ve) => setVendors(ve.items))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
itemsApi.list({ pageSize: 200, status: "Active" }),
|
||||
uomsApi.list(),
|
||||
warehousesApi.list(),
|
||||
vendorsApi.list({ pageSize: 200, status: "Active" }),
|
||||
])
|
||||
.then(([it, uo, wh, ve]) => {
|
||||
setItems(it.items)
|
||||
setUoms(uo.items)
|
||||
setWarehouses(wh.items)
|
||||
setVendors(ve.items)
|
||||
})
|
||||
.catch((err) => setLoadError(errorMessage(err)))
|
||||
loadItems(),
|
||||
uomsApi.list().then((uo) => setUoms(uo.items)),
|
||||
warehousesApi.list().then((wh) => setWarehouses(wh.items)),
|
||||
loadVendors(),
|
||||
]).catch((err) => setLoadError(errorMessage(err)))
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// A new item is created on the standalone item builder (too many fields for a modal here),
|
||||
// typically in another tab — refetch on refocus so it shows up in the line pickers without
|
||||
// the user having to reload this page and lose their draft.
|
||||
useEffect(() => {
|
||||
function onFocus() {
|
||||
loadItems().catch(() => {})
|
||||
}
|
||||
window.addEventListener("focus", onFocus)
|
||||
return () => window.removeEventListener("focus", onFocus)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
function resetVendorForm() {
|
||||
setVName("")
|
||||
setVTerms("")
|
||||
setVTaxReg("")
|
||||
setVCurrency("LKR")
|
||||
setVErrors({})
|
||||
}
|
||||
|
||||
async function handleCreateVendor() {
|
||||
const nextErrors: Record<string, string> = {}
|
||||
if (!vName.trim()) nextErrors.name = "Vendor name is required"
|
||||
if (!vCurrency.trim()) nextErrors.currency = "Currency is required"
|
||||
setVErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setVSubmitting(true)
|
||||
try {
|
||||
const result = await vendorsApi.create({
|
||||
code: generatedVendorCode,
|
||||
name: vName,
|
||||
terms: vTerms || null,
|
||||
taxReg: vTaxReg || null,
|
||||
currency: vCurrency,
|
||||
})
|
||||
await loadVendors()
|
||||
setVendorId(result.data.vendorId)
|
||||
toast.success("Vendor created", `${result.data.code} — ${result.data.name}`)
|
||||
setVendorDialogOpen(false)
|
||||
resetVendorForm()
|
||||
} catch (err) {
|
||||
// A 409 here means another creation raced ours for the same generated code — the
|
||||
// proactive de-dupe above only knows about vendors loaded when the dialog opened.
|
||||
toast.error("Could not create vendor", errorMessage(err))
|
||||
} finally {
|
||||
setVSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (requisitionId) {
|
||||
requisitionsApi
|
||||
@@ -236,43 +306,72 @@ function NewPurchaseOrderContent() {
|
||||
{!loading && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Vendor code</Label>
|
||||
<Select<number | null>
|
||||
value={vendorId}
|
||||
onValueChange={setVendorId}
|
||||
items={(vendors ?? []).map((v) => ({ label: v.code, value: v.vendorId }))}
|
||||
>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Select vendor code" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(vendors ?? []).map((v) => (
|
||||
<SelectItem key={v.vendorId} value={v.vendorId} className="text-base">
|
||||
{v.code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Vendor name</Label>
|
||||
<Select<number | null>
|
||||
value={vendorId}
|
||||
onValueChange={setVendorId}
|
||||
items={(vendors ?? []).map((v) => ({ label: v.name, value: v.vendorId }))}
|
||||
>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Select vendor name" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(vendors ?? []).map((v) => (
|
||||
<SelectItem key={v.vendorId} value={v.vendorId} className="text-base">
|
||||
{v.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex flex-col gap-2 sm:col-span-2">
|
||||
<Label className="text-base">Vendor</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select<number | null>
|
||||
value={vendorId}
|
||||
onValueChange={setVendorId}
|
||||
items={(vendors ?? []).map((v) => ({ label: `${v.code} — ${v.name}`, value: v.vendorId }))}
|
||||
>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Select vendor" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(vendors ?? []).map((v) => (
|
||||
<SelectItem key={v.vendorId} value={v.vendorId} className="text-base">
|
||||
{v.code} — {v.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Dialog open={vendorDialogOpen} onOpenChange={(o) => { setVendorDialogOpen(o); if (!o) resetVendorForm() }}>
|
||||
<DialogTrigger
|
||||
render={<Button type="button" variant="outline" size="icon-lg" aria-label="New vendor" title="New vendor" />}
|
||||
>
|
||||
<Plus className="size-5" />
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>New vendor</DialogTitle>
|
||||
<DialogDescription>Create a supplier record without leaving this PO. Its code is generated from the name.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!vErrors.name}>
|
||||
<FieldLabel htmlFor="po-v-name">Name</FieldLabel>
|
||||
<Input id="po-v-name" value={vName} onChange={(e) => setVName(e.target.value)} placeholder="Lanka Steel Traders (Pvt) Ltd" aria-invalid={!!vErrors.name} />
|
||||
<FieldError errors={[vErrors.name ? { message: vErrors.name } : undefined]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="po-v-code">Code (auto-generated)</FieldLabel>
|
||||
<Input id="po-v-code" value={generatedVendorCode} readOnly disabled placeholder="Enter a name to generate a code" className="text-muted-foreground" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="po-v-terms">Payment terms (optional)</FieldLabel>
|
||||
<Input id="po-v-terms" value={vTerms} onChange={(e) => setVTerms(e.target.value)} placeholder="NET30" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="po-v-taxreg">Tax registration (optional)</FieldLabel>
|
||||
<Input id="po-v-taxreg" value={vTaxReg} onChange={(e) => setVTaxReg(e.target.value)} placeholder="134567890-7000" />
|
||||
</Field>
|
||||
<Field data-invalid={!!vErrors.currency}>
|
||||
<FieldLabel htmlFor="po-v-currency">Currency</FieldLabel>
|
||||
<Input id="po-v-currency" value={vCurrency} onChange={(e) => setVCurrency(e.target.value)} placeholder="LKR" maxLength={3} aria-invalid={!!vErrors.currency} />
|
||||
<FieldError errors={[vErrors.currency ? { message: vErrors.currency } : undefined]} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setVendorDialogOpen(false)} disabled={vSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreateVendor} disabled={vSubmitting}>
|
||||
{vSubmitting ? "Creating…" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
{requisitionId && (
|
||||
<div className="flex flex-col justify-end pb-2.5 text-sm text-muted-foreground">From Requisition #{requisitionId}</div>
|
||||
@@ -287,10 +386,22 @@ function NewPurchaseOrderContent() {
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-foreground">Lines</h2>
|
||||
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, emptyLine()])}>
|
||||
<Plus className="size-5" />
|
||||
Add line
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/dashboard/products/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(buttonVariants({ variant: "outline" }))}
|
||||
title="Opens in a new tab — the item list here refreshes when you come back"
|
||||
>
|
||||
<ExternalLink className="size-5" />
|
||||
New item
|
||||
</Link>
|
||||
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, emptyLine()])}>
|
||||
<Plus className="size-5" />
|
||||
Add line
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lines.length > 0 && (
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ChevronLeft, ChevronRight, Plus, ShoppingCart } from "lucide-react"
|
||||
import { Check, ChevronLeft, ChevronRight, Eye, Pencil, Plus, ShoppingCart, Trash2 } from "lucide-react"
|
||||
|
||||
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||
import { vendorsApi } from "@/lib/api/vendors"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { PurchaseOrderStatus, PurchaseOrderSummary } from "@/types/procurement"
|
||||
@@ -16,6 +16,7 @@ import { Input } from "@/components/ui/input"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import { PoStatusBadge } from "@/components/procurement/status-badges"
|
||||
|
||||
type StatusFilter = PurchaseOrderStatus | "All"
|
||||
@@ -32,6 +33,8 @@ export default function PurchaseOrdersListPage() {
|
||||
const [query, setQuery] = useState("")
|
||||
const [status, setStatus] = useState<StatusFilter>("All")
|
||||
const [page, setPage] = useState(1)
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||
const [approvingId, setApprovingId] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
|
||||
@@ -60,6 +63,34 @@ export default function PurchaseOrdersListPage() {
|
||||
return vendors.find((v) => v.vendorId === vendorId)?.code ?? `#${vendorId}`
|
||||
}
|
||||
|
||||
async function handleDelete(po: PurchaseOrderSummary) {
|
||||
if (!window.confirm(`Delete draft ${po.docNo}? This cannot be undone.`)) return
|
||||
setDeletingId(po.poId)
|
||||
try {
|
||||
await purchaseOrdersApi.remove(po.poId)
|
||||
toast.success("Draft deleted", po.docNo)
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not delete purchase order", errorMessage(err))
|
||||
} finally {
|
||||
setDeletingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApprove(po: PurchaseOrderSummary) {
|
||||
if (!window.confirm(`Approve ${po.docNo}? It will be locked for editing once approved.`)) return
|
||||
setApprovingId(po.poId)
|
||||
try {
|
||||
const updated = await purchaseOrdersApi.submit(po.poId)
|
||||
toast.success("Purchase order approved", `${updated.docNo} — now ${updated.status}.`)
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not approve purchase order", errorMessage(err))
|
||||
} finally {
|
||||
setApprovingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const hasFilters = query.length > 0 || status !== "All"
|
||||
|
||||
return (
|
||||
@@ -133,24 +164,76 @@ export default function PurchaseOrdersListPage() {
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Grand total</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
|
||||
<TableHead className="h-12 w-44 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pos.map((po) => (
|
||||
<TableRow key={po.poId}>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-medium text-foreground hover:underline">
|
||||
{po.docNo}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{vendorCode(po.vendorId)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<PoStatusBadge status={po.status} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{po.totals.currency} {po.totals.grandTotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{new Date(po.createdAt).toLocaleString()}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{pos.map((po) => {
|
||||
const editable = isPoEditable(po.status)
|
||||
return (
|
||||
<TableRow key={po.poId}>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-medium text-foreground hover:underline">
|
||||
{po.docNo}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{vendorCode(po.vendorId)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<PoStatusBadge status={po.status} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{po.totals.currency} {po.totals.grandTotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{new Date(po.createdAt).toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<Link
|
||||
href={`/dashboard/procurement/purchase-orders/${po.poId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
|
||||
aria-label="View"
|
||||
title="View"
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
</Link>
|
||||
{editable && (
|
||||
<>
|
||||
<Link
|
||||
href={`/dashboard/procurement/purchase-orders/${po.poId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
|
||||
aria-label="Edit draft"
|
||||
title="Edit draft"
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Link>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Approve draft"
|
||||
title="Approve draft"
|
||||
disabled={approvingId === po.poId || deletingId === po.poId}
|
||||
onClick={() => handleApprove(po)}
|
||||
className="text-success hover:bg-success/10 hover:text-success"
|
||||
>
|
||||
<Check className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Delete draft"
|
||||
title="Delete draft"
|
||||
disabled={deletingId === po.poId || approvingId === po.poId}
|
||||
onClick={() => handleDelete(po)}
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
|
||||
+245
-245
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { Plus, Trash2, X } from "lucide-react"
|
||||
import { Plus, Trash2 } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CustomFieldType, StageInputSource } from "@/types/production"
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "./types"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Field, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
@@ -175,265 +176,264 @@ export function StageEditorPanel({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-y-auto rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:w-96">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-base font-bold text-foreground">Stage editor</h2>
|
||||
<button type="button" onClick={onClose} className="text-muted-foreground hover:text-foreground" aria-label="Close panel">
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<Dialog open onOpenChange={(next) => !next && onClose()}>
|
||||
<DialogContent className="max-h-[85vh] w-full max-w-lg overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Stage editor</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<Field>
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<Input value={data.name} disabled={readOnly} onChange={(e) => onChange({ name: e.target.value })} placeholder="e.g. Welding" />
|
||||
</Field>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Field>
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<Input value={data.name} disabled={readOnly} onChange={(e) => onChange({ name: e.target.value })} placeholder="e.g. Welding" />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Role label</FieldLabel>
|
||||
<Input
|
||||
value={data.roleLabel}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onChange({ roleLabel: e.target.value })}
|
||||
placeholder="e.g. QA"
|
||||
list="role-suggestions"
|
||||
/>
|
||||
<datalist id="role-suggestions">
|
||||
{ROLE_SUGGESTIONS.map((r) => (
|
||||
<option key={r} value={r} />
|
||||
))}
|
||||
</datalist>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Role label</FieldLabel>
|
||||
<Input
|
||||
value={data.roleLabel}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onChange({ roleLabel: e.target.value })}
|
||||
placeholder="e.g. QA"
|
||||
list="role-suggestions"
|
||||
/>
|
||||
<datalist id="role-suggestions">
|
||||
{ROLE_SUGGESTIONS.map((r) => (
|
||||
<option key={r} value={r} />
|
||||
))}
|
||||
</datalist>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Estimated minutes</FieldLabel>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={data.estimatedMinutes}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onChange({ estimatedMinutes: Number(e.target.value) || 0 })}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Estimated minutes</FieldLabel>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={data.estimatedMinutes}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onChange({ estimatedMinutes: Number(e.target.value) || 0 })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{/* Inputs */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-foreground">Inputs</p>
|
||||
{!readOnly && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={addInput}>
|
||||
<Plus className="size-4" />
|
||||
Add
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{data.inputs.length === 0 && <p className="text-sm text-muted-foreground">No inputs yet.</p>}
|
||||
{data.inputs.map((input) => (
|
||||
<div key={input.localId} className="rounded-lg border border-border p-2.5">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<Select<StageInputSource>
|
||||
value={input.source}
|
||||
onValueChange={(v) => v && changeInputSource(input.localId, v)}
|
||||
>
|
||||
<SelectTrigger className="h-8! flex-1 text-sm" disabled={readOnly}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Stock" className="text-sm">Stock</SelectItem>
|
||||
<SelectItem value="Upstream" className="text-sm">Upstream</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!readOnly && (
|
||||
<button type="button" onClick={() => removeInput(input.localId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove input">
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{input.source === "Stock" ? (
|
||||
<Select<number>
|
||||
value={input.itemId}
|
||||
onValueChange={(v) => v && pickInputItem(input, v)}
|
||||
{/* Inputs */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-foreground">Inputs</p>
|
||||
{!readOnly && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={addInput}>
|
||||
<Plus className="size-4" />
|
||||
Add
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{data.inputs.length === 0 && <p className="text-sm text-muted-foreground">No inputs yet.</p>}
|
||||
{data.inputs.map((input) => (
|
||||
<div key={input.localId} className="rounded-lg border border-border p-2.5">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<Select<StageInputSource>
|
||||
value={input.source}
|
||||
onValueChange={(v) => v && changeInputSource(input.localId, v)}
|
||||
>
|
||||
<SelectTrigger className="h-8! w-full text-sm" disabled={readOnly}>
|
||||
<SelectValue placeholder="Pick an item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{items.map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
|
||||
{i.name} · {i.sku}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Select<string>
|
||||
value={input.fromOutputKey}
|
||||
onValueChange={(v) => v && updateInput(input.localId, { fromOutputKey: v })}
|
||||
>
|
||||
<SelectTrigger className="h-8! w-full text-sm" disabled={readOnly || upstreamOptions.length === 0}>
|
||||
<SelectValue placeholder={upstreamOptions.length === 0 ? "No upstream stages connected" : "Pick an upstream output"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{upstreamOptions.map((o) => (
|
||||
<SelectItem key={o.outputKey} value={o.outputKey} className="text-sm">
|
||||
{o.stageName} — {o.outputName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<QtyRow
|
||||
qty={input.qtyPerBatch}
|
||||
uomId={input.uomId}
|
||||
uoms={uoms}
|
||||
readOnly={readOnly}
|
||||
onQtyChange={(qtyPerBatch) => updateInput(input.localId, { qtyPerBatch })}
|
||||
onUomChange={(uomId) => updateInput(input.localId, { uomId })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Outputs */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
Outputs{isTerminal && <span className="ml-1.5 font-normal text-muted-foreground">(terminal — finished good)</span>}
|
||||
</p>
|
||||
{!readOnly && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={addOutput}>
|
||||
<Plus className="size-4" />
|
||||
Add
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{data.outputs.length === 0 && <p className="text-sm text-muted-foreground">No outputs yet.</p>}
|
||||
{data.outputs.map((output) => (
|
||||
<div key={output.key} className="rounded-lg border border-border p-2.5">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
{isTerminal ? (
|
||||
<Select<number> value={output.itemId} onValueChange={(v) => v && pickOutputItem(output, v)}>
|
||||
<SelectTrigger className="h-8! flex-1 text-sm" disabled={readOnly}>
|
||||
<SelectValue placeholder="Pick the finished-good item" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{items.map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
|
||||
{i.name} · {i.sku}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="Stock" className="text-sm">Stock</SelectItem>
|
||||
<SelectItem value="Upstream" className="text-sm">Upstream</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={output.name}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => updateOutput(output.key, { name: e.target.value })}
|
||||
placeholder="Output name (work in progress)"
|
||||
className="h-8 flex-1 text-sm"
|
||||
/>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<button type="button" onClick={() => removeOutput(output.key)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove output">
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<QtyRow
|
||||
qty={output.qtyPerBatch}
|
||||
uomId={output.uomId}
|
||||
uoms={uoms}
|
||||
readOnly={readOnly}
|
||||
onQtyChange={(qtyPerBatch) => updateOutput(output.key, { qtyPerBatch })}
|
||||
onUomChange={(uomId) => updateOutput(output.key, { uomId })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<button type="button" onClick={() => removeInput(input.localId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove input">
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Custom fields */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-foreground">Custom fields</p>
|
||||
{!readOnly && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={addField}>
|
||||
<Plus className="size-4" />
|
||||
Add
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{data.fieldDefs.length === 0 && <p className="text-sm text-muted-foreground">No custom fields.</p>}
|
||||
{data.fieldDefs.map((field) => (
|
||||
<div key={field.localId} className="rounded-lg border border-border p-2.5">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Input
|
||||
value={field.label}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => updateField(field.localId, { label: e.target.value })}
|
||||
placeholder="Label"
|
||||
className="h-8 flex-1 text-sm"
|
||||
/>
|
||||
{!readOnly && (
|
||||
<button type="button" onClick={() => removeField(field.localId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove field">
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{field.key && <p className="mb-2 font-mono text-xs text-muted-foreground">key: {field.key}</p>}
|
||||
<div className="flex items-center gap-2">
|
||||
<Select<CustomFieldType>
|
||||
value={field.type}
|
||||
onValueChange={(v) => v && updateField(field.localId, { type: v })}
|
||||
>
|
||||
<SelectTrigger className="h-8! flex-1 text-sm" disabled={readOnly}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FIELD_TYPES.map((t) => (
|
||||
<SelectItem key={t} value={t} className="text-sm">{t}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={field.required}
|
||||
disabled={readOnly}
|
||||
onCheckedChange={(checked) => updateField(field.localId, { required: checked })}
|
||||
<div className="flex flex-col gap-2">
|
||||
{input.source === "Stock" ? (
|
||||
<Select<number>
|
||||
value={input.itemId}
|
||||
onValueChange={(v) => v && pickInputItem(input, v)}
|
||||
>
|
||||
<SelectTrigger className="h-8! w-full text-sm" disabled={readOnly}>
|
||||
<SelectValue placeholder="Pick an item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{items.map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
|
||||
{i.name} · {i.sku}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Select<string>
|
||||
value={input.fromOutputKey}
|
||||
onValueChange={(v) => v && updateInput(input.localId, { fromOutputKey: v })}
|
||||
>
|
||||
<SelectTrigger className="h-8! w-full text-sm" disabled={readOnly || upstreamOptions.length === 0}>
|
||||
<SelectValue placeholder={upstreamOptions.length === 0 ? "No upstream stages connected" : "Pick an upstream output"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{upstreamOptions.map((o) => (
|
||||
<SelectItem key={o.outputKey} value={o.outputKey} className="text-sm">
|
||||
{o.stageName} — {o.outputName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<QtyRow
|
||||
qty={input.qtyPerBatch}
|
||||
uomId={input.uomId}
|
||||
uoms={uoms}
|
||||
readOnly={readOnly}
|
||||
onQtyChange={(qtyPerBatch) => updateInput(input.localId, { qtyPerBatch })}
|
||||
onUomChange={(uomId) => updateInput(input.localId, { uomId })}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">Required</span>
|
||||
</div>
|
||||
</div>
|
||||
{field.type === "Select" && (
|
||||
<Input
|
||||
value={field.options.join(", ")}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => updateField(field.localId, { options: e.target.value.split(",").map((s) => s.trim()).filter(Boolean) })}
|
||||
placeholder="Options, comma separated"
|
||||
className="mt-2 h-8 text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!readOnly && (
|
||||
<Button type="button" variant="outline" className={cn("mt-2 text-destructive hover:bg-destructive/10")} onClick={onDelete}>
|
||||
<Trash2 className="size-4" />
|
||||
Delete stage
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Outputs */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
Outputs{isTerminal && <span className="ml-1.5 font-normal text-muted-foreground">(terminal — finished good)</span>}
|
||||
</p>
|
||||
{!readOnly && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={addOutput}>
|
||||
<Plus className="size-4" />
|
||||
Add
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{data.outputs.length === 0 && <p className="text-sm text-muted-foreground">No outputs yet.</p>}
|
||||
{data.outputs.map((output) => (
|
||||
<div key={output.key} className="rounded-lg border border-border p-2.5">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
{isTerminal ? (
|
||||
<Select<number> value={output.itemId} onValueChange={(v) => v && pickOutputItem(output, v)}>
|
||||
<SelectTrigger className="h-8! flex-1 text-sm" disabled={readOnly}>
|
||||
<SelectValue placeholder="Pick the finished-good item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{items.map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
|
||||
{i.name} · {i.sku}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={output.name}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => updateOutput(output.key, { name: e.target.value })}
|
||||
placeholder="Output name (work in progress)"
|
||||
className="h-8 flex-1 text-sm"
|
||||
/>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<button type="button" onClick={() => removeOutput(output.key)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove output">
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<QtyRow
|
||||
qty={output.qtyPerBatch}
|
||||
uomId={output.uomId}
|
||||
uoms={uoms}
|
||||
readOnly={readOnly}
|
||||
onQtyChange={(qtyPerBatch) => updateOutput(output.key, { qtyPerBatch })}
|
||||
onUomChange={(uomId) => updateOutput(output.key, { uomId })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom fields */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-foreground">Custom fields</p>
|
||||
{!readOnly && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={addField}>
|
||||
<Plus className="size-4" />
|
||||
Add
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{data.fieldDefs.length === 0 && <p className="text-sm text-muted-foreground">No custom fields.</p>}
|
||||
{data.fieldDefs.map((field) => (
|
||||
<div key={field.localId} className="rounded-lg border border-border p-2.5">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Input
|
||||
value={field.label}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => updateField(field.localId, { label: e.target.value })}
|
||||
placeholder="Label"
|
||||
className="h-8 flex-1 text-sm"
|
||||
/>
|
||||
{!readOnly && (
|
||||
<button type="button" onClick={() => removeField(field.localId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove field">
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{field.key && <p className="mb-2 font-mono text-xs text-muted-foreground">key: {field.key}</p>}
|
||||
<div className="flex items-center gap-2">
|
||||
<Select<CustomFieldType>
|
||||
value={field.type}
|
||||
onValueChange={(v) => v && updateField(field.localId, { type: v })}
|
||||
>
|
||||
<SelectTrigger className="h-8! flex-1 text-sm" disabled={readOnly}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FIELD_TYPES.map((t) => (
|
||||
<SelectItem key={t} value={t} className="text-sm">{t}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={field.required}
|
||||
disabled={readOnly}
|
||||
onCheckedChange={(checked) => updateField(field.localId, { required: checked })}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">Required</span>
|
||||
</div>
|
||||
</div>
|
||||
{field.type === "Select" && (
|
||||
<Input
|
||||
value={field.options.join(", ")}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => updateField(field.localId, { options: e.target.value.split(",").map((s) => s.trim()).filter(Boolean) })}
|
||||
placeholder="Options, comma separated"
|
||||
className="mt-2 h-8 text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!readOnly && (
|
||||
<Button type="button" variant="outline" className={cn("mt-2 text-destructive hover:bg-destructive/10")} onClick={onDelete}>
|
||||
<Trash2 className="size-4" />
|
||||
Delete stage
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -765,46 +765,44 @@ export default function TemplateBuilderPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex min-h-0 flex-1 gap-4">
|
||||
<div className="min-w-0 flex-1 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10">
|
||||
{mounted && (
|
||||
<ReactFlow
|
||||
nodes={displayNodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodesChange={locked ? undefined : onNodesChange}
|
||||
onEdgesChange={locked ? undefined : onEdgesChange}
|
||||
onNodesDelete={locked ? undefined : onNodesDelete}
|
||||
onConnect={locked ? undefined : onConnect}
|
||||
onNodeClick={onNodeClick}
|
||||
onPaneClick={onPaneClick}
|
||||
nodesDraggable={!locked}
|
||||
nodesConnectable={!locked}
|
||||
elementsSelectable
|
||||
colorMode={resolvedTheme === "dark" ? "dark" : "light"}
|
||||
fitView
|
||||
>
|
||||
<Background />
|
||||
<Controls showInteractive={!locked} />
|
||||
<MiniMap pannable zoomable />
|
||||
</ReactFlow>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedNode && (
|
||||
<StageEditorPanel
|
||||
data={selectedNode.data as StageNodeData}
|
||||
isTerminal={analysis.terminalIds.has(selectedNode.id)}
|
||||
upstreamOptions={upstreamOptions}
|
||||
items={items}
|
||||
uoms={uoms}
|
||||
readOnly={locked}
|
||||
onChange={(patch) => updateNodeData(selectedNode.id, patch)}
|
||||
onDelete={() => deleteNode(selectedNode.id)}
|
||||
onClose={() => setSelectedNodeId(null)}
|
||||
/>
|
||||
<div className="min-h-0 flex-1 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10">
|
||||
{mounted && (
|
||||
<ReactFlow
|
||||
nodes={displayNodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodesChange={locked ? undefined : onNodesChange}
|
||||
onEdgesChange={locked ? undefined : onEdgesChange}
|
||||
onNodesDelete={locked ? undefined : onNodesDelete}
|
||||
onConnect={locked ? undefined : onConnect}
|
||||
onNodeClick={onNodeClick}
|
||||
onPaneClick={onPaneClick}
|
||||
nodesDraggable={!locked}
|
||||
nodesConnectable={!locked}
|
||||
elementsSelectable
|
||||
colorMode={resolvedTheme === "dark" ? "dark" : "light"}
|
||||
fitView
|
||||
>
|
||||
<Background />
|
||||
<Controls showInteractive={!locked} />
|
||||
<MiniMap pannable zoomable />
|
||||
</ReactFlow>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedNode && (
|
||||
<StageEditorPanel
|
||||
data={selectedNode.data as StageNodeData}
|
||||
isTerminal={analysis.terminalIds.has(selectedNode.id)}
|
||||
upstreamOptions={upstreamOptions}
|
||||
items={items}
|
||||
uoms={uoms}
|
||||
readOnly={locked}
|
||||
onChange={(patch) => updateNodeData(selectedNode.id, patch)}
|
||||
onDelete={() => deleteNode(selectedNode.id)}
|
||||
onClose={() => setSelectedNodeId(null)}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
|
||||
+21
-13
@@ -5,8 +5,9 @@ import Link from "next/link"
|
||||
import { ChevronLeft, ChevronRight, Eye, Pencil, Plus, Search, Trash2, Truck } from "lucide-react"
|
||||
|
||||
import { vendorsApi } from "@/lib/api/vendors"
|
||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { generateVendorCode } from "@/lib/vendor-code"
|
||||
import { EntityStatus, PaginationMeta } from "@/types/common"
|
||||
import { Vendor } from "@/types/master-data"
|
||||
|
||||
@@ -44,7 +45,6 @@ export default function VendorsPage() {
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [code, setCode] = useState("")
|
||||
const [name, setName] = useState("")
|
||||
const [terms, setTerms] = useState("")
|
||||
const [taxReg, setTaxReg] = useState("")
|
||||
@@ -52,8 +52,14 @@ export default function VendorsPage() {
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
// Separate from the paginated table list above — this needs every existing code (up to the
|
||||
// server's page-size cap) to de-dupe against, not just the current page's 5 rows.
|
||||
const [allVendorCodes, setAllVendorCodes] = useState<string[]>([])
|
||||
|
||||
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||
|
||||
const generatedCode = name.trim() ? generateVendorCode(name, allVendorCodes) : ""
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
|
||||
return () => clearTimeout(timeout)
|
||||
@@ -75,8 +81,11 @@ export default function VendorsPage() {
|
||||
|
||||
useEffect(load, [query, status, page])
|
||||
|
||||
useEffect(() => {
|
||||
vendorsApi.list({ pageSize: 200 }).then((res) => setAllVendorCodes(res.items.map((v) => v.code))).catch(() => {})
|
||||
}, [])
|
||||
|
||||
function resetForm() {
|
||||
setCode("")
|
||||
setName("")
|
||||
setTerms("")
|
||||
setTaxReg("")
|
||||
@@ -86,7 +95,6 @@ export default function VendorsPage() {
|
||||
|
||||
async function handleCreate() {
|
||||
const nextErrors: Record<string, string> = {}
|
||||
if (!code.trim()) nextErrors.code = "Vendor code is required"
|
||||
if (!name.trim()) nextErrors.name = "Vendor name is required"
|
||||
if (!currency.trim()) nextErrors.currency = "Currency is required"
|
||||
setErrors(nextErrors)
|
||||
@@ -94,14 +102,15 @@ export default function VendorsPage() {
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const result = await vendorsApi.create({ code, name, terms: terms || null, taxReg: taxReg || null, currency })
|
||||
const result = await vendorsApi.create({ code: generatedCode, name, terms: terms || null, taxReg: taxReg || null, currency })
|
||||
toast.success("Vendor created", `${result.data.code} — ${result.data.name}`)
|
||||
setOpen(false)
|
||||
resetForm()
|
||||
load()
|
||||
setAllVendorCodes((codes) => [...codes, result.data.code])
|
||||
} catch (err) {
|
||||
const fe = fieldErrors(err)
|
||||
if (fe?.code) setErrors({ code: fe.code })
|
||||
// A 409 here means another creation raced ours for the same generated code — the
|
||||
// proactive de-dupe above only knows about codes loaded when the dialog opened.
|
||||
toast.error("Could not create vendor", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
@@ -145,19 +154,18 @@ export default function VendorsPage() {
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>New vendor</DialogTitle>
|
||||
<DialogDescription>Create a supplier record.</DialogDescription>
|
||||
<DialogDescription>Create a supplier record. Its code is generated from the name.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!errors.code}>
|
||||
<FieldLabel htmlFor="v-code">Code</FieldLabel>
|
||||
<Input id="v-code" value={code} onChange={(e) => setCode(e.target.value)} placeholder="VN-005" aria-invalid={!!errors.code} />
|
||||
<FieldError errors={[errors.code ? { message: errors.code } : undefined]} />
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.name}>
|
||||
<FieldLabel htmlFor="v-name">Name</FieldLabel>
|
||||
<Input id="v-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Lanka Steel Traders (Pvt) Ltd" aria-invalid={!!errors.name} />
|
||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="v-code">Code (auto-generated)</FieldLabel>
|
||||
<Input id="v-code" value={generatedCode} readOnly disabled placeholder="Enter a name to generate a code" className="text-muted-foreground" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="v-terms">Payment terms (optional)</FieldLabel>
|
||||
<Input id="v-terms" value={terms} onChange={(e) => setTerms(e.target.value)} placeholder="NET30" />
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -37,6 +37,11 @@ import {
|
||||
ReceivedFromType,
|
||||
CreateReceivedChequeRequest,
|
||||
UpdateReceivedChequeStatusRequest,
|
||||
CHEQUE_BOOK_STATUS_BY_CODE,
|
||||
CHEQUE_PAGE_ISSUE_STATUS_BY_CODE,
|
||||
PAYEE_TYPE_BY_CODE,
|
||||
RECEIVED_FROM_TYPE_BY_CODE,
|
||||
RECEIVED_CHEQUE_STATUS_BY_CODE,
|
||||
} from "@/types/general-ledger"
|
||||
|
||||
const GL_BASE = "/api/v1/gl"
|
||||
@@ -257,50 +262,100 @@ export const cashAccountTypesApi = {
|
||||
* routes, not a numeric id. No `list()`/`get()` for pages standalone — a book's pages are always
|
||||
* read via `get(chequeBookNo, true)`'s `pages[]`, which is the only place this frontend needs them.
|
||||
*/
|
||||
// GL sends `ChequeBook.status`/`ChequePage.issueStatus`/`ChequePage.payeeType`/
|
||||
// `ReceivedCheque.receivedFromType`/`ReceivedCheque.status` as raw integers, not their string
|
||||
// name (06_Enums_Reference.md — no global JsonStringEnumConverter on GL's side; see the long
|
||||
// comment above the `*_BY_CODE` maps in types/general-ledger.ts for why). These `Raw*` shapes
|
||||
// describe exactly what GL's JSON actually contains for those fields; the `map*` functions below
|
||||
// translate them into this frontend's normal string-enum `ChequeBook`/`ChequePage`/`ReceivedCheque`
|
||||
// types immediately after each call returns, so every other file in this app can keep comparing
|
||||
// against `ChequeBookStatus.Active` etc. exactly as before.
|
||||
type RawChequePage = Omit<ChequePage, "issueStatus" | "payeeType"> & {
|
||||
issueStatus: number
|
||||
payeeType: number | null
|
||||
}
|
||||
type RawChequeBook = Omit<ChequeBook, "status" | "pages"> & {
|
||||
status: number
|
||||
pages: RawChequePage[]
|
||||
}
|
||||
type RawReceivedCheque = Omit<ReceivedCheque, "receivedFromType" | "status"> & {
|
||||
receivedFromType: number
|
||||
status: number
|
||||
}
|
||||
|
||||
function mapChequePage(raw: RawChequePage): ChequePage {
|
||||
return {
|
||||
...raw,
|
||||
issueStatus: CHEQUE_PAGE_ISSUE_STATUS_BY_CODE[raw.issueStatus],
|
||||
payeeType: raw.payeeType == null ? null : PAYEE_TYPE_BY_CODE[raw.payeeType],
|
||||
}
|
||||
}
|
||||
|
||||
function mapChequeBook(raw: RawChequeBook): ChequeBook {
|
||||
return {
|
||||
...raw,
|
||||
status: CHEQUE_BOOK_STATUS_BY_CODE[raw.status],
|
||||
pages: (raw.pages ?? []).map(mapChequePage),
|
||||
}
|
||||
}
|
||||
|
||||
function mapReceivedCheque(raw: RawReceivedCheque): ReceivedCheque {
|
||||
return {
|
||||
...raw,
|
||||
receivedFromType: RECEIVED_FROM_TYPE_BY_CODE[raw.receivedFromType],
|
||||
status: RECEIVED_CHEQUE_STATUS_BY_CODE[raw.status],
|
||||
}
|
||||
}
|
||||
|
||||
export const chequeBooksApi = {
|
||||
list(params?: {
|
||||
async list(params?: {
|
||||
bankAccountId?: number
|
||||
branchId?: number
|
||||
status?: ChequeBookStatus
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}): Promise<GlPagedResult<ChequeBook>> {
|
||||
return glRequest<GlPagedResult<ChequeBook>>("/cheque-books", { query: { ...params } })
|
||||
const res = await glRequest<GlPagedResult<RawChequeBook>>("/cheque-books", { query: { ...params } })
|
||||
return { ...res, items: res.items.map(mapChequeBook) }
|
||||
},
|
||||
|
||||
/** `expandPages` maps to GL's `?expand=pages` — omit it for just the book's own fields. */
|
||||
get(chequeBookNo: string, expandPages = false): Promise<ChequeBook> {
|
||||
return glRequest<ChequeBook>(`/cheque-books/${encodeURIComponent(chequeBookNo)}`, {
|
||||
async get(chequeBookNo: string, expandPages = false): Promise<ChequeBook> {
|
||||
const res = await glRequest<RawChequeBook>(`/cheque-books/${encodeURIComponent(chequeBookNo)}`, {
|
||||
query: expandPages ? { expand: "pages" } : undefined,
|
||||
})
|
||||
return mapChequeBook(res)
|
||||
},
|
||||
|
||||
/** Auto-generates every leaf (`totalLeaves` `ChequePage` rows, all `Unused`) in the same call — the response's `pages[]` already has them. */
|
||||
create(request: CreateChequeBookRequest): Promise<ChequeBook> {
|
||||
return glRequest<ChequeBook>("/cheque-books", { method: "POST", body: request })
|
||||
async create(request: CreateChequeBookRequest): Promise<ChequeBook> {
|
||||
const res = await glRequest<RawChequeBook>("/cheque-books", { method: "POST", body: request })
|
||||
return mapChequeBook(res)
|
||||
},
|
||||
}
|
||||
|
||||
export const chequePagesApi = {
|
||||
issue(chequeNo: string, request: IssueChequePageRequest): Promise<ChequePage> {
|
||||
return glRequest<ChequePage>(`/cheque-pages/${encodeURIComponent(chequeNo)}/issue`, {
|
||||
async issue(chequeNo: string, request: IssueChequePageRequest): Promise<ChequePage> {
|
||||
const res = await glRequest<RawChequePage>(`/cheque-pages/${encodeURIComponent(chequeNo)}/issue`, {
|
||||
method: "PUT",
|
||||
body: request,
|
||||
})
|
||||
return mapChequePage(res)
|
||||
},
|
||||
|
||||
/** `Clear`/`Bounce`/`Cancel`/`Void` — only valid from certain `issueStatus` values, see `types/general-ledger.ts`'s `ChequePageStatusAction`. */
|
||||
updateStatus(chequeNo: string, request: UpdateChequePageStatusRequest): Promise<ChequePage> {
|
||||
return glRequest<ChequePage>(`/cheque-pages/${encodeURIComponent(chequeNo)}/status`, {
|
||||
async updateStatus(chequeNo: string, request: UpdateChequePageStatusRequest): Promise<ChequePage> {
|
||||
const res = await glRequest<RawChequePage>(`/cheque-pages/${encodeURIComponent(chequeNo)}/status`, {
|
||||
method: "PUT",
|
||||
body: request,
|
||||
})
|
||||
return mapChequePage(res)
|
||||
},
|
||||
}
|
||||
|
||||
/** Received Cheques — cheques received from customers/suppliers/others, deliberately unlinked to any `ChequeBook`. */
|
||||
export const receivedChequesApi = {
|
||||
list(params?: {
|
||||
async list(params?: {
|
||||
companyId?: number
|
||||
branchId?: number
|
||||
status?: ReceivedChequeStatus
|
||||
@@ -308,15 +363,18 @@ export const receivedChequesApi = {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}): Promise<GlPagedResult<ReceivedCheque>> {
|
||||
return glRequest<GlPagedResult<ReceivedCheque>>("/received-cheques", { query: { ...params } })
|
||||
const res = await glRequest<GlPagedResult<RawReceivedCheque>>("/received-cheques", { query: { ...params } })
|
||||
return { ...res, items: res.items.map(mapReceivedCheque) }
|
||||
},
|
||||
|
||||
create(request: CreateReceivedChequeRequest): Promise<ReceivedCheque> {
|
||||
return glRequest<ReceivedCheque>("/received-cheques", { method: "POST", body: request })
|
||||
async create(request: CreateReceivedChequeRequest): Promise<ReceivedCheque> {
|
||||
const res = await glRequest<RawReceivedCheque>("/received-cheques", { method: "POST", body: request })
|
||||
return mapReceivedCheque(res)
|
||||
},
|
||||
|
||||
/** `Deposit`/`Clear`/`Return`/`Cancel` — only valid from certain statuses, see `types/general-ledger.ts`'s `ReceivedChequeStatusAction`. */
|
||||
updateStatus(id: number, request: UpdateReceivedChequeStatusRequest): Promise<ReceivedCheque> {
|
||||
return glRequest<ReceivedCheque>(`/received-cheques/${id}/status`, { method: "PUT", body: request })
|
||||
async updateStatus(id: number, request: UpdateReceivedChequeStatusRequest): Promise<ReceivedCheque> {
|
||||
const res = await glRequest<RawReceivedCheque>(`/received-cheques/${id}/status`, { method: "PUT", body: request })
|
||||
return mapReceivedCheque(res)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/** First word of the name, uppercased and stripped to alphanumerics — falls back to "VN"
|
||||
* so an empty/punctuation-only name still yields a usable base. Mirrors the warehouse
|
||||
* code generator (app/dashboard/warehouse/page.tsx). */
|
||||
function vendorCodeBase(name: string): string {
|
||||
const firstWord = name.trim().split(/\s+/)[0] ?? ""
|
||||
const cleaned = firstWord.toUpperCase().replace(/[^A-Z0-9]/g, "")
|
||||
return cleaned.slice(0, 10) || "VN"
|
||||
}
|
||||
|
||||
/** Appends a numeric suffix until the code doesn't collide with an existing one — the
|
||||
* backend enforces global uniqueness (409 on conflict) but has no generation of its own. */
|
||||
export function generateVendorCode(name: string, existingCodes: string[]): string {
|
||||
const base = `VN-${vendorCodeBase(name)}`
|
||||
if (!existingCodes.includes(base)) return base
|
||||
let suffix = 2
|
||||
while (existingCodes.includes(`${base}${suffix}`)) suffix += 1
|
||||
return `${base}${suffix}`
|
||||
}
|
||||
@@ -410,6 +410,59 @@ export enum ReceivedChequeStatusAction {
|
||||
Cancel = "Cancel",
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirmed (`06_Enums_Reference.md`, user-supplied) — GL has **no global `JsonStringEnumConverter`**
|
||||
* registered. That's documented there as a request-body binding gotcha (a JSON-body enum field must
|
||||
* be sent as its string name, parsed server-side via `Enum.TryParse`), but the same missing converter
|
||||
* also governs the other direction: every one of these five fields is a real enum-typed property on
|
||||
* GL's own response DTO (backed by an `integer` DB column, per that doc's "Persisted enums" table),
|
||||
* so with no converter registered, GL's JSON response serializes each one as its **raw integer**
|
||||
* (`1`/`2`/`3`/...), not the name — confirmed live by the user ("most status has integer numbers").
|
||||
* Request bodies/query-string filters are unaffected and still take the string name as before (a
|
||||
* JSON-body enum field is independently declared `string` server-side, and query-string enum
|
||||
* binding parses names natively) — only inbound response values need translating. These maps do
|
||||
* that translation, keyed by the exact integer values `06_Enums_Reference.md` documents; applied in
|
||||
* `lib/api/general-ledger.ts` immediately after each GL call returns, so every consumer of
|
||||
* `ChequeBook`/`ChequePage`/`ReceivedCheque` in this frontend keeps working with the same string
|
||||
* enum values as before and never has to know GL sent a number.
|
||||
*/
|
||||
export const CHEQUE_BOOK_STATUS_BY_CODE: Record<number, ChequeBookStatus> = {
|
||||
1: ChequeBookStatus.Active,
|
||||
2: ChequeBookStatus.Completed,
|
||||
3: ChequeBookStatus.Cancelled,
|
||||
}
|
||||
|
||||
export const CHEQUE_PAGE_ISSUE_STATUS_BY_CODE: Record<number, ChequePageIssueStatus> = {
|
||||
1: ChequePageIssueStatus.Unused,
|
||||
2: ChequePageIssueStatus.Issued,
|
||||
3: ChequePageIssueStatus.Cleared,
|
||||
4: ChequePageIssueStatus.Bounced,
|
||||
5: ChequePageIssueStatus.Cancelled,
|
||||
6: ChequePageIssueStatus.Void,
|
||||
}
|
||||
|
||||
/** `ChequePage.payeeType` is nullable — only set once a page is issued (`06_Enums_Reference.md`). */
|
||||
export const PAYEE_TYPE_BY_CODE: Record<number, PayeeType> = {
|
||||
1: PayeeType.Supplier,
|
||||
2: PayeeType.Customer,
|
||||
3: PayeeType.Employee,
|
||||
4: PayeeType.Other,
|
||||
}
|
||||
|
||||
export const RECEIVED_FROM_TYPE_BY_CODE: Record<number, ReceivedFromType> = {
|
||||
1: ReceivedFromType.Customer,
|
||||
2: ReceivedFromType.Supplier,
|
||||
3: ReceivedFromType.Other,
|
||||
}
|
||||
|
||||
export const RECEIVED_CHEQUE_STATUS_BY_CODE: Record<number, ReceivedChequeStatus> = {
|
||||
1: ReceivedChequeStatus.Received,
|
||||
2: ReceivedChequeStatus.Deposited,
|
||||
3: ReceivedChequeStatus.Cleared,
|
||||
4: ReceivedChequeStatus.Returned,
|
||||
5: ReceivedChequeStatus.Cancelled,
|
||||
}
|
||||
|
||||
/**
|
||||
* A single leaf of a Cheque Book. GL's own reference confirms every field named in the `issue`
|
||||
* request body plus `issueStatus`/`printedAt`/`clearedDate`/`clearedByBank`/`cancelReason` in
|
||||
|
||||
@@ -520,6 +520,7 @@ that role sees the sidebar entries — normal onboarding, not a bug.
|
||||
spread across the available width instead of stretching a single narrow column. Modals
|
||||
(`ChequePageDialog`/`ReceivedChequeDialog`) were deliberately left at their existing fixed width —
|
||||
a dialog is supposed to stay narrow, this complaint was about full-page create forms only.
|
||||
- [x] **Cheque Management status/type fields were displaying raw integers, not names (2026-08-05, user-reported + confirmed live) — fixed by mapping GL's response integers to this frontend's string enums at the API boundary.** User supplied GL's own `06_Enums_Reference.md`: GL has no global `JsonStringEnumConverter`, so while a JSON-**body** enum field is independently declared `string` server-side (and a query-string enum filter binds natively by name — both already correct here, unaffected), a real enum-typed **response** DTO property serializes as its raw underlying integer with no converter to turn it back into a name. `ChequeBook.status`, `ChequePage.issueStatus`, `ChequePage.payeeType`, `ReceivedCheque.receivedFromType`, and `ReceivedCheque.status` are exactly that — genuine DB-backed enum properties on GL's response DTOs — so every one of them was arriving as `1`/`2`/`3`/... instead of `"Active"`/`"Issued"`/`"Supplier"`, silently breaking every `===` comparison this frontend does against its own string enums (list badges, the create-form's own `<Select>` values, and both dialogs' status-based available-actions logic), not just the visible label. Added five `*_BY_CODE` lookup maps to `types/general-ledger.ts` (`CHEQUE_BOOK_STATUS_BY_CODE`/`CHEQUE_PAGE_ISSUE_STATUS_BY_CODE`/`PAYEE_TYPE_BY_CODE`/`RECEIVED_FROM_TYPE_BY_CODE`/`RECEIVED_CHEQUE_STATUS_BY_CODE`), keyed by the exact integer values `06_Enums_Reference.md` documents. Applied them in `lib/api/general-ledger.ts` via new `Raw*` types (describing what GL's JSON for these fields actually is: `number`/`number | null`) and `mapChequeBook`/`mapChequePage`/`mapReceivedCheque` helpers, wired into every `chequeBooksApi`/`chequePagesApi`/`receivedChequesApi` method that returns one of these shapes — the translation happens once, at the API boundary, so every existing page/dialog/badge map keeps comparing against the same string enum values as before and needed zero changes itself. Checked every other enum in `06_Enums_Reference.md`'s "Persisted enums" table (`JournalEntryStatus`/`PeriodStatus`/`TaxCalculationBasis`/`TaxAppliesTo`/`DepreciationMethod`/`FixedAssetStatus`/`AuditCategory`/`AuditAction`) against this frontend — none are consumed anywhere (no Journal Entry/Fiscal Period/Tax Code/Fixed Asset/Audit Log UI exists here), so Cheque Management is the complete fix, not a partial one. Verified: `tsc --noEmit`/`eslint` clean on both touched files.
|
||||
- [ ] **Not done — internal ERPCore→GL wiring.** Unrelated to this revision, still deferred
|
||||
(`docs/12` §6).
|
||||
- [ ] **Deferred — bank/cash account edit.** Needs GL service changes first (§4); now needs them for
|
||||
|
||||
@@ -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