sales & grn fixers

This commit is contained in:
2026-10-04 15:31:59 +05:30
parent cea433e320
commit 3d3dbbbb59
52 changed files with 20761 additions and 482 deletions
@@ -23,8 +23,9 @@ public sealed class CustomersController : ApiControllerBase
[FromQuery] PageQuery query,
[FromQuery] EntityStatus? status,
[FromQuery] CustomerType? customerType,
CancellationToken ct)
=> Ok(await _customers.ListAsync(query, status, customerType, ct));
CancellationToken ct,
[FromQuery] bool includeCashCustomer = false)
=> Ok(await _customers.ListAsync(query, status, customerType, includeCashCustomer, ct));
[HttpGet("{customerId:int}")]
[ProducesResponseType(typeof(CustomerDto), StatusCodes.Status200OK)]
@@ -0,0 +1,45 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Config;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// GRN configuration endpoints — warehouse selection and the default receiving warehouse.
/// <para>
/// <b>Authorization:</b> gated on the Receiving nav policy (the New GRN form has to read it), same
/// Phase-1 posture as <see cref="SalesConfigController"/>.
/// </para>
/// </summary>
[Route("api/v1/grn-config")]
[Authorize(Policy = NavPolicies.Prefix + "receiving")]
public sealed class GrnConfigController : ApiControllerBase
{
private readonly IGrnConfigService _config;
public GrnConfigController(IGrnConfigService config) => _config = config;
[HttpGet]
[ProducesResponseType(typeof(GrnConfigDto), StatusCodes.Status200OK)]
public async Task<ActionResult<GrnConfigDto>> Get(CancellationToken ct)
{
var result = await _config.GetAsync(ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPut]
[ProducesResponseType(typeof(GrnConfigDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<GrnConfigDto>> Update(
[FromBody] UpdateGrnConfigRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _config.UpdateAsync(request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
}
@@ -51,6 +51,16 @@ public sealed class SalesInvoicesController : ApiControllerBase
return Ok(new SalesStockAvailabilityDto(itemId, warehouseId, isStocked, available));
}
// Feeds New Sale's "Add item" dropdown with each item's issuable stock at the sale's warehouse.
// Under "sales" for the same reason as above; items absent from the list have no stock there.
[HttpGet("stock-summary")]
[ProducesResponseType(typeof(IReadOnlyList<SalesItemStockDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<SalesItemStockDto>>> StockSummary([FromQuery] int warehouseId, CancellationToken ct)
{
var available = await _stock.GetAvailableByItemAsync(warehouseId, ct);
return Ok(available.Select(kv => new SalesItemStockDto(kv.Key, kv.Value)).ToList());
}
// Lets the New Sale form pre-fill Unit price for items with no flat selling price, using the
// same resolution the server applies on save.
[HttpGet("suggested-price")]
@@ -143,6 +153,18 @@ public sealed class SalesInvoicesController : ApiControllerBase
return Created($"/api/v1/sales-invoices/{salesInvoiceId}/payments/{dto.SalesInvoicePaymentId}", dto);
}
/// <summary>
/// Pay several Posted invoices of one (non-cash) customer at once, split across payment lines;
/// applied oldest invoice first under a single GL journal entry. Returns one payment per split.
/// </summary>
[HttpPost("payments/bulk")]
[ProducesResponseType(typeof(IReadOnlyList<SalesInvoicePaymentDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<IReadOnlyList<SalesInvoicePaymentDto>>> PayBulk([FromBody] CreateBulkSalesInvoicePaymentRequest request, CancellationToken ct)
=> Ok(await _payments.PayBulkAsync(request, ct));
/// <summary>Payment history for this invoice, newest first.</summary>
[HttpGet("{salesInvoiceId:int}/payments")]
[ProducesResponseType(typeof(IReadOnlyList<SalesInvoicePaymentDto>), StatusCodes.Status200OK)]
@@ -0,0 +1,41 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// GRN (goods receipt) configuration — a singleton row, same shape as <see cref="SalesConfig"/>.
/// Controls which warehouse goods are received into.
/// </summary>
public class GrnConfig
{
/// <summary>Always 1 - the singleton row's id.</summary>
public const int SingletonId = 1;
public int ConfigId { get; set; }
/// <summary>When on (default) and no <see cref="DefaultWarehouseId"/> is saved, New GRN shows
/// a Warehouse picker. When off, a default must be saved.</summary>
public bool AllowWarehouseSelection { get; set; } = true;
/// <summary>Default receiving warehouse. Whenever set, New GRN shows only this warehouse (locked)
/// and every GRN is forced onto it server-side (see GrnConfigService.GetForcedWarehouseIdAsync).
/// Required while <see cref="AllowWarehouseSelection"/> is off; clearing it brings the picker back.</summary>
public int? DefaultWarehouseId { get; set; }
public Warehouse? DefaultWarehouse { get; set; }
/// <summary>When on (default), GRN lines may carry a discount % (the New GRN "Disc %" column and
/// header rate); when off, they are hidden and a discount above zero is rejected server-side
/// (see GrnService.CreateAsync).</summary>
public bool AllowDiscounts { get; set; } = true;
/// <summary>When on (default), GRN lines carry VAT (entered, or the item's tax rate when left
/// empty); when off, the VAT columns are hidden, every line is received at 0% VAT, and a VAT
/// above zero is rejected server-side (see GrnService.CreateAsync).</summary>
public bool AllowVat { get; set; } = true;
public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; }
public User? UpdatedByUser { get; set; }
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
public uint RowVersion { get; set; }
}
@@ -0,0 +1,22 @@
using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Config;
/// <summary>GRN configuration resource.</summary>
public sealed record GrnConfigDto(
bool AllowWarehouseSelection, int? DefaultWarehouseId, string? DefaultWarehouseName, bool AllowDiscounts, bool AllowVat,
DateTime? UpdatedAt, int? UpdatedBy);
/// <summary>
/// Full replacement of the flags. <c>UpdatedBy</c> is derived from the token, never posted.
/// Nullable bool so an omitted flag is a 400 rather than silently binding to <c>false</c>
/// (same reasoning as <see cref="UpdateSalesConfigRequest"/>).
/// </summary>
public sealed class UpdateGrnConfigRequest
{
[Required] public bool? AllowWarehouseSelection { get; set; }
/// <summary>Required when <see cref="AllowWarehouseSelection"/> is false.</summary>
public int? DefaultWarehouseId { get; set; }
[Required] public bool? AllowDiscounts { get; set; }
[Required] public bool? AllowVat { get; set; }
}
+2 -1
View File
@@ -133,5 +133,6 @@ public sealed class ReorderSettingInput
public sealed class UpdateReorderRequest
{
[Required, MinLength(1)] public List<ReorderSettingInput> Settings { get; set; } = new();
/// <summary>The complete set — an empty list removes every rule for the item.</summary>
[Required] public List<ReorderSettingInput> Settings { get; set; } = new();
}
@@ -34,6 +34,28 @@ public sealed class CreateSalesInvoicePaymentRequest
[Range(0.01, double.MaxValue)] public decimal Amount { get; set; }
[Required] public long GlBankAccountId { get; set; }
[StringLength(100)] public string? Reference { get; set; }
/// <summary>When the customer paid; also the GL entry date. Omitted ⇒ today. Never in the future.</summary>
public DateOnly? PaymentDate { get; set; }
}
/// <summary>One way the customer paid in a multi-invoice payment: an amount into a cash/bank account.</summary>
public sealed class BulkSalesInvoicePaymentLine
{
[Range(0.01, double.MaxValue)] public decimal Amount { get; set; }
[Required] public long GlBankAccountId { get; set; }
}
/// <summary>
/// Pay several Posted invoices of one customer at once (never the generic cash customer). The lines'
/// total is applied oldest invoice first and may be less than the invoices' combined balance.
/// </summary>
public sealed class CreateBulkSalesInvoicePaymentRequest
{
[Required, MinLength(1)] public List<int> SalesInvoiceIds { get; set; } = [];
[Required, MinLength(1)] public List<BulkSalesInvoicePaymentLine> Lines { get; set; } = [];
[StringLength(100)] public string? Reference { get; set; }
/// <summary>When the customer paid; also the GL entry date. Omitted ⇒ today. Never in the future.</summary>
public DateOnly? PaymentDate { get; set; }
}
public sealed record SalesInvoicePostingIssueDto(
@@ -52,6 +74,10 @@ public sealed record SalesSuggestedPriceDto(int ItemId, int WarehouseId, decimal
/// per-line shortage warning. <c>IsStocked</c> false = non-stock item, never limited.</summary>
public sealed record SalesStockAvailabilityDto(int ItemId, int WarehouseId, bool IsStocked, decimal Available);
/// <summary>Issuable stock (on-hand less on-hold) for one item at a warehouse — one row per item
/// that has ever had stock there, for New Sale's "Add item" dropdown.</summary>
public sealed record SalesItemStockDto(int ItemId, decimal Available);
public sealed class CreateSalesInvoiceLineRequest
{
[Required] public int ItemId { get; set; }
@@ -0,0 +1,42 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
/// <summary>
/// Configures the singleton GRN-configuration row. The check constraint is what makes
/// "singleton" a database guarantee rather than a convention (mirrors SalesConfig).
/// </summary>
public sealed class GrnConfigConfiguration : IEntityTypeConfiguration<GrnConfig>
{
public void Configure(EntityTypeBuilder<GrnConfig> builder)
{
builder.ToTable("grn_config", t =>
t.HasCheckConstraint("ck_grn_config_singleton", $"\"ConfigId\" = {GrnConfig.SingletonId}"));
builder.HasKey(c => c.ConfigId);
// The id is fixed, never generated — there is exactly one row, seeded by DataSeeder.
builder.Property(c => c.ConfigId).ValueGeneratedNever();
// Sentinel = the CLR initializer (true), so an explicit false is still written on insert
// instead of being swallowed by the database default.
builder.Property(c => c.AllowWarehouseSelection).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.Property(c => c.AllowDiscounts).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.Property(c => c.AllowVat).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.HasOne(c => c.DefaultWarehouse)
.WithMany()
.HasForeignKey(c => c.DefaultWarehouseId)
.OnDelete(DeleteBehavior.SetNull);
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(c => c.RowVersion).IsRowVersion();
builder.HasOne(c => c.UpdatedByUser)
.WithMany()
.HasForeignKey(c => c.UpdatedBy)
.OnDelete(DeleteBehavior.Restrict);
}
}
@@ -47,6 +47,7 @@ public static class DataSeeder
dirty |= await SeedProductConfigAsync(db, ct);
dirty |= await SeedBranchConfigAsync(db, ct);
dirty |= await SeedSalesConfigAsync(db, ct);
dirty |= await SeedGrnConfigAsync(db, ct);
dirty |= await SeedNotificationConfigAsync(db, ct);
dirty |= await SeedSalesMastersAsync(db, ct);
dirty |= await SeedSalesStockAsync(db, ct);
@@ -144,6 +145,16 @@ public static class DataSeeder
return true;
}
/// <summary>Seeds the GRN configuration singleton — warehouse selection on, no default warehouse,
/// so New GRN keeps its warehouse picker until an admin opts in from Settings → Configuration → GRN Configuration.</summary>
private static async Task<bool> SeedGrnConfigAsync(ErpDbContext db, CancellationToken ct)
{
if (await db.GrnConfig.AnyAsync(c => c.ConfigId == GrnConfig.SingletonId, ct)) return false;
db.GrnConfig.Add(new GrnConfig { ConfigId = GrnConfig.SingletonId });
return true;
}
private static async Task<bool> SeedNotificationConfigAsync(ErpDbContext db, CancellationToken ct)
{
if (await db.NotificationConfig.AnyAsync(c => c.NotificationConfigId == NotificationConfig.SingletonId, ct)) return false;
@@ -46,6 +46,7 @@ public class ErpDbContext : DbContext
/// <summary>Singleton row gating whether this deployment uses branches at all.</summary>
public DbSet<BranchConfig> BranchConfig => Set<BranchConfig>();
public DbSet<SalesConfig> SalesConfig => Set<SalesConfig>();
public DbSet<GrnConfig> GrnConfig => Set<GrnConfig>();
/// <summary>Singleton row for notification delivery preferences.</summary>
public DbSet<NotificationConfig> NotificationConfig => Set<NotificationConfig>();
public DbSet<NotificationConfigModule> NotificationConfigModules => Set<NotificationConfigModule>();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,61 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddGrnConfig : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "grn_config",
columns: table => new
{
ConfigId = table.Column<int>(type: "integer", nullable: false),
AllowWarehouseSelection = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
DefaultWarehouseId = table.Column<int>(type: "integer", nullable: true),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
UpdatedBy = table.Column<int>(type: "integer", nullable: true),
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_grn_config", x => x.ConfigId);
table.CheckConstraint("ck_grn_config_singleton", "\"ConfigId\" = 1");
table.ForeignKey(
name: "FK_grn_config_users_UpdatedBy",
column: x => x.UpdatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_grn_config_warehouses_DefaultWarehouseId",
column: x => x.DefaultWarehouseId,
principalTable: "warehouses",
principalColumn: "WarehouseId",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateIndex(
name: "IX_grn_config_DefaultWarehouseId",
table: "grn_config",
column: "DefaultWarehouseId");
migrationBuilder.CreateIndex(
name: "IX_grn_config_UpdatedBy",
table: "grn_config",
column: "UpdatedBy");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "grn_config");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddGrnConfigDiscountsVat : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "AllowDiscounts",
table: "grn_config",
type: "boolean",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<bool>(
name: "AllowVat",
table: "grn_config",
type: "boolean",
nullable: false,
defaultValue: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AllowDiscounts",
table: "grn_config");
migrationBuilder.DropColumn(
name: "AllowVat",
table: "grn_config");
}
}
}
@@ -1531,6 +1531,53 @@ namespace ERPCore.Migrations
b.ToTable("grns", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.GrnConfig", b =>
{
b.Property<int>("ConfigId")
.HasColumnType("integer");
b.Property<bool>("AllowDiscounts")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<bool>("AllowVat")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<bool>("AllowWarehouseSelection")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<int?>("DefaultWarehouseId")
.HasColumnType("integer");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("UpdatedBy")
.HasColumnType("integer");
b.HasKey("ConfigId");
b.HasIndex("DefaultWarehouseId");
b.HasIndex("UpdatedBy");
b.ToTable("grn_config", null, t =>
{
t.HasCheckConstraint("ck_grn_config_singleton", "\"ConfigId\" = 1");
});
});
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b =>
{
b.Property<int>("GrnLineId")
@@ -7211,6 +7258,23 @@ namespace ERPCore.Migrations
b.Navigation("Warehouse");
});
modelBuilder.Entity("ERPCore.Domain.Entities.GrnConfig", b =>
{
b.HasOne("ERPCore.Domain.Entities.Warehouse", "DefaultWarehouse")
.WithMany()
.HasForeignKey("DefaultWarehouseId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser")
.WithMany()
.HasForeignKey("UpdatedBy")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("DefaultWarehouse");
b.Navigation("UpdatedByUser");
});
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b =>
{
b.HasOne("ERPCore.Domain.Entities.Batch", "Batch")
+1
View File
@@ -98,6 +98,7 @@ builder.Services.AddScoped<IItemTypeService, ItemTypeService>();
builder.Services.AddScoped<IProductConfigService, ProductConfigService>();
builder.Services.AddScoped<IBranchConfigService, BranchConfigService>();
builder.Services.AddScoped<ISalesConfigService, SalesConfigService>();
builder.Services.AddScoped<IGrnConfigService, GrnConfigService>();
builder.Services.AddScoped<IBranchAccessService, BranchAccessService>();
builder.Services.AddSingleton<INotificationModuleCatalog, NotificationModuleCatalog>();
builder.Services.AddScoped<INotificationConfigService, NotificationConfigService>();
+20 -2
View File
@@ -16,19 +16,37 @@ public sealed class CustomerService : ICustomerService
private readonly IRepository<Customer> _customers;
private readonly IRepository<Warehouse> _warehouses;
private readonly IBranchConfigService _branchConfig;
private readonly IRepository<SalesConfig> _salesConfig;
private readonly IUnitOfWork _uow;
public CustomerService(IRepository<Customer> customers, IRepository<Warehouse> warehouses, IBranchConfigService branchConfig, IUnitOfWork uow)
/// <summary>Code of the system "Cash Customer" row (see <see cref="SalesConfigService"/>).</summary>
private const string CashCustomerCode = "CUST-CASH";
public CustomerService(
IRepository<Customer> customers, IRepository<Warehouse> warehouses, IBranchConfigService branchConfig,
IRepository<SalesConfig> salesConfig, IUnitOfWork uow)
{
_customers = customers;
_warehouses = warehouses;
_branchConfig = branchConfig;
_salesConfig = salesConfig;
_uow = uow;
}
public async Task<PagedResponse<CustomerDto>> ListAsync(PageQuery query, EntityStatus? status, CustomerType? customerType, CancellationToken ct = default)
public async Task<PagedResponse<CustomerDto>> ListAsync(
PageQuery query, EntityStatus? status, CustomerType? customerType, bool includeCashCustomer = false, CancellationToken ct = default)
{
var q = _customers.Query().AsNoTracking();
// The generic cash customer is a system default for walk-in / one-time buyers, not a registered
// customer — kept out of customer lists unless a caller asks for it (e.g. to resolve names).
if (!includeCashCustomer)
{
var cashCustomerId = await _salesConfig.Query().AsNoTracking()
.Where(c => c.ConfigId == SalesConfig.SingletonId)
.Select(c => c.CashCustomerId)
.FirstOrDefaultAsync(ct);
q = q.Where(c => c.CustomerCode != CashCustomerCode && (cashCustomerId == null || c.CustomerId != cashCustomerId));
}
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
@@ -0,0 +1,100 @@
using ERPCore.Common.Http;
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Dtos.Config;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>GRN configuration service over the singleton row seeded by DataSeeder.</summary>
public sealed class GrnConfigService : IGrnConfigService
{
private readonly IRepository<GrnConfig> _config;
private readonly IRepository<Warehouse> _warehouses;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
public GrnConfigService(
IRepository<GrnConfig> config, IRepository<Warehouse> warehouses, ICurrentUser currentUser, IUnitOfWork uow)
{
_config = config;
_warehouses = warehouses;
_currentUser = currentUser;
_uow = uow;
}
public async Task<ETagged<GrnConfigDto>> GetAsync(CancellationToken ct = default)
{
var config = await _config.Query().AsNoTracking().Include(c => c.DefaultWarehouse)
.FirstOrDefaultAsync(c => c.ConfigId == GrnConfig.SingletonId, ct)
?? throw new NotFoundException("GRN configuration has not been seeded.");
return new ETagged<GrnConfigDto>(Map(config), config.RowVersion);
}
public async Task<ETagged<GrnConfigDto>> UpdateAsync(
UpdateGrnConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var config = await _config.GetByIdAsync(GrnConfig.SingletonId, ct)
?? throw new NotFoundException("GRN configuration has not been seeded.");
if (config.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
var allowWarehouseSelection = request.AllowWarehouseSelection!.Value;
if (!allowWarehouseSelection && request.DefaultWarehouseId is null)
throw new DomainException(ErrorCodes.Validation, "Pick a default receiving warehouse before turning warehouse selection off.", 422);
if (request.DefaultWarehouseId is int defaultWarehouseId
&& !await _warehouses.Query().AnyAsync(w => w.WarehouseId == defaultWarehouseId, ct))
throw new NotFoundException($"Warehouse {defaultWarehouseId} was not found.");
config.AllowWarehouseSelection = allowWarehouseSelection;
config.DefaultWarehouseId = request.DefaultWarehouseId;
config.AllowDiscounts = request.AllowDiscounts!.Value;
config.AllowVat = request.AllowVat!.Value;
config.UpdatedAt = DateTime.UtcNow;
config.UpdatedBy = _currentUser.AuditUserId;
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
}
return await GetAsync(ct);
}
public async Task<int?> GetForcedWarehouseIdAsync(CancellationToken ct = default)
{
var config = await _config.Query().AsNoTracking()
.Where(c => c.ConfigId == GrnConfig.SingletonId)
.Select(c => new { c.AllowWarehouseSelection, c.DefaultWarehouseId })
.FirstOrDefaultAsync(ct);
if (config is null) return null;
// A saved default receiving warehouse always wins — GRNs use only that warehouse.
if (config.DefaultWarehouseId is int defaultWarehouseId) return defaultWarehouseId;
if (config.AllowWarehouseSelection) return null;
// The FK is SetNull on delete, so the default can disappear after it was configured.
throw new DomainException(ErrorCodes.Validation, "Warehouse selection is turned off in GRN Configuration but no default receiving warehouse is set.", 422);
}
public async Task<(bool AllowDiscounts, bool AllowVat)> GetLineOptionsAsync(CancellationToken ct = default)
{
var config = await _config.Query().AsNoTracking()
.Where(c => c.ConfigId == GrnConfig.SingletonId)
.Select(c => new { c.AllowDiscounts, c.AllowVat })
.FirstOrDefaultAsync(ct);
return config is null ? (true, true) : (config.AllowDiscounts, config.AllowVat);
}
private static GrnConfigDto Map(GrnConfig c) => new(
c.AllowWarehouseSelection, c.DefaultWarehouseId, c.DefaultWarehouse?.Name, c.AllowDiscounts, c.AllowVat, c.UpdatedAt, c.UpdatedBy);
}
+15 -2
View File
@@ -40,6 +40,7 @@ public sealed class GrnService : IGrnService
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
private readonly IBranchConfigService _branchConfig;
private readonly IGrnConfigService _grnConfig;
private readonly IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl;
private readonly INotificationService _notifications;
@@ -52,7 +53,7 @@ public sealed class GrnService : IGrnService
IRepository<Item> items, IRepository<Warehouse> warehouses,
IRepository<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches,
IRepository<StockLayer> layers, IRepository<StockLedger> ledger, IRepository<TaxRate> taxRates,
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig,
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IGrnConfigService grnConfig,
IUnitOfWork uow, IGeneralLedgerService gl, INotificationService notifications, IConfiguration configuration)
{
_grns = grns;
@@ -70,6 +71,7 @@ public sealed class GrnService : IGrnService
_numbers = numbers;
_currentUser = currentUser;
_branchConfig = branchConfig;
_grnConfig = grnConfig;
_uow = uow;
_gl = gl;
_notifications = notifications;
@@ -118,6 +120,10 @@ public sealed class GrnService : IGrnService
public async Task<GrnDto> CreateAsync(CreateGrnRequest request, CancellationToken ct = default)
{
// GRN Configuration default receiving warehouse: whatever the client sent, the GRN uses it.
if (await _grnConfig.GetForcedWarehouseIdAsync(ct) is int forcedWarehouseId)
request.WarehouseId = forcedWarehouseId;
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct))
throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422);
@@ -144,6 +150,12 @@ public sealed class GrnService : IGrnService
vendorId = request.VendorId.Value;
}
var (allowDiscounts, allowVat) = await _grnConfig.GetLineOptionsAsync(ct);
if (!allowDiscounts && request.Lines.Any(l => l.DiscountPct > 0m))
throw new DomainException(ErrorCodes.Validation, "Discounts are turned off in GRN Configuration.", 422);
if (!allowVat && request.Lines.Any(l => l.VatPct > 0m))
throw new DomainException(ErrorCodes.Validation, "VAT is turned off in GRN Configuration.", 422);
var lines = new List<GrnLine>();
var batchCache = new Dictionary<(int ItemId, string BatchNo), Batch>();
@@ -187,7 +199,8 @@ public sealed class GrnService : IGrnService
// input.VatPct supplied is an explicit override; omitted (null) resolves from the
// item's assigned TaxRate — see ISalesDomainService.ResolveLineTaxPctAsync's sales-side
// equivalent of this same "Item.TaxRate drives the default, don't hand-type it" rule.
var vatPct = input.VatPct ?? (item.TaxRateId is int taxRateId
// GRN Configuration "Allow VAT" off: every line is 0% — the item's tax rate is not applied either.
var vatPct = !allowVat ? 0m : input.VatPct ?? (item.TaxRateId is int taxRateId
? await _taxRates.Query().AsNoTracking().Where(t => t.TaxRateId == taxRateId).Select(t => t.Percentage).FirstAsync(ct)
: 0m);
var vatAmount = Math.Round(receivedValue * vatPct / 100m, 4, MidpointRounding.AwayFromZero);
@@ -7,7 +7,8 @@ namespace ERPCore.Services.Interfaces;
public interface ICustomerService
{
Task<PagedResponse<CustomerDto>> ListAsync(PageQuery query, EntityStatus? status, CustomerType? customerType, CancellationToken ct = default);
/// <summary>Registered customers; the system cash customer is left out unless <paramref name="includeCashCustomer"/>.</summary>
Task<PagedResponse<CustomerDto>> ListAsync(PageQuery query, EntityStatus? status, CustomerType? customerType, bool includeCashCustomer = false, CancellationToken ct = default);
Task<ETagged<CustomerDto>?> GetAsync(int customerId, CancellationToken ct = default);
Task<ETagged<CustomerDto>> CreateAsync(CreateCustomerRequest request, CancellationToken ct = default);
Task<ETagged<CustomerDto>> UpdateAsync(int customerId, UpdateCustomerRequest request, uint expectedRowVersion, CancellationToken ct = default);
@@ -0,0 +1,20 @@
using ERPCore.Common.Http;
using ERPCore.Dtos.Config;
namespace ERPCore.Services.Interfaces;
/// <summary>GRN configuration business logic. Singleton.</summary>
public interface IGrnConfigService
{
Task<ETagged<GrnConfigDto>> GetAsync(CancellationToken ct = default);
Task<ETagged<GrnConfigDto>> UpdateAsync(
UpdateGrnConfigRequest request, uint expectedRowVersion, CancellationToken ct = default);
/// <summary>GRN Configuration default receiving warehouse: when one is saved, every GRN must
/// use it. Null only when none is saved and "Allow warehouse selection" is on.</summary>
Task<int?> GetForcedWarehouseIdAsync(CancellationToken ct = default);
/// <summary>Whether GRN lines may carry a discount and VAT (both default to allowed).</summary>
Task<(bool AllowDiscounts, bool AllowVat)> GetLineOptionsAsync(CancellationToken ct = default);
}
@@ -6,5 +6,7 @@ namespace ERPCore.Services.Interfaces;
public interface ISalesInvoicePaymentService
{
Task<SalesInvoicePaymentDto> PayAsync(int salesInvoiceId, CreateSalesInvoicePaymentRequest request, CancellationToken ct = default);
/// <summary>One customer, several invoices, one GL journal entry; returns a payment per invoice/account split.</summary>
Task<IReadOnlyList<SalesInvoicePaymentDto>> PayBulkAsync(CreateBulkSalesInvoicePaymentRequest request, CancellationToken ct = default);
Task<IReadOnlyList<SalesInvoicePaymentDto>> ListAsync(int salesInvoiceId, CancellationToken ct = default);
}
@@ -12,6 +12,9 @@ public interface IStockService
Task<PagedResponse<StockOnHandDto>> GetOnHandListAsync(
int? itemId, int? warehouseId, int? branchId, PageQuery query, CancellationToken ct = default);
/// <summary>Issuable (non-held) stock per item at one warehouse, keyed by item id.</summary>
Task<IReadOnlyDictionary<int, decimal>> GetAvailableByItemAsync(int warehouseId, CancellationToken ct = default);
Task<PagedResponse<StockLedgerRowDto>> GetLedgerAsync(
int? itemId, int? warehouseId, int? branchId, DateOnly? from, DateOnly? to,
string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default);
@@ -24,17 +24,19 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
{
private readonly IRepository<SalesInvoice> _invoices;
private readonly IRepository<SalesInvoicePayment> _payments;
private readonly IRepository<SalesConfig> _salesConfig;
private readonly IGeneralLedgerService _gl;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
private readonly string _glAccountsReceivableCode;
public SalesInvoicePaymentService(
IRepository<SalesInvoice> invoices, IRepository<SalesInvoicePayment> payments, IGeneralLedgerService gl,
ICurrentUser currentUser, IUnitOfWork uow, IConfiguration configuration)
IRepository<SalesInvoice> invoices, IRepository<SalesInvoicePayment> payments, IRepository<SalesConfig> salesConfig,
IGeneralLedgerService gl, ICurrentUser currentUser, IUnitOfWork uow, IConfiguration configuration)
{
_invoices = invoices;
_payments = payments;
_salesConfig = salesConfig;
_gl = gl;
_currentUser = currentUser;
_uow = uow;
@@ -58,17 +60,18 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
var actor = _currentUser.AuditUserId;
var now = DateTime.UtcNow;
var (paymentDay, paymentDate) = ResolvePaymentDate(request.PaymentDate, now);
var payment = await _uow.ExecuteInTransactionAsync(async token =>
{
// Same atomicity approach as GrnPaymentService.PayAsync: post to GL first, inside
// this transaction, before anything is committed — a GL rejection/timeout rolls
// the whole payment back with no partial local state.
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), token);
var period = await _gl.GetPeriodByDateAsync(paymentDay, token);
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{
PeriodId = period.PeriodId,
EntryDate = DateOnly.FromDateTime(now),
EntryDate = paymentDay,
SourceModule = "SALES_INVOICE_PAYMENT",
Reference = invoice.InvoiceNo,
Narration = $"Payment against invoice {invoice.InvoiceNo}",
@@ -83,7 +86,7 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
{
SalesInvoiceId = invoice.SalesInvoiceId,
Amount = request.Amount,
PaymentDate = now,
PaymentDate = paymentDate,
GlBankAccountId = account.AccountId,
BankAccountName = account.AccountName,
Reference = request.Reference,
@@ -102,6 +105,134 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
return Map(payment);
}
public async Task<IReadOnlyList<SalesInvoicePaymentDto>> PayBulkAsync(CreateBulkSalesInvoicePaymentRequest request, CancellationToken ct = default)
{
var ids = request.SalesInvoiceIds.Distinct().ToList();
if (ids.Count == 0 || request.Lines.Count == 0)
throw new DomainException(ErrorCodes.Validation, "Select at least one invoice and one payment line.", 400);
var invoices = await _invoices.Query().Where(i => ids.Contains(i.SalesInvoiceId)).ToListAsync(ct);
var missing = ids.Except(invoices.Select(i => i.SalesInvoiceId)).ToList();
if (missing.Count > 0)
throw new NotFoundException($"Sales invoice {missing[0]} was not found.");
if (invoices.Select(i => i.CustomerId).Distinct().Count() > 1)
throw new DomainException(ErrorCodes.Validation, "All invoices in one payment must belong to the same customer.", 400);
// The generic cash customer's invoices are paid one at a time, never grouped.
var cashCustomerId = await _salesConfig.Query().AsNoTracking()
.Where(c => c.ConfigId == SalesConfig.SingletonId)
.Select(c => c.CashCustomerId)
.FirstOrDefaultAsync(ct);
if (cashCustomerId is not null && invoices[0].CustomerId == cashCustomerId)
throw new DomainException(ErrorCodes.Validation, "Cash customer invoices cannot be paid together.", 400);
foreach (var inv in invoices)
{
if (inv.Status != SalesInvoiceStatus.Posted)
throw new DomainException(ErrorCodes.SalesInvoiceNotPayable, $"Sales invoice {inv.InvoiceNo} must be posted before it can be paid.", 409);
if (inv.BalanceAmount <= 0)
throw new DomainException(ErrorCodes.SalesInvoiceNotPayable, $"Sales invoice {inv.InvoiceNo} is already fully paid.", 409);
}
var total = request.Lines.Sum(l => l.Amount);
var totalBalance = invoices.Sum(i => i.BalanceAmount);
if (total > totalBalance)
throw new DomainException(ErrorCodes.SalesInvoicePaymentExceedsBalance,
$"Payment total {total} exceeds the invoices' combined balance {totalBalance}.", 400);
var accounts = await _gl.ListBankAccountsAsync(ct);
var lines = request.Lines.Select(l => (
Line: l,
Account: accounts.FirstOrDefault(a => a.AccountId == l.GlBankAccountId)
?? throw new DomainException(ErrorCodes.SalesInvoiceBankAccountNotFound, $"Bank/cash account {l.GlBankAccountId} was not found.", 404)))
.ToList();
// Oldest invoice first: walk the lines in order, filling each invoice's balance before the next.
var ordered = invoices.OrderBy(i => i.InvoiceDate).ThenBy(i => i.SalesInvoiceId).ToList();
var allocations = new List<(SalesInvoice Invoice, GlBankAccount Account, decimal Amount)>();
var invIdx = 0;
var invRemaining = ordered[0].BalanceAmount;
foreach (var (line, account) in lines)
{
var lineRemaining = line.Amount;
while (lineRemaining > 0)
{
var take = Math.Min(lineRemaining, invRemaining);
allocations.Add((ordered[invIdx], account, take));
lineRemaining -= take;
invRemaining -= take;
if (invRemaining == 0 && invIdx < ordered.Count - 1)
invRemaining = ordered[++invIdx].BalanceAmount;
}
}
var actor = _currentUser.AuditUserId;
var now = DateTime.UtcNow;
var (paymentDay, paymentDate) = ResolvePaymentDate(request.PaymentDate, now);
var paidNos = string.Join(", ", allocations.Select(a => a.Invoice.InvoiceNo).Distinct());
var narration = $"Payment against invoices {paidNos}";
if (narration.Length > 250) narration = narration[..247] + "...";
var payments = await _uow.ExecuteInTransactionAsync(async token =>
{
// One journal for the whole payment (Debit each cash/bank line / Credit AR in total), posted
// before anything is committed — so a GL rejection leaves neither GL nor local state half-done.
var period = await _gl.GetPeriodByDateAsync(paymentDay, token);
var glLines = lines
.Select(x => new GlJournalEntryLineRequest(x.Account.GlAccountCode, x.Line.Amount, 0m, narration))
.Append(new GlJournalEntryLineRequest(_glAccountsReceivableCode, 0m, total, narration))
.ToList();
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{
PeriodId = period.PeriodId,
EntryDate = paymentDay,
SourceModule = "SALES_INVOICE_PAYMENT",
Reference = request.Reference ?? ordered[0].InvoiceNo,
Narration = narration,
Lines = glLines
}, token);
var entities = new List<SalesInvoicePayment>();
foreach (var (invoice, account, amount) in allocations)
{
var entity = new SalesInvoicePayment
{
SalesInvoiceId = invoice.SalesInvoiceId,
Amount = amount,
PaymentDate = paymentDate,
GlBankAccountId = account.AccountId,
BankAccountName = account.AccountName,
Reference = request.Reference,
GlJournalNo = posted.JournalNo,
CreatedBy = actor,
CreatedAt = now
};
await _payments.AddAsync(entity, token);
invoice.PaidAmount += amount;
invoice.BalanceAmount -= amount;
entities.Add(entity);
}
return entities;
}, ct);
return payments.Select(Map).ToList();
}
/// <summary>
/// The payment's calendar day (also the GL entry date) and the timestamp stored on it. Omitted ⇒
/// today. One day of slack on "future": the client sends its local date, which runs ahead of UTC
/// for users east of it. Today keeps the actual time of entry; a back-dated payment is stamped at
/// that day's start.
/// </summary>
private static (DateOnly Day, DateTime Stamp) ResolvePaymentDate(DateOnly? requested, DateTime now)
{
var today = DateOnly.FromDateTime(now);
var day = requested ?? today;
if (day > today.AddDays(1))
throw new DomainException(ErrorCodes.Validation, "Payment date cannot be in the future.", 400);
return (day, day >= today ? now : day.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc));
}
public async Task<IReadOnlyList<SalesInvoicePaymentDto>> ListAsync(int salesInvoiceId, CancellationToken ct = default)
{
if (!await _invoices.Query().AnyAsync(i => i.SalesInvoiceId == salesInvoiceId, ct))
@@ -21,8 +21,17 @@ public sealed class SalesPricingService : ISalesPricingService
_fifo = fifo;
}
// Sales unit prices are money — always 2dp, whichever source (flat, GRN weighted avg, FIFO avg,
// manual) produced them, so the suggested-price endpoint and saved lines agree.
public async Task<SalesPriceResolution> ResolveAsync(
int itemId, int warehouseId, decimal? requestedUnitPrice, bool allowManualOverride, CancellationToken ct = default)
{
var resolved = await ResolveUnroundedAsync(itemId, warehouseId, requestedUnitPrice, allowManualOverride, ct);
return resolved with { UnitPrice = Math.Round(resolved.UnitPrice, 2, MidpointRounding.AwayFromZero) };
}
private async Task<SalesPriceResolution> ResolveUnroundedAsync(
int itemId, int warehouseId, decimal? requestedUnitPrice, bool allowManualOverride, CancellationToken ct)
{
var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(x => x.ItemId == itemId, ct)
?? throw new InvalidOperationException($"Item {itemId} was not found.");
@@ -132,6 +132,18 @@ public sealed class StockService : IStockService
return PagedResponse<StockOnHandDto>.Create(rows, query.Page, query.PageSize, total);
}
/// <summary>
/// Same figure as <see cref="GetOnHandAsync"/>'s Available (all layers, negative ones included,
/// less held OnHold/Rejected GRN stock), for every item at the warehouse in one grouped query.
/// </summary>
public async Task<IReadOnlyDictionary<int, decimal>> GetAvailableByItemAsync(int warehouseId, CancellationToken ct = default)
=> await _layers.Query().AsNoTracking()
.Where(l => l.WarehouseId == warehouseId
&& !(l.GrnLine != null && (l.GrnLine.HoldStatus == HoldStatus.OnHold || l.GrnLine.HoldStatus == HoldStatus.Rejected)))
.GroupBy(l => l.ItemId)
.Select(g => new { ItemId = g.Key, Qty = g.Sum(x => x.QtyRemaining) })
.ToDictionaryAsync(x => x.ItemId, x => x.Qty, ct);
public async Task<PagedResponse<StockLedgerRowDto>> GetLedgerAsync(
int? itemId, int? warehouseId, int? branchId, DateOnly? from, DateOnly? to,
string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default)