Add sales slip document
This commit is contained in:
@@ -0,0 +1,67 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Sales;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
[Route("api/v1/sales-slips")]
|
||||||
|
public sealed class SalesSlipsController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly ISalesSlipService _slips;
|
||||||
|
|
||||||
|
public SalesSlipsController(ISalesSlipService slips) => _slips = slips;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<SalesSlipSummaryDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<SalesSlipSummaryDto>>> List(
|
||||||
|
[FromQuery] PageQuery query,
|
||||||
|
[FromQuery] SalesSlipStatus? status,
|
||||||
|
[FromQuery] int? customerId,
|
||||||
|
[FromQuery] int? warehouseId,
|
||||||
|
CancellationToken ct)
|
||||||
|
=> Ok(await _slips.ListAsync(query, status, customerId, warehouseId, ct));
|
||||||
|
|
||||||
|
[HttpGet("{salesSlipId:int}")]
|
||||||
|
[ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<SalesSlipDto>> GetById(int salesSlipId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _slips.GetAsync(salesSlipId, ct);
|
||||||
|
if (result is null) return NotFound();
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status201Created)]
|
||||||
|
public async Task<ActionResult<SalesSlipDto>> Create([FromBody] CreateSalesSlipRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _slips.CreateAsync(request, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Created($"/api/v1/sales-slips/{result.Value.SalesSlipId}", result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{salesSlipId:int}")]
|
||||||
|
[ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||||
|
public async Task<ActionResult<SalesSlipDto>> Update(int salesSlipId, [FromBody] UpdateSalesSlipRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var expected = RequireIfMatch();
|
||||||
|
var result = await _slips.UpdateAsync(salesSlipId, request, expected, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{salesSlipId:int}/post")]
|
||||||
|
[ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<SalesSlipDto>> Post(int salesSlipId, CancellationToken ct)
|
||||||
|
=> Ok(await _slips.PostAsync(salesSlipId, ct));
|
||||||
|
|
||||||
|
[HttpPost("{salesSlipId:int}/cancel")]
|
||||||
|
[ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<SalesSlipDto>> Cancel(int salesSlipId, CancellationToken ct)
|
||||||
|
=> Ok(await _slips.CancelAsync(salesSlipId, ct));
|
||||||
|
}
|
||||||
@@ -18,4 +18,5 @@ public static class DocumentTypes
|
|||||||
/// <summary>Production run (docs/30 FR-MFG-08) — <c>PRD-2026-00001</c>.</summary>
|
/// <summary>Production run (docs/30 FR-MFG-08) — <c>PRD-2026-00001</c>.</summary>
|
||||||
public const string Production = "PRD";
|
public const string Production = "PRD";
|
||||||
public const string SalesInvoice = "SI";
|
public const string SalesInvoice = "SI";
|
||||||
|
public const string SalesSlip = "SSL";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
public class SalesSlip
|
||||||
|
{
|
||||||
|
public int SalesSlipId { get; set; }
|
||||||
|
public string SlipNo { get; set; } = string.Empty;
|
||||||
|
public DateTime SlipDate { get; set; }
|
||||||
|
|
||||||
|
public int CustomerId { get; set; }
|
||||||
|
public Customer? Customer { get; set; }
|
||||||
|
|
||||||
|
public string CustomerSnapshotName { get; set; } = string.Empty;
|
||||||
|
public int WarehouseId { get; set; }
|
||||||
|
public Warehouse? Warehouse { get; set; }
|
||||||
|
|
||||||
|
public int CashierUserId { get; set; }
|
||||||
|
public User? CashierUser { get; set; }
|
||||||
|
|
||||||
|
public SalesSlipStatus Status { get; set; } = SalesSlipStatus.Draft;
|
||||||
|
|
||||||
|
public decimal Subtotal { get; set; }
|
||||||
|
public decimal DiscountTotal { get; set; }
|
||||||
|
public decimal TaxTotal { get; set; }
|
||||||
|
public decimal GrandTotal { get; set; }
|
||||||
|
public decimal PaidAmount { get; set; }
|
||||||
|
public decimal BalanceAmount { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
public uint RowVersion { get; set; }
|
||||||
|
|
||||||
|
public ICollection<SalesSlipLine> Lines { get; set; } = new List<SalesSlipLine>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
public class SalesSlipLine
|
||||||
|
{
|
||||||
|
public int SalesSlipLineId { get; set; }
|
||||||
|
|
||||||
|
public int SalesSlipId { get; set; }
|
||||||
|
public SalesSlip? SalesSlip { get; set; }
|
||||||
|
|
||||||
|
public int ItemId { get; set; }
|
||||||
|
public Item? Item { get; set; }
|
||||||
|
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public decimal Qty { get; set; }
|
||||||
|
public decimal FreeQty { get; set; }
|
||||||
|
public int UomId { get; set; }
|
||||||
|
public Uom? Uom { get; set; }
|
||||||
|
public int WarehouseId { get; set; }
|
||||||
|
public Warehouse? Warehouse { get; set; }
|
||||||
|
|
||||||
|
public decimal UnitPrice { get; set; }
|
||||||
|
public decimal BaseCost { get; set; }
|
||||||
|
public string PriceSource { get; set; } = string.Empty;
|
||||||
|
public decimal DiscountPct { get; set; }
|
||||||
|
public decimal DiscountAmount { get; set; }
|
||||||
|
public decimal NetUnitPrice { get; set; }
|
||||||
|
public decimal LineTotal { get; set; }
|
||||||
|
public decimal TaxPct { get; set; }
|
||||||
|
public decimal TaxAmount { get; set; }
|
||||||
|
public bool IsFreeIssue { get; set; }
|
||||||
|
public int? ParentLineId { get; set; }
|
||||||
|
|
||||||
|
public uint RowVersion { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
public enum SalesSlipStatus
|
||||||
|
{
|
||||||
|
Draft = 1,
|
||||||
|
Posted = 2,
|
||||||
|
Cancelled = 3
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Dtos.Sales;
|
||||||
|
|
||||||
|
public sealed record SalesSlipLineDto(
|
||||||
|
int SalesSlipLineId, int ItemId, string Description, decimal Qty, decimal FreeQty, int UomId,
|
||||||
|
int WarehouseId, decimal UnitPrice, decimal BaseCost, string PriceSource, decimal DiscountPct,
|
||||||
|
decimal DiscountAmount, decimal NetUnitPrice, decimal LineTotal, decimal TaxPct, decimal TaxAmount,
|
||||||
|
bool IsFreeIssue, int? ParentLineId);
|
||||||
|
|
||||||
|
public sealed record SalesSlipTotalsDto(
|
||||||
|
decimal Subtotal, decimal DiscountTotal, decimal TaxTotal, decimal GrandTotal,
|
||||||
|
decimal PaidAmount, decimal BalanceAmount);
|
||||||
|
|
||||||
|
public sealed record SalesSlipDto(
|
||||||
|
int SalesSlipId, string SlipNo, DateTime SlipDate, int CustomerId,
|
||||||
|
string CustomerSnapshotName, int WarehouseId, int CashierUserId, SalesSlipStatus Status,
|
||||||
|
DateTime CreatedAt, DateTime? UpdatedAt, SalesSlipTotalsDto Totals, IReadOnlyList<SalesSlipLineDto> Lines);
|
||||||
|
|
||||||
|
public sealed record SalesSlipSummaryDto(
|
||||||
|
int SalesSlipId, string SlipNo, DateTime SlipDate, int CustomerId,
|
||||||
|
string CustomerSnapshotName, int WarehouseId, SalesSlipStatus Status,
|
||||||
|
SalesSlipTotalsDto Totals, DateTime CreatedAt);
|
||||||
|
|
||||||
|
public sealed class CreateSalesSlipLineRequest
|
||||||
|
{
|
||||||
|
[Required] public int ItemId { get; set; }
|
||||||
|
[Required] public int UomId { get; set; }
|
||||||
|
[Required] public int WarehouseId { get; set; }
|
||||||
|
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
|
||||||
|
[Range(0, double.MaxValue)] public decimal FreeQty { get; set; }
|
||||||
|
[Range(0, double.MaxValue)] public decimal? UnitPrice { get; set; }
|
||||||
|
[Range(0, 100)] public decimal DiscountPct { get; set; }
|
||||||
|
[Range(0, double.MaxValue)] public decimal DiscountAmount { get; set; }
|
||||||
|
[Range(0, 100)] public decimal TaxPct { get; set; }
|
||||||
|
public bool IsFreeIssue { get; set; }
|
||||||
|
public int? ParentLineId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CreateSalesSlipRequest
|
||||||
|
{
|
||||||
|
[Required] public int CustomerId { get; set; }
|
||||||
|
[Required] public int WarehouseId { get; set; }
|
||||||
|
[Required] public int CashierUserId { get; set; }
|
||||||
|
[Required, MinLength(1)] public List<CreateSalesSlipLineRequest> Lines { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UpdateSalesSlipRequest
|
||||||
|
{
|
||||||
|
[Required] public int CustomerId { get; set; }
|
||||||
|
[Required] public int WarehouseId { get; set; }
|
||||||
|
[Required] public int CashierUserId { get; set; }
|
||||||
|
[Required, MinLength(1)] public List<CreateSalesSlipLineRequest> Lines { get; set; } = new();
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace ERPCore.Infra.Persistence.Configurations;
|
||||||
|
|
||||||
|
public sealed class SalesSlipConfiguration : IEntityTypeConfiguration<SalesSlip>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<SalesSlip> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("sales_slips");
|
||||||
|
builder.HasKey(x => x.SalesSlipId);
|
||||||
|
|
||||||
|
builder.Property(x => x.SlipNo).IsRequired().HasMaxLength(50);
|
||||||
|
builder.HasIndex(x => x.SlipNo).IsUnique();
|
||||||
|
|
||||||
|
builder.Property(x => x.SlipDate).IsRequired();
|
||||||
|
|
||||||
|
builder.HasOne(x => x.Customer)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.CustomerId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
builder.Property(x => x.CustomerSnapshotName).IsRequired().HasMaxLength(200);
|
||||||
|
|
||||||
|
builder.HasOne(x => x.Warehouse)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.WarehouseId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
builder.HasOne(x => x.CashierUser)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.CashierUserId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
builder.Property(x => x.Status)
|
||||||
|
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
||||||
|
.HasDefaultValue(SalesSlipStatus.Draft);
|
||||||
|
|
||||||
|
foreach (var p in new[] { nameof(SalesSlip.Subtotal), nameof(SalesSlip.DiscountTotal), nameof(SalesSlip.TaxTotal), nameof(SalesSlip.GrandTotal), nameof(SalesSlip.PaidAmount), nameof(SalesSlip.BalanceAmount) })
|
||||||
|
builder.Property<decimal>(p).HasPrecision(18, 4);
|
||||||
|
|
||||||
|
builder.Property(x => x.CreatedAt).IsRequired();
|
||||||
|
builder.Property(x => x.RowVersion).IsRowVersion();
|
||||||
|
|
||||||
|
builder.HasIndex(x => x.Status);
|
||||||
|
builder.HasIndex(x => x.SlipDate);
|
||||||
|
|
||||||
|
builder.HasMany(x => x.Lines)
|
||||||
|
.WithOne(x => x.SalesSlip)
|
||||||
|
.HasForeignKey(x => x.SalesSlipId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SalesSlipLineConfiguration : IEntityTypeConfiguration<SalesSlipLine>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<SalesSlipLine> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("sales_slip_lines");
|
||||||
|
builder.HasKey(x => x.SalesSlipLineId);
|
||||||
|
|
||||||
|
builder.HasOne(x => x.Item)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.ItemId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
builder.Property(x => x.Description).IsRequired().HasMaxLength(200);
|
||||||
|
|
||||||
|
builder.Property(x => x.Qty).HasPrecision(18, 4);
|
||||||
|
builder.Property(x => x.FreeQty).HasPrecision(18, 4);
|
||||||
|
builder.Property(x => x.UnitPrice).HasPrecision(18, 4);
|
||||||
|
builder.Property(x => x.BaseCost).HasPrecision(18, 4);
|
||||||
|
builder.Property(x => x.DiscountPct).HasPrecision(9, 4);
|
||||||
|
builder.Property(x => x.DiscountAmount).HasPrecision(18, 4);
|
||||||
|
builder.Property(x => x.NetUnitPrice).HasPrecision(18, 4);
|
||||||
|
builder.Property(x => x.LineTotal).HasPrecision(18, 4);
|
||||||
|
builder.Property(x => x.TaxPct).HasPrecision(9, 4);
|
||||||
|
builder.Property(x => x.TaxAmount).HasPrecision(18, 4);
|
||||||
|
|
||||||
|
builder.HasOne(x => x.Uom)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.UomId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
builder.HasOne(x => x.Warehouse)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.WarehouseId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
builder.Property(x => x.PriceSource).HasMaxLength(50);
|
||||||
|
builder.Property(x => x.RowVersion).IsRowVersion();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,6 +86,8 @@ public class ErpDbContext : DbContext
|
|||||||
// --- Sales (Phase 1) ---
|
// --- Sales (Phase 1) ---
|
||||||
public DbSet<SalesInvoice> SalesInvoices => Set<SalesInvoice>();
|
public DbSet<SalesInvoice> SalesInvoices => Set<SalesInvoice>();
|
||||||
public DbSet<SalesInvoiceLine> SalesInvoiceLines => Set<SalesInvoiceLine>();
|
public DbSet<SalesInvoiceLine> SalesInvoiceLines => Set<SalesInvoiceLine>();
|
||||||
|
public DbSet<SalesSlip> SalesSlips => Set<SalesSlip>();
|
||||||
|
public DbSet<SalesSlipLine> SalesSlipLines => Set<SalesSlipLine>();
|
||||||
|
|
||||||
// --- Reference data (docs/10 Part C.7) ---
|
// --- Reference data (docs/10 Part C.7) ---
|
||||||
public DbSet<ReasonCode> ReasonCodes => Set<ReasonCode>();
|
public DbSet<ReasonCode> ReasonCodes => Set<ReasonCode>();
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ builder.Services.AddScoped<IGrnService, GrnService>();
|
|||||||
|
|
||||||
// Sales (Phase 1)
|
// Sales (Phase 1)
|
||||||
builder.Services.AddScoped<ISalesInvoiceService, SalesInvoiceService>();
|
builder.Services.AddScoped<ISalesInvoiceService, SalesInvoiceService>();
|
||||||
|
builder.Services.AddScoped<ISalesSlipService, SalesSlipService>();
|
||||||
|
|
||||||
// Stock transactions + reference data (docs/11 §5–6)
|
// Stock transactions + reference data (docs/11 §5–6)
|
||||||
builder.Services.AddScoped<IStockMutator, StockMutator>();
|
builder.Services.AddScoped<IStockMutator, StockMutator>();
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using ERPCore.Common.Http;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Sales;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Services.Interfaces;
|
||||||
|
|
||||||
|
public interface ISalesSlipService
|
||||||
|
{
|
||||||
|
Task<PagedResponse<SalesSlipSummaryDto>> ListAsync(PageQuery query, SalesSlipStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default);
|
||||||
|
Task<ETagged<SalesSlipDto>?> GetAsync(int salesSlipId, CancellationToken ct = default);
|
||||||
|
Task<ETagged<SalesSlipDto>> CreateAsync(CreateSalesSlipRequest request, CancellationToken ct = default);
|
||||||
|
Task<ETagged<SalesSlipDto>> UpdateAsync(int salesSlipId, UpdateSalesSlipRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||||
|
Task<SalesSlipDto> PostAsync(int salesSlipId, CancellationToken ct = default);
|
||||||
|
Task<SalesSlipDto> CancelAsync(int salesSlipId, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
using ERPCore.Common.Http;
|
||||||
|
using ERPCore.Domain;
|
||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
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 SalesSlipService : ISalesSlipService
|
||||||
|
{
|
||||||
|
private readonly IRepository<SalesSlip> _slips;
|
||||||
|
private readonly IRepository<Customer> _customers;
|
||||||
|
private readonly IRepository<Item> _items;
|
||||||
|
private readonly IRepository<Uom> _uoms;
|
||||||
|
private readonly IRepository<Warehouse> _warehouses;
|
||||||
|
private readonly IRepository<User> _users;
|
||||||
|
private readonly IFifoCostingService _fifo;
|
||||||
|
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, IFifoCostingService fifo,
|
||||||
|
ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow)
|
||||||
|
{
|
||||||
|
_slips = slips;
|
||||||
|
_customers = customers;
|
||||||
|
_items = items;
|
||||||
|
_uoms = uoms;
|
||||||
|
_warehouses = warehouses;
|
||||||
|
_users = users;
|
||||||
|
_fifo = fifo;
|
||||||
|
_currentUser = currentUser;
|
||||||
|
_numbers = numbers;
|
||||||
|
_uow = uow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PagedResponse<SalesSlipSummaryDto>> ListAsync(PageQuery query, SalesSlipStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
IQueryable<SalesSlip> q = _slips.Query().AsNoTracking().Include(x => x.Lines);
|
||||||
|
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||||
|
{
|
||||||
|
var term = query.Q.Trim();
|
||||||
|
q = q.Where(x => EF.Functions.ILike(x.SlipNo, $"%{term}%") || EF.Functions.ILike(x.CustomerSnapshotName, $"%{term}%"));
|
||||||
|
}
|
||||||
|
if (status is not null) q = q.Where(x => x.Status == status);
|
||||||
|
if (customerId is not null) q = q.Where(x => x.CustomerId == customerId);
|
||||||
|
if (warehouseId is not null) q = q.Where(x => x.WarehouseId == warehouseId);
|
||||||
|
|
||||||
|
var total = await q.CountAsync(ct);
|
||||||
|
var rows = await q.OrderByDescending(x => x.SalesSlipId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
|
||||||
|
return PagedResponse<SalesSlipSummaryDto>.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ETagged<SalesSlipDto>?> GetAsync(int salesSlipId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ETagged<SalesSlipDto>> CreateAsync(CreateSalesSlipRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await ValidateReferencesAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, request.Lines, ct);
|
||||||
|
|
||||||
|
var slip = new SalesSlip
|
||||||
|
{
|
||||||
|
SlipNo = await _numbers.NextAsync(DocumentTypes.SalesSlip, ct),
|
||||||
|
SlipDate = DateTime.UtcNow,
|
||||||
|
CustomerId = request.CustomerId,
|
||||||
|
WarehouseId = request.WarehouseId,
|
||||||
|
CashierUserId = request.CashierUserId,
|
||||||
|
Status = SalesSlipStatus.Draft,
|
||||||
|
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);
|
||||||
|
Recalculate(slip);
|
||||||
|
|
||||||
|
await _slips.AddAsync(slip, ct);
|
||||||
|
await _uow.SaveChangesAsync(ct);
|
||||||
|
return new ETagged<SalesSlipDto>(Map(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.");
|
||||||
|
|
||||||
|
await ValidateReferencesAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, request.Lines, 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);
|
||||||
|
Recalculate(slip);
|
||||||
|
slip.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _uow.SaveChangesAsync(ct);
|
||||||
|
return new ETagged<SalesSlipDto>(Map(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SalesSlipDto> CancelAsync(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 cancelled.");
|
||||||
|
slip.Status = SalesSlipStatus.Cancelled;
|
||||||
|
slip.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _uow.SaveChangesAsync(ct);
|
||||||
|
return Map(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 _customers.Query().AnyAsync(x => x.CustomerId == customerId, ct))
|
||||||
|
throw new NotFoundException($"Customer {customerId} 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)
|
||||||
|
{
|
||||||
|
var lines = new List<SalesSlipLine>();
|
||||||
|
foreach (var r in requests)
|
||||||
|
{
|
||||||
|
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
|
||||||
|
var priceSource = "SALE_PRICE";
|
||||||
|
decimal unitPrice;
|
||||||
|
if (r.UnitPrice is not null)
|
||||||
|
{
|
||||||
|
unitPrice = r.UnitPrice.Value;
|
||||||
|
priceSource = "MANUAL";
|
||||||
|
}
|
||||||
|
else if (item.SalePrice.HasValue)
|
||||||
|
{
|
||||||
|
unitPrice = item.SalePrice.Value;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var valuation = await _fifo.GetValuationAsync(r.ItemId, r.WarehouseId, ct);
|
||||||
|
unitPrice = valuation.TotalQty > 0 ? valuation.TotalValue / valuation.TotalQty : 0m;
|
||||||
|
priceSource = "FIFO_AVG";
|
||||||
|
}
|
||||||
|
|
||||||
|
var gross = r.Qty * unitPrice;
|
||||||
|
var discountAfterPct = gross * (r.DiscountPct / 100m);
|
||||||
|
var discountTotal = discountAfterPct + r.DiscountAmount;
|
||||||
|
var netUnit = r.Qty > 0 ? (gross - discountTotal) / r.Qty : 0m;
|
||||||
|
var lineTotal = gross - discountTotal;
|
||||||
|
var taxAmount = lineTotal * (r.TaxPct / 100m);
|
||||||
|
|
||||||
|
lines.Add(new SalesSlipLine
|
||||||
|
{
|
||||||
|
ItemId = r.ItemId,
|
||||||
|
Description = item.Name,
|
||||||
|
Qty = r.Qty,
|
||||||
|
FreeQty = r.FreeQty,
|
||||||
|
UomId = r.UomId,
|
||||||
|
WarehouseId = r.WarehouseId,
|
||||||
|
UnitPrice = unitPrice,
|
||||||
|
BaseCost = unitPrice,
|
||||||
|
PriceSource = priceSource,
|
||||||
|
DiscountPct = r.DiscountPct,
|
||||||
|
DiscountAmount = discountTotal,
|
||||||
|
NetUnitPrice = netUnit,
|
||||||
|
LineTotal = lineTotal,
|
||||||
|
TaxPct = r.TaxPct,
|
||||||
|
TaxAmount = taxAmount,
|
||||||
|
IsFreeIssue = r.IsFreeIssue,
|
||||||
|
ParentLineId = r.ParentLineId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Recalculate(SalesSlip slip)
|
||||||
|
{
|
||||||
|
slip.Subtotal = slip.Lines.Sum(x => x.Qty * x.UnitPrice);
|
||||||
|
slip.DiscountTotal = slip.Lines.Sum(x => x.DiscountAmount);
|
||||||
|
slip.TaxTotal = slip.Lines.Sum(x => x.TaxAmount);
|
||||||
|
slip.GrandTotal = slip.Lines.Sum(x => x.LineTotal) + slip.TaxTotal;
|
||||||
|
slip.PaidAmount = 0m;
|
||||||
|
slip.BalanceAmount = slip.GrandTotal - slip.PaidAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static 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.TaxTotal, x.GrandTotal, x.PaidAmount, x.BalanceAmount), x.CreatedAt);
|
||||||
|
|
||||||
|
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.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.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId)).ToList());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user