sales & grn fixers
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+9008
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")
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user