Compare commits

..

1 Commits

Author SHA1 Message Date
Sasanka c2b92f3086 new updates 2026-09-17 14:22:45 +05:30
280 changed files with 7385 additions and 135760 deletions
-92
View File
@@ -1,92 +0,0 @@
name: CI/CD - ERP Core Monorepo
on:
pull_request:
branches:
- production
types:
- closed
jobs:
build-and-deploy-backend:
name: Build & Deploy Backend
runs-on: linux
if: github.event.pull_request.merged == true
steps:
- name: Checkout code
run: |
git clone \
--depth 1 \
--branch production \
https://oauth2:${{ secrets.ERPCORE }}@gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git \
.
- name: Restore
run: dotnet restore Backend/ERPCore.sln
- name: Publish
run: |
rm -rf /tmp/erpcore-deploy
mkdir -p /tmp/erpcore-deploy
dotnet publish Backend/ERPCore/ERPCore.csproj \
-c Release \
-r linux-x64 \
--self-contained true \
-o /tmp/erpcore-deploy
- name: Stop service
run: systemctl stop erpcore.service
- name: Copy to deploy folder
run: |
mkdir -p /home/erp/core/publish
rm -rf /home/erp/core/publish/*
cp -r /tmp/erpcore-deploy/* /home/erp/core/publish/
- name: Fix ownership and permissions
run: |
chown -R erpuser:erpuser /home/erp/core/publish
find /home/erp/core/publish -type d -exec chmod 755 {} +
find /home/erp/core/publish -type f -exec chmod 644 {} +
chmod +x /home/erp/core/publish/ERPCore
- name: Restart service
run: systemctl restart erpcore.service
- name: Verify
run: |
systemctl is-active erpcore.service && echo "✅ Service running" || echo "❌ Service failed"
build-and-deploy-frontend:
name: Build & Deploy Frontend
runs-on: linux
if: github.event.pull_request.merged == true
steps:
- name: Checkout code
run: |
git clone \
--depth 1 \
--branch production \
https://oauth2:${{ secrets.ERPCORE }}@gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git \
.
- name: Build Docker image
run: |
docker build --progress=plain --no-cache \
-t erpcore-frontend:latest \
./Frontend/erp-system
- name: Stop & remove existing container
run: |
docker stop erpcore-frontend 2>/dev/null || true
docker rm erpcore-frontend 2>/dev/null || true
- name: Start container
run: |
docker run -d \
--name erpcore-frontend \
-p 6702:6702 \
--add-host=host.docker.internal:host-gateway \
-e BACKEND_ORIGIN=http://host.docker.internal:6701 \
--restart unless-stopped \
--memory="512m" \
--cpus="1.0" \
--cap-drop ALL \
--security-opt no-new-privileges \
erpcore-frontend:latest
- name: Remove old images
run: docker image prune -f
- name: Verify
run: |
docker ps --filter name=erpcore-frontend --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" \
| grep erpcore-frontend && echo "✅ Container running" || echo "❌ Container failed"
@@ -23,9 +23,8 @@ public sealed class CustomersController : ApiControllerBase
[FromQuery] PageQuery query,
[FromQuery] EntityStatus? status,
[FromQuery] CustomerType? customerType,
CancellationToken ct,
[FromQuery] bool includeCashCustomer = false)
=> Ok(await _customers.ListAsync(query, status, customerType, includeCashCustomer, ct));
CancellationToken ct)
=> Ok(await _customers.ListAsync(query, status, customerType, ct));
[HttpGet("{customerId:int}")]
[ProducesResponseType(typeof(CustomerDto), StatusCodes.Status200OK)]
@@ -1,45 +0,0 @@
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);
}
}
@@ -1,64 +0,0 @@
using ERPCore.Dtos.Items;
using ERPCore.Infra.Auth;
using ERPCore.Services;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Item photos. Same access as the item master itself.</summary>
[Route("api/v1/items/{itemId:int}/images")]
[Authorize(Policy = NavPolicies.Prefix + "products")]
public sealed class ItemImagesController : ApiControllerBase
{
private readonly IItemImageService _images;
private readonly ICurrentUser _currentUser;
public ItemImagesController(IItemImageService images, ICurrentUser currentUser)
{
_images = images;
_currentUser = currentUser;
}
[HttpGet]
[ProducesResponseType(typeof(List<ItemImageDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<List<ItemImageDto>>> List(int itemId, CancellationToken ct)
=> Ok(await _images.ListAsync(itemId, ct));
/// <summary>Upload one image (multipart <c>file</c>). 422 CONFIG_DISABLED while item images are off.</summary>
[HttpPost]
[RequestSizeLimit(ItemImageService.MaxSizeBytes + 64 * 1024)]
[ProducesResponseType(typeof(ItemImageDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status413PayloadTooLarge)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<ItemImageDto>> Upload(int itemId, IFormFile file, CancellationToken ct)
{
await using var stream = file.OpenReadStream();
var result = await _images.UploadAsync(itemId, stream, file.FileName, file.ContentType, _currentUser.AuditUserId, ct);
return Created($"/api/v1/items/{itemId}/images/{result.ItemImageId}", result);
}
/// <summary>The image bytes, served inline so an &lt;img&gt; can point straight at it.</summary>
[HttpGet("{itemImageId:int}/content")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Content(int itemId, int itemImageId, CancellationToken ct)
{
var (content, contentType) = await _images.OpenAsync(itemId, itemImageId, ct);
// Stored files are immutable (new upload ⇒ new id), so the browser may cache them.
Response.Headers.CacheControl = "private, max-age=86400";
return File(content, contentType);
}
[HttpDelete("{itemImageId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(int itemId, int itemImageId, CancellationToken ct)
{
await _images.DeleteAsync(itemId, itemImageId, ct);
return NoContent();
}
}
@@ -30,22 +30,6 @@ public sealed class ItemsController : ApiControllerBase
CancellationToken ct)
=> Ok(await _items.ListAsync(query, status, categoryId, subCategoryId, brandId, trackingMode, ct));
/// <summary>Items list enriched with stock value, low-stock, last-purchase and
/// trailing-30-day sales aggregates, for the Items overview table.</summary>
[HttpGet("overview")]
[ProducesResponseType(typeof(PagedResponse<ItemOverviewDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<ItemOverviewDto>>> Overview(
[FromQuery] PageQuery query,
[FromQuery] EntityStatus? status,
[FromQuery] int? categoryId,
[FromQuery] int? subCategoryId,
[FromQuery] int? brandId,
[FromQuery] TrackingMode? trackingMode,
[FromQuery] StockNature? stockNature,
[FromQuery] ItemStockFilter? stock,
CancellationToken ct)
=> Ok(await _items.GetOverviewAsync(query, status, categoryId, subCategoryId, brandId, trackingMode, stockNature, stock, ct));
/// <summary>Barcode/RFID lookup — matches an item's barcode/SKU, or a batch/serial
/// barcode/no (optionally scoped to <paramref name="itemId"/> first).</summary>
[HttpGet("scan")]
@@ -45,4 +45,5 @@ public sealed class ProductConfigController : ApiControllerBase
var result = await _config.UpdateAsync(request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}}
}
}
@@ -1,46 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Config;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Sales configuration endpoints — the singleton "allow cash customer" toggle.
/// <para>
/// <b>Authorization:</b> gated on the Sales nav policy (the New Sale form has to read it), same
/// Phase-1 posture as <see cref="BranchConfigController"/> — any Sales user can flip the flag
/// until per-endpoint RBAC lands.
/// </para>
/// </summary>
[Route("api/v1/sales-config")]
[Authorize(Policy = NavPolicies.Prefix + "sales")]
public sealed class SalesConfigController : ApiControllerBase
{
private readonly ISalesConfigService _config;
public SalesConfigController(ISalesConfigService config) => _config = config;
[HttpGet]
[ProducesResponseType(typeof(SalesConfigDto), StatusCodes.Status200OK)]
public async Task<ActionResult<SalesConfigDto>> Get(CancellationToken ct)
{
var result = await _config.GetAsync(ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPut]
[ProducesResponseType(typeof(SalesConfigDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<SalesConfigDto>> Update(
[FromBody] UpdateSalesConfigRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _config.UpdateAsync(request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
}
@@ -14,70 +14,11 @@ public sealed class SalesInvoicesController : ApiControllerBase
{
private readonly ISalesInvoiceService _invoices;
private readonly ISalesInvoicePaymentService _payments;
private readonly ISalesPricingService _pricing;
private readonly ISalesDomainService _sales;
private readonly IStockService _stock;
public SalesInvoicesController(
ISalesInvoiceService invoices, ISalesInvoicePaymentService payments, ISalesPricingService pricing,
ISalesDomainService sales, IStockService stock)
public SalesInvoicesController(ISalesInvoiceService invoices, ISalesInvoicePaymentService payments)
{
_invoices = invoices;
_payments = payments;
_pricing = pricing;
_sales = sales;
_stock = stock;
}
// Lets the New Sale form warn, per line, when qty exceeds the stock a sale can issue — under
// the "sales" policy, since sales users may not hold the "stock" nav permission. Non-stocked
// items (services) are never short, so they report IsStocked = false and no limit.
[HttpGet("stock-availability")]
[ProducesResponseType(typeof(SalesStockAvailabilityDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesStockAvailabilityDto>> StockAvailability(
[FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
{
bool isStocked;
try
{
isStocked = await _sales.IsStockedItemAsync(itemId, ct);
}
catch (InvalidOperationException)
{
return NotFound();
}
var available = isStocked ? (await _stock.GetOnHandAsync(itemId, warehouseId, ct)).Available : 0m;
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")]
[ProducesResponseType(typeof(SalesSuggestedPriceDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesSuggestedPriceDto>> SuggestedPrice(
[FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
{
try
{
var price = await _pricing.ResolveAsync(itemId, warehouseId, null, false, ct);
return Ok(new SalesSuggestedPriceDto(itemId, warehouseId, price.UnitPrice, price.PriceSource));
}
catch (InvalidOperationException)
{
return NotFound();
}
}
[HttpGet]
@@ -88,10 +29,8 @@ public sealed class SalesInvoicesController : ApiControllerBase
[FromQuery] int? customerId,
[FromQuery] int? warehouseId,
[FromQuery] int? branchId,
[FromQuery] DateOnly? from,
[FromQuery] DateOnly? to,
CancellationToken ct)
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, from, to, ct));
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, ct));
[HttpGet("{salesInvoiceId:int}")]
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
@@ -153,18 +92,6 @@ 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)]
+2 -14
View File
@@ -1,4 +1,3 @@
using ERPCore.Domain.Enums;
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Common;
@@ -19,9 +18,8 @@ public sealed class UomsController : ApiControllerBase
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<UomDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<UomDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _uoms.ListAsync(query, status, ct));
public async Task<ActionResult<PagedResponse<UomDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _uoms.ListAsync(query, ct));
[HttpPost]
[ProducesResponseType(typeof(UomDto), StatusCodes.Status201Created)]
@@ -31,14 +29,4 @@ public sealed class UomsController : ApiControllerBase
var dto = await _uoms.CreateAsync(request, ct);
return Created($"/api/v1/uoms/{dto.UomId}", dto);
}
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
[HttpPatch("{uomId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int uomId, [FromBody] UpdateUomStatusRequest request, CancellationToken ct)
{
await _uoms.SetStatusAsync(uomId, request.Status, ct);
return NoContent();
}
}
@@ -1,44 +0,0 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Items;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Custom warranty period master — managed under Products like Item Variants.</summary>
[Route("api/v1/warranty-periods")]
[Authorize(Policy = NavPolicies.Prefix + "products")]
public sealed class WarrantyPeriodsController : ApiControllerBase
{
private readonly IWarrantyPeriodService _periods;
public WarrantyPeriodsController(IWarrantyPeriodService periods) => _periods = periods;
/// <summary>Custom periods only (defaults are fixed); filter <c>status=Active</c> for selectable rows.</summary>
[HttpGet]
[ProducesResponseType(typeof(List<WarrantyPeriodDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<WarrantyPeriodDto>>> List([FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _periods.ListAsync(status, ct));
[HttpPost]
[ProducesResponseType(typeof(WarrantyPeriodDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<WarrantyPeriodDto>> Create([FromBody] CreateWarrantyPeriodRequest request, CancellationToken ct)
{
var result = await _periods.CreateAsync(request.Months!.Value, ct);
return Created($"/api/v1/warranty-periods/{result.WarrantyPeriodId}", result);
}
/// <summary>Deactivate/reactivate. Never hard-deleted (FR-MD-08).</summary>
[HttpPatch("{warrantyPeriodId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int warrantyPeriodId, [FromBody] UpdateWarrantyPeriodStatusRequest request, CancellationToken ct)
{
await _periods.SetStatusAsync(warrantyPeriodId, request.Status, ct);
return NoContent();
}
}
@@ -1,41 +0,0 @@
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; }
}
@@ -1,27 +0,0 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// A product photo attached to an item. Like <see cref="EmployeeDocument"/>, the file lives
/// behind <see cref="Infra.Storage.IFileStorageService"/> under a server-generated name and is
/// only reachable through the authenticated items API — never a static path. Only uploadable
/// while <see cref="ProductConfig.ItemImagesEnabled"/> is on; existing images stay readable
/// after the flag is switched off (reads are never gated).
/// </summary>
public class ItemImage
{
public int ItemImageId { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public string OriginalFileName { get; set; } = string.Empty;
public string StoredFileName { get; set; } = string.Empty;
public string RelativePath { get; set; } = string.Empty;
public string ContentType { get; set; } = string.Empty;
public long SizeBytes { get; set; }
/// <summary>Display order; the lowest is the item's main image.</summary>
public int SortOrder { get; set; }
public int UploadedBy { get; set; }
public DateTime UploadedAt { get; set; }
}
@@ -31,26 +31,6 @@ public class ProductConfig
public bool BatchListsEnabled { get; set; } = true;
public bool WarrantyPeriodsEnabled { get; set; } = true;
/// <summary>
/// Lets the item create screen add a new item variant (item type) on the spot, instead
/// of only picking from the Item Variants master. Advisory like <see cref="ItemTypesEnabled"/>
/// - it gates a UI shortcut; the Item Variants master itself stays available either way.
/// </summary>
public bool CustomVariantsEnabled { get; set; }
/// <summary>
/// Allows photos to be uploaded to items (<see cref="ItemImage"/>). Enforced on upload
/// (<c>CONFIG_DISABLED</c>); images added earlier stay viewable when switched off.
/// </summary>
public bool ItemImagesEnabled { get; set; }
/// <summary>
/// Lets items use the active <see cref="WarrantyPeriod"/> master rows alongside the default
/// <see cref="Enums.WarrantyPeriods.AllowedMonths"/>. Enforced on item writes. The periods
/// themselves are managed under Products, not here.
/// </summary>
public bool CustomWarrantyPeriodsEnabled { get; set; }
public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; }
@@ -1,61 +0,0 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Sales configuration — a singleton row (single-tenant, docs/00-CORE §1), same shape as
/// <see cref="BranchConfig"/>. When <see cref="AllowCashCustomer"/> is on, sales documents may
/// be raised against the generic "Cash Customer" (<see cref="CashCustomerId"/>) and the New Sale
/// form defaults to it; when off, a real customer must be picked and the cash customer is
/// rejected server-side (see SalesDomainService.ValidateSalesHeaderAsync).
/// </summary>
public class SalesConfig
{
/// <summary>Always 1 - the singleton row's id.</summary>
public const int SingletonId = 1;
public int ConfigId { get; set; }
public bool AllowCashCustomer { get; set; }
/// <summary>The Customer row used for cash (walk-in, unnamed) sales. Created on first
/// enabling <see cref="AllowCashCustomer"/> and kept afterwards, so switching the option
/// off and on again reuses the same customer (and its sales history).</summary>
public int? CashCustomerId { get; set; }
public Customer? CashCustomer { get; set; }
/// <summary>When on, sales invoice lines may carry a free quantity (the New Sale "Free"
/// column); when off, the column is hidden and a free qty above zero is rejected server-side
/// (see SalesInvoiceService.BuildLinesAsync). Defaults on, matching the behaviour before
/// this option existed.</summary>
public bool AllowFreeItems { get; set; } = true;
/// <summary>When on, sales invoice lines may carry a line discount (the New Sale "Disc %"
/// column); when off, the column is hidden and a discount above zero is rejected server-side
/// (see SalesInvoiceService.BuildLinesAsync). Defaults on, matching the behaviour before
/// this option existed.</summary>
public bool AllowDiscounts { get; set; } = true;
/// <summary>When on, posting a sale (invoice, slip, bundle) may issue more than the stock on
/// hand — the shortfall drives on-hand negative (see FifoCostingService.ConsumeAsync) and the
/// pre-post check reports it as a warning only. When off (default), posting is blocked with
/// STOCK_NEGATIVE_BLOCKED until stock covers every line.</summary>
public bool AllowOverStockSale { get; set; }
/// <summary>When on (default) and no <see cref="DefaultWarehouseId"/> is saved, New Sale shows
/// a Warehouse picker. When off, a default must be saved.</summary>
public bool AllowWarehouseSelection { get; set; } = true;
/// <summary>Default sales warehouse. Whenever set, New Sale shows only this warehouse (locked)
/// and every sales invoice (header and lines) is forced onto it server-side (see
/// SalesDomainService.GetForcedSalesWarehouseIdAsync). Required while
/// <see cref="AllowWarehouseSelection"/> is off; clearing it brings the picker back.</summary>
public int? DefaultWarehouseId { get; set; }
public Warehouse? DefaultWarehouse { get; set; }
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; }
}
-6
View File
@@ -1,5 +1,3 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
@@ -12,8 +10,4 @@ public class Uom
{
public int UomId { get; set; }
public string Name { get; set; } = string.Empty;
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
}
-6
View File
@@ -15,12 +15,6 @@ public class User
/// <summary>Seeded fallback actor for unauthenticated/system operations.</summary>
public const int SystemUserId = 1;
/// <summary>
/// Username prefix of a permanently deleted login whose row is kept only because history
/// (audit log, documents) still references it. Such rows are hidden from user management.
/// </summary>
public const string DeletedUsernamePrefix = "deleted-user-";
public int UserId { get; set; }
public string Username { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
@@ -1,23 +0,0 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Custom warranty period master — a coverage length offered to items alongside the fixed
/// defaults in <see cref="WarrantyPeriods.AllowedMonths"/>, while
/// <see cref="ProductConfig.CustomWarrantyPeriodsEnabled"/> is on.
/// <para>
/// Always in months (a "2 year" entry is stored as 24). Like <see cref="ItemType"/> it is
/// deliberately unlinked: an item stores the number of months itself, not a reference, so
/// deactivating a period only stops it being offered — items already using it keep it.
/// The length is immutable (no PUT); a different length is a different row.
/// </para>
/// </summary>
public class WarrantyPeriod
{
public int WarrantyPeriodId { get; set; }
public int Months { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
}
@@ -1,22 +0,0 @@
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; }
}
@@ -12,8 +12,6 @@ namespace ERPCore.Dtos.Config;
public sealed record ProductConfigDto(
bool SubcategoriesEnabled, bool BrandsEnabled, bool ItemTypesEnabled,
bool SellingPricesEnabled, bool SerialNumbersEnabled, bool BatchListsEnabled, bool WarrantyPeriodsEnabled,
bool CustomVariantsEnabled, bool ItemImagesEnabled,
bool CustomWarrantyPeriodsEnabled,
DateTime? UpdatedAt, int? UpdatedBy);
/// <summary>
@@ -34,7 +32,4 @@ public sealed class UpdateProductConfigRequest
[Required] public bool? SerialNumbersEnabled { get; set; }
[Required] public bool? BatchListsEnabled { get; set; }
[Required] public bool? WarrantyPeriodsEnabled { get; set; }
[Required] public bool? CustomVariantsEnabled { get; set; }
[Required] public bool? ItemImagesEnabled { get; set; }
[Required] public bool? CustomWarrantyPeriodsEnabled { get; set; }
}
@@ -1,25 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Config;
/// <summary>Sales configuration resource. <c>CashCustomerId</c>/<c>CashCustomerName</c> identify
/// the generic cash customer (null until the option has been switched on at least once).</summary>
public sealed record SalesConfigDto(
bool AllowCashCustomer, int? CashCustomerId, string? CashCustomerName, bool AllowFreeItems, bool AllowDiscounts, bool AllowOverStockSale,
bool AllowWarehouseSelection, int? DefaultWarehouseId, string? DefaultWarehouseName, 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="UpdateBranchConfigRequest"/>).
/// </summary>
public sealed class UpdateSalesConfigRequest
{
[Required] public bool? AllowCashCustomer { get; set; }
[Required] public bool? AllowFreeItems { get; set; }
[Required] public bool? AllowDiscounts { get; set; }
[Required] public bool? AllowOverStockSale { get; set; }
[Required] public bool? AllowWarehouseSelection { get; set; }
/// <summary>Required when <see cref="AllowWarehouseSelection"/> is false.</summary>
public int? DefaultWarehouseId { get; set; }
}
@@ -23,12 +23,7 @@ public sealed record DashboardStatsDto(
int PendingCounts,
int OpenRfqs,
int PendingLeaveRequests,
int OverdueSalesInvoices,
IReadOnlyList<MonthlyPurchasesVsSalesDto> MonthlyPurchasesVsSales);
int OverdueSalesInvoices);
/// <summary>One bar in the Stock Valuation chart — total FIFO layer value for a warehouse.</summary>
public sealed record WarehouseValuationDto(int WarehouseId, decimal Total);
/// <summary>One month in the Monthly Purchases chart (Purchase Invoices page) — GrandTotal
/// of Posted purchase invoices vs Posted sales invoices. <c>Month</c> is "yyyy-MM".</summary>
public sealed record MonthlyPurchasesVsSalesDto(string Month, decimal Purchases, decimal Sales);
+1 -1
View File
@@ -22,7 +22,7 @@ public sealed record GrnDto(
/// <summary>Row shape for <c>GET /grns</c> — line count instead of the lines themselves.</summary>
public sealed record GrnSummaryDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, int BranchId, GrnStatus Status,
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount, decimal TotalValue,
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount,
int? PurchaseInvoiceId, string? PurchaseInvoiceNo);
public sealed record CreatedLayerDto(
+1 -2
View File
@@ -133,6 +133,5 @@ public sealed class ReorderSettingInput
public sealed class UpdateReorderRequest
{
/// <summary>The complete set — an empty list removes every rule for the item.</summary>
[Required] public List<ReorderSettingInput> Settings { get; set; } = new();
[Required, MinLength(1)] public List<ReorderSettingInput> Settings { get; set; } = new();
}
@@ -1,7 +0,0 @@
namespace ERPCore.Dtos.Items;
/// <summary>An item photo's metadata. The bytes are served by
/// <c>GET /items/{itemId}/images/{itemImageId}/content</c>.</summary>
public sealed record ItemImageDto(
int ItemImageId, int ItemId, string FileName, string ContentType, long SizeBytes,
int SortOrder, DateTime UploadedAt);
@@ -1,27 +0,0 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Items;
/// <summary>
/// Item row enriched with cross-module aggregates (current FIFO stock value, low-stock
/// flag, last purchase, trailing-30-day sales) for the Items list page. Deliberately a
/// separate endpoint from <see cref="ItemListItemDto"/>'s <c>GET /items</c> — that list
/// also backs the GRN/PO/Requisition/RFQ item pickers, which don't need these aggregates
/// computed on every keystroke.
/// </summary>
public sealed record ItemOverviewDto(
int ItemId, string Sku, string Name,
int CategoryId, int? SubCategoryId, int? BrandId,
EntityStatus Status,
decimal StockQty, decimal StockValue, bool IsLowStock,
DateTime? LastPurchaseDate, int? LastSupplierId, string? LastSupplierName,
decimal SalesQtyLast30Days);
/// <summary>Stock-state filter for the Items overview. <c>LowStock</c> uses the same rule
/// as <see cref="ItemOverviewDto.IsLowStock"/>: on-hand at or below any warehouse's reorder point.</summary>
public enum ItemStockFilter
{
InStock,
OutOfStock,
LowStock
}
@@ -1,17 +0,0 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Items;
public sealed record WarrantyPeriodDto(int WarrantyPeriodId, int Months, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
/// <summary>A new custom length, always in months (the client converts years).</summary>
public sealed class CreateWarrantyPeriodRequest
{
[Required] public int? Months { get; set; }
}
public sealed class UpdateWarrantyPeriodStatusRequest
{
[Required] public EntityStatus Status { get; set; }
}
@@ -34,28 +34,6 @@ 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(
@@ -66,18 +44,6 @@ public sealed record SalesInvoicePostingCheckDto(
int SalesInvoiceId, string InvoiceNo, SalesInvoiceStatus Status, bool CanPost,
IReadOnlyList<SalesInvoicePostingIssueDto> Issues);
/// <summary>The unit price a new line would get if left blank — same rule as on save
/// (SALE_PRICE, else GRN_WEIGHTED_AVG for the warehouse, else FIFO_AVG).</summary>
public sealed record SalesSuggestedPriceDto(int ItemId, int WarehouseId, decimal UnitPrice, string PriceSource);
/// <summary>Issuable stock for one item at one warehouse (on-hand less on-hold), for New Sale's
/// 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; }
+1 -7
View File
@@ -1,17 +1,11 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Uoms;
/// <summary>UOM resource (docs/11-BACKEND-PHASE1.md §2.2).</summary>
public sealed record UomDto(int UomId, string Name, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
public sealed record UomDto(int UomId, string Name);
public sealed class CreateUomRequest
{
[Required, StringLength(50)] public string Name { get; set; } = string.Empty;
}
public sealed class UpdateUomStatusRequest
{
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
}
@@ -1,42 +0,0 @@
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);
}
}
@@ -1,25 +0,0 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class ItemImageConfiguration : IEntityTypeConfiguration<ItemImage>
{
public void Configure(EntityTypeBuilder<ItemImage> builder)
{
builder.ToTable("item_images");
builder.HasKey(i => i.ItemImageId);
builder.Property(i => i.OriginalFileName).IsRequired().HasMaxLength(260);
builder.Property(i => i.StoredFileName).IsRequired().HasMaxLength(260);
builder.Property(i => i.RelativePath).IsRequired().HasMaxLength(500);
builder.Property(i => i.ContentType).IsRequired().HasMaxLength(200);
builder.Property(i => i.UploadedAt).IsRequired();
builder.HasOne(i => i.Item).WithMany()
.HasForeignKey(i => i.ItemId).OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(i => new { i.ItemId, i.SortOrder });
}
}
@@ -29,9 +29,7 @@ public sealed class ProductConfigConfiguration : IEntityTypeConfiguration<Produc
builder.Property(c => c.SerialNumbersEnabled).IsRequired().HasDefaultValue(true);
builder.Property(c => c.BatchListsEnabled).IsRequired().HasDefaultValue(true);
builder.Property(c => c.WarrantyPeriodsEnabled).IsRequired().HasDefaultValue(true);
builder.Property(c => c.CustomVariantsEnabled).IsRequired().HasDefaultValue(false);
builder.Property(c => c.ItemImagesEnabled).IsRequired().HasDefaultValue(false);
builder.Property(c => c.CustomWarrantyPeriodsEnabled).IsRequired().HasDefaultValue(false);
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(c => c.RowVersion).IsRowVersion();
@@ -1,49 +0,0 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
/// <summary>
/// Configures the singleton sales-configuration row. The check constraint is what makes
/// "singleton" a database guarantee rather than a convention (mirrors BranchConfig).
/// </summary>
public sealed class SalesConfigConfiguration : IEntityTypeConfiguration<SalesConfig>
{
public void Configure(EntityTypeBuilder<SalesConfig> builder)
{
builder.ToTable("sales_config", t =>
t.HasCheckConstraint("ck_sales_config_singleton", $"\"ConfigId\" = {SalesConfig.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();
builder.Property(c => c.AllowCashCustomer).IsRequired().HasDefaultValue(false);
// 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.AllowFreeItems).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.Property(c => c.AllowDiscounts).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.Property(c => c.AllowOverStockSale).IsRequired().HasDefaultValue(false);
builder.Property(c => c.AllowWarehouseSelection).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.CashCustomer)
.WithMany()
.HasForeignKey(c => c.CashCustomerId)
.OnDelete(DeleteBehavior.SetNull);
builder.HasOne(c => c.UpdatedByUser)
.WithMany()
.HasForeignKey(c => c.UpdatedBy)
.OnDelete(DeleteBehavior.Restrict);
}
}
@@ -1,5 +1,4 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
@@ -14,13 +13,5 @@ public sealed class UomConfiguration : IEntityTypeConfiguration<Uom>
builder.Property(u => u.Name).IsRequired().HasMaxLength(50);
builder.HasIndex(u => u.Name).IsUnique();
builder.Property(u => u.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.Property(u => u.CreatedAt).IsRequired();
builder.HasIndex(u => u.Status);
}
}
@@ -1,27 +0,0 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
/// <summary>Custom warranty period master. No relationships — items store months, not a reference.</summary>
public sealed class WarrantyPeriodConfiguration : IEntityTypeConfiguration<WarrantyPeriod>
{
public void Configure(EntityTypeBuilder<WarrantyPeriod> builder)
{
builder.ToTable("warranty_periods", t =>
t.HasCheckConstraint("ck_warranty_periods_months", "\"Months\" BETWEEN 1 AND 600"));
builder.HasKey(p => p.WarrantyPeriodId);
builder.Property(p => p.Months).IsRequired();
builder.HasIndex(p => p.Months).IsUnique();
builder.Property(p => p.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.Property(p => p.CreatedAt).IsRequired();
builder.HasIndex(p => p.Status);
}
}
@@ -46,8 +46,6 @@ public static class DataSeeder
// dirty |= await SeedCompanyProfileAsync(db, ct);
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);
@@ -108,10 +106,7 @@ public static class DataSeeder
SellingPricesEnabled = true,
SerialNumbersEnabled = true,
BatchListsEnabled = true,
WarrantyPeriodsEnabled = true,
CustomVariantsEnabled = false,
ItemImagesEnabled = false,
CustomWarrantyPeriodsEnabled = false
WarrantyPeriodsEnabled = true
});
return true;
}
@@ -133,28 +128,6 @@ public static class DataSeeder
return true;
}
/// <summary>
/// Ensures the singleton sales-config row exists. Starts with cash customer sales off, so
/// every sale needs a real customer until an admin opts in from Settings → Configuration → Sales Configuration.
/// </summary>
private static async Task<bool> SeedSalesConfigAsync(ErpDbContext db, CancellationToken ct)
{
if (await db.SalesConfig.AnyAsync(c => c.ConfigId == SalesConfig.SingletonId, ct)) return false;
db.SalesConfig.Add(new SalesConfig { ConfigId = SalesConfig.SingletonId, AllowCashCustomer = false });
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;
@@ -35,8 +35,6 @@ public class ErpDbContext : DbContext
public DbSet<TaxRate> TaxRates => Set<TaxRate>();
public DbSet<Item> Items => Set<Item>();
public DbSet<ItemReorder> ItemReorders => Set<ItemReorder>();
public DbSet<ItemImage> ItemImages => Set<ItemImage>();
public DbSet<WarrantyPeriod> WarrantyPeriods => Set<WarrantyPeriod>();
public DbSet<Vendor> Vendors => Set<Vendor>();
public DbSet<Warehouse> Warehouses => Set<Warehouse>();
public DbSet<Bin> Bins => Set<Bin>();
@@ -45,8 +43,6 @@ public class ErpDbContext : DbContext
public DbSet<ProductConfig> ProductConfig => Set<ProductConfig>();
/// <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
@@ -1,61 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddUomLifecycleColumnsLocal : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "CreatedAt",
table: "uoms",
type: "timestamp with time zone",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
migrationBuilder.AddColumn<string>(
name: "Status",
table: "uoms",
type: "character varying(20)",
maxLength: 20,
nullable: false,
defaultValue: "Active");
migrationBuilder.AddColumn<DateTime>(
name: "UpdatedAt",
table: "uoms",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_uoms_Status",
table: "uoms",
column: "Status");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_uoms_Status",
table: "uoms");
migrationBuilder.DropColumn(
name: "CreatedAt",
table: "uoms");
migrationBuilder.DropColumn(
name: "Status",
table: "uoms");
migrationBuilder.DropColumn(
name: "UpdatedAt",
table: "uoms");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,22 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class changeMismatch : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,61 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddSalesConfig : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "sales_config",
columns: table => new
{
ConfigId = table.Column<int>(type: "integer", nullable: false),
AllowCashCustomer = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
CashCustomerId = 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_sales_config", x => x.ConfigId);
table.CheckConstraint("ck_sales_config_singleton", "\"ConfigId\" = 1");
table.ForeignKey(
name: "FK_sales_config_customers_CashCustomerId",
column: x => x.CashCustomerId,
principalTable: "customers",
principalColumn: "CustomerId",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_sales_config_users_UpdatedBy",
column: x => x.UpdatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_sales_config_CashCustomerId",
table: "sales_config",
column: "CashCustomerId");
migrationBuilder.CreateIndex(
name: "IX_sales_config_UpdatedBy",
table: "sales_config",
column: "UpdatedBy");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "sales_config");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,29 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddSalesConfigAllowFreeItems : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "AllowFreeItems",
table: "sales_config",
type: "boolean",
nullable: false,
defaultValue: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AllowFreeItems",
table: "sales_config");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,29 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddSalesConfigAllowOverStockSale : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "AllowOverStockSale",
table: "sales_config",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AllowOverStockSale",
table: "sales_config");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,60 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddSalesConfigWarehouseSelection : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "AllowWarehouseSelection",
table: "sales_config",
type: "boolean",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<int>(
name: "DefaultWarehouseId",
table: "sales_config",
type: "integer",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_sales_config_DefaultWarehouseId",
table: "sales_config",
column: "DefaultWarehouseId");
migrationBuilder.AddForeignKey(
name: "FK_sales_config_warehouses_DefaultWarehouseId",
table: "sales_config",
column: "DefaultWarehouseId",
principalTable: "warehouses",
principalColumn: "WarehouseId",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_sales_config_warehouses_DefaultWarehouseId",
table: "sales_config");
migrationBuilder.DropIndex(
name: "IX_sales_config_DefaultWarehouseId",
table: "sales_config");
migrationBuilder.DropColumn(
name: "AllowWarehouseSelection",
table: "sales_config");
migrationBuilder.DropColumn(
name: "DefaultWarehouseId",
table: "sales_config");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,29 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddProductConfigCustomVariants : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "CustomVariantsEnabled",
table: "product_config",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CustomVariantsEnabled",
table: "product_config");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,66 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddItemImages : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "ItemImagesEnabled",
table: "product_config",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.CreateTable(
name: "item_images",
columns: table => new
{
ItemImageId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
ItemId = table.Column<int>(type: "integer", nullable: false),
OriginalFileName = table.Column<string>(type: "character varying(260)", maxLength: 260, nullable: false),
StoredFileName = table.Column<string>(type: "character varying(260)", maxLength: 260, nullable: false),
RelativePath = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
ContentType = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
SizeBytes = table.Column<long>(type: "bigint", nullable: false),
SortOrder = table.Column<int>(type: "integer", nullable: false),
UploadedBy = table.Column<int>(type: "integer", nullable: false),
UploadedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_item_images", x => x.ItemImageId);
table.ForeignKey(
name: "FK_item_images_items_ItemId",
column: x => x.ItemId,
principalTable: "items",
principalColumn: "ItemId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_item_images_ItemId_SortOrder",
table: "item_images",
columns: new[] { "ItemId", "SortOrder" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "item_images");
migrationBuilder.DropColumn(
name: "ItemImagesEnabled",
table: "product_config");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,41 +0,0 @@
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddCustomWarrantyPeriods : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<List<int>>(
name: "CustomWarrantyPeriodMonths",
table: "product_config",
type: "integer[]",
nullable: false,
defaultValueSql: "'{}'::integer[]");
migrationBuilder.AddColumn<bool>(
name: "CustomWarrantyPeriodsEnabled",
table: "product_config",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CustomWarrantyPeriodMonths",
table: "product_config");
migrationBuilder.DropColumn(
name: "CustomWarrantyPeriodsEnabled",
table: "product_config");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,80 +0,0 @@
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class MoveCustomWarrantyPeriodsToMaster : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "warranty_periods",
columns: table => new
{
WarrantyPeriodId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Months = table.Column<int>(type: "integer", nullable: false),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_warranty_periods", x => x.WarrantyPeriodId);
table.CheckConstraint("ck_warranty_periods_months", "\"Months\" BETWEEN 1 AND 600");
});
migrationBuilder.CreateIndex(
name: "IX_warranty_periods_Months",
table: "warranty_periods",
column: "Months",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_warranty_periods_Status",
table: "warranty_periods",
column: "Status");
// Carry over any periods already saved on the config (hand-added — EF would have
// dropped the column first and lost them). Out-of-range values are skipped rather
// than failing the check constraint.
migrationBuilder.Sql("""
INSERT INTO warranty_periods ("Months", "Status", "CreatedAt")
SELECT DISTINCT m, 'Active', now()
FROM product_config, unnest("CustomWarrantyPeriodMonths") AS m
WHERE m BETWEEN 1 AND 600
ON CONFLICT ("Months") DO NOTHING;
""");
migrationBuilder.DropColumn(
name: "CustomWarrantyPeriodMonths",
table: "product_config");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<List<int>>(
name: "CustomWarrantyPeriodMonths",
table: "product_config",
type: "integer[]",
nullable: false,
defaultValueSql: "'{}'::integer[]");
migrationBuilder.Sql("""
UPDATE product_config SET "CustomWarrantyPeriodMonths" = COALESCE(
(SELECT array_agg("Months" ORDER BY "Months") FROM warranty_periods WHERE "Status" = 'Active'),
'{}'::integer[]);
""");
migrationBuilder.DropTable(
name: "warranty_periods");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,29 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddSalesConfigAllowDiscounts : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "AllowDiscounts",
table: "sales_config",
type: "boolean",
nullable: false,
defaultValue: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AllowDiscounts",
table: "sales_config");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,61 +0,0 @@
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
@@ -1,40 +0,0 @@
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,53 +1531,6 @@ 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")
@@ -1878,56 +1831,6 @@ namespace ERPCore.Migrations
b.ToTable("items", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.ItemImage", b =>
{
b.Property<int>("ItemImageId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("ItemImageId"));
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("ItemId")
.HasColumnType("integer");
b.Property<string>("OriginalFileName")
.IsRequired()
.HasMaxLength(260)
.HasColumnType("character varying(260)");
b.Property<string>("RelativePath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<long>("SizeBytes")
.HasColumnType("bigint");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<string>("StoredFileName")
.IsRequired()
.HasMaxLength(260)
.HasColumnType("character varying(260)");
b.Property<DateTime>("UploadedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("UploadedBy")
.HasColumnType("integer");
b.HasKey("ItemImageId");
b.HasIndex("ItemId", "SortOrder");
b.ToTable("item_images", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b =>
{
b.Property<int>("ReorderId")
@@ -3349,21 +3252,6 @@ namespace ERPCore.Migrations
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<bool>("CustomVariantsEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<bool>("CustomWarrantyPeriodsEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<bool>("ItemImagesEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<bool>("ItemTypesEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
@@ -4449,68 +4337,6 @@ namespace ERPCore.Migrations
b.ToTable("hr_salary_components", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesConfig", b =>
{
b.Property<int>("ConfigId")
.HasColumnType("integer");
b.Property<bool>("AllowCashCustomer")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<bool>("AllowDiscounts")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<bool>("AllowFreeItems")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<bool>("AllowOverStockSale")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<bool>("AllowWarehouseSelection")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<int?>("CashCustomerId")
.HasColumnType("integer");
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("CashCustomerId");
b.HasIndex("DefaultWarehouseId");
b.HasIndex("UpdatedBy");
b.ToTable("sales_config", null, t =>
{
t.HasCheckConstraint("ck_sales_config_singleton", "\"ConfigId\" = 1");
});
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesDayEnd", b =>
{
b.Property<int>("SalesDayEndId")
@@ -6519,31 +6345,16 @@ namespace ERPCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("UomId"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Active");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("UomId");
b.HasIndex("Name")
.IsUnique();
b.HasIndex("Status");
b.ToTable("uoms", (string)null);
});
@@ -6767,43 +6578,6 @@ namespace ERPCore.Migrations
b.ToTable("warehouses", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.WarrantyPeriod", b =>
{
b.Property<int>("WarrantyPeriodId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("WarrantyPeriodId"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Months")
.HasColumnType("integer");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Active");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("WarrantyPeriodId");
b.HasIndex("Months")
.IsUnique();
b.HasIndex("Status");
b.ToTable("warranty_periods", null, t =>
{
t.HasCheckConstraint("ck_warranty_periods_months", "\"Months\" BETWEEN 1 AND 600");
});
});
modelBuilder.Entity("ERPCore.Domain.Entities.WorkShift", b =>
{
b.Property<int>("WorkShiftId")
@@ -7258,23 +7032,6 @@ 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")
@@ -7373,17 +7130,6 @@ namespace ERPCore.Migrations
b.Navigation("TaxRate");
});
modelBuilder.Entity("ERPCore.Domain.Entities.ItemImage", b =>
{
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
.WithMany()
.HasForeignKey("ItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Item");
});
modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b =>
{
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
@@ -8074,30 +7820,6 @@ namespace ERPCore.Migrations
b.Navigation("Uom");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesConfig", b =>
{
b.HasOne("ERPCore.Domain.Entities.Customer", "CashCustomer")
.WithMany()
.HasForeignKey("CashCustomerId")
.OnDelete(DeleteBehavior.SetNull);
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("CashCustomer");
b.Navigation("DefaultWarehouse");
b.Navigation("UpdatedByUser");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesDayEnd", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "CashierUser")
-4
View File
@@ -87,8 +87,6 @@ builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
// Master-data services (docs/11 §2)
builder.Services.AddScoped<ICustomerService, CustomerService>();
builder.Services.AddScoped<IItemService, ItemService>();
builder.Services.AddScoped<IItemImageService, ItemImageService>();
builder.Services.AddScoped<IWarrantyPeriodService, WarrantyPeriodService>();
builder.Services.AddScoped<IUomService, UomService>();
builder.Services.AddScoped<ICategoryService, CategoryService>();
builder.Services.AddScoped<IBrandService, BrandService>();
@@ -97,8 +95,6 @@ builder.Services.AddScoped<IItemTypeService, ItemTypeService>();
//builder.Services.AddScoped<ICompanyProfileService, CompanyProfileService>();
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>();
@@ -6,7 +6,7 @@
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "http://localhost:6701",
"applicationUrl": "http://localhost:5224",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
@@ -16,7 +16,7 @@
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "https://localhost:7112;http://localhost:6701",
"applicationUrl": "https://localhost:7112;http://localhost:5224",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
+2 -20
View File
@@ -16,37 +16,19 @@ 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;
/// <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)
public CustomerService(IRepository<Customer> customers, IRepository<Warehouse> warehouses, IBranchConfigService branchConfig, IUnitOfWork uow)
{
_customers = customers;
_warehouses = warehouses;
_branchConfig = branchConfig;
_salesConfig = salesConfig;
_uow = uow;
}
public async Task<PagedResponse<CustomerDto>> ListAsync(
PageQuery query, EntityStatus? status, CustomerType? customerType, bool includeCashCustomer = false, CancellationToken ct = default)
public async Task<PagedResponse<CustomerDto>> ListAsync(PageQuery query, EntityStatus? status, CustomerType? customerType, 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();
+2 -48
View File
@@ -26,7 +26,6 @@ public sealed class DashboardService : IDashboardService
private readonly IRepository<Rfq> _rfqs;
private readonly IRepository<LeaveRequest> _leaveRequests;
private readonly IRepository<SalesInvoice> _salesInvoices;
private readonly IRepository<PurchaseInvoice> _purchaseInvoices;
private readonly IReorderService _reorder;
// Neither SalesInvoice nor PurchaseInvoice carries a DueDate field (Phase 1 schema
@@ -34,14 +33,11 @@ public sealed class DashboardService : IDashboardService
// balance older than this many days from its invoice date.
private const int OverdueAgeDays = 30;
// Monthly Purchases chart window: the current month plus the previous 11.
private const int ChartMonths = 12;
public DashboardService(
IRepository<StockLayer> layers, IRepository<Grn> grns, IRepository<PurchaseOrder> pos,
IRepository<Requisition> requisitions, IRepository<StockCount> counts, IRepository<Rfq> rfqs,
IRepository<LeaveRequest> leaveRequests, IRepository<SalesInvoice> salesInvoices,
IRepository<PurchaseInvoice> purchaseInvoices, IReorderService reorder)
IReorderService reorder)
{
_layers = layers;
_grns = grns;
@@ -51,7 +47,6 @@ public sealed class DashboardService : IDashboardService
_rfqs = rfqs;
_leaveRequests = leaveRequests;
_salesInvoices = salesInvoices;
_purchaseInvoices = purchaseInvoices;
_reorder = reorder;
}
@@ -108,50 +103,9 @@ public sealed class DashboardService : IDashboardService
var lowStockAlerts = (await _reorder.GetAlertsAsync(null, branchId, new PageQuery { Page = 1, PageSize = 1 }, ct))
.Pagination.TotalItems;
var monthlyPurchasesVsSales = await GetMonthlyPurchasesVsSalesAsync(branchId, salesInvoices, ct);
return new DashboardStatsDto(
lowStockAlerts, onHandTotal, onHandWarehouses, stockValuationTotal, stockValuationByWarehouse,
pendingApprovalPOs, pendingGrns, openRequisitions, pendingCounts, openRfqs,
pendingLeaveRequests, overdueSalesInvoices, monthlyPurchasesVsSales);
}
/// <summary>
/// Posted purchase vs sales invoice GrandTotals per calendar month (UTC) over the last
/// <see cref="ChartMonths"/> months, oldest first. Months with neither are omitted, so an
/// empty list means "no posted invoices yet". Purchases are dated by VendorInvoiceDate,
/// sales by InvoiceDate.
/// </summary>
private async Task<IReadOnlyList<MonthlyPurchasesVsSalesDto>> GetMonthlyPurchasesVsSalesAsync(
int? branchId, IQueryable<SalesInvoice> salesInvoices, CancellationToken ct)
{
var now = DateTime.UtcNow;
var since = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(-(ChartMonths - 1));
var purchaseInvoices = _purchaseInvoices.Query().AsNoTracking();
if (branchId is not null) purchaseInvoices = purchaseInvoices.Where(i => i.BranchId == branchId);
var purchases = await purchaseInvoices
.Where(i => i.Status == PurchaseInvoiceStatus.Posted && i.VendorInvoiceDate >= since)
.GroupBy(i => new { i.VendorInvoiceDate.Year, i.VendorInvoiceDate.Month })
.Select(g => new { g.Key.Year, g.Key.Month, Total = g.Sum(i => i.GrandTotal) })
.ToListAsync(ct);
var sales = await salesInvoices
.Where(i => i.Status == SalesInvoiceStatus.Posted && i.InvoiceDate >= since)
.GroupBy(i => new { i.InvoiceDate.Year, i.InvoiceDate.Month })
.Select(g => new { g.Key.Year, g.Key.Month, Total = g.Sum(i => i.GrandTotal) })
.ToListAsync(ct);
var purchasesByMonth = purchases.ToDictionary(p => (p.Year, p.Month), p => p.Total);
var salesByMonth = sales.ToDictionary(s => (s.Year, s.Month), s => s.Total);
return Enumerable.Range(0, ChartMonths)
.Select(offset => since.AddMonths(offset))
.Select(m => new MonthlyPurchasesVsSalesDto(
$"{m.Year:D4}-{m.Month:D2}",
purchasesByMonth.GetValueOrDefault((m.Year, m.Month)),
salesByMonth.GetValueOrDefault((m.Year, m.Month))))
.Where(m => m.Purchases != 0m || m.Sales != 0m)
.ToList();
pendingLeaveRequests, overdueSalesInvoices);
}
}
@@ -1,100 +0,0 @@
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);
}
+3 -16
View File
@@ -40,7 +40,6 @@ 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;
@@ -53,7 +52,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, IGrnConfigService grnConfig,
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig,
IUnitOfWork uow, IGeneralLedgerService gl, INotificationService notifications, IConfiguration configuration)
{
_grns = grns;
@@ -71,7 +70,6 @@ public sealed class GrnService : IGrnService
_numbers = numbers;
_currentUser = currentUser;
_branchConfig = branchConfig;
_grnConfig = grnConfig;
_uow = uow;
_gl = gl;
_notifications = notifications;
@@ -102,7 +100,7 @@ public sealed class GrnService : IGrnService
.Skip(query.Skip).Take(query.PageSize)
.Select(g => new GrnSummaryDto(
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.BranchId, g.Status,
g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count, g.Lines.Sum(l => l.LineTotal),
g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count,
g.PurchaseInvoiceId, g.PurchaseInvoice != null ? g.PurchaseInvoice.DocNo : null))
.ToListAsync(ct);
@@ -120,10 +118,6 @@ 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);
@@ -150,12 +144,6 @@ 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>();
@@ -199,8 +187,7 @@ 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.
// 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
var vatPct = 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);
@@ -285,18 +285,9 @@ public sealed class EmployeeService : IEmployeeService
}
catch (DbUpdateException)
{
// Every save a user makes writes an AuditLog row against their UserId, so almost any
// real user is still referenced by history and can't be hard-deleted. The login is
// still permanently removed: the row is kept only as a tombstone so historical
// "created by" references stay intact — credentials/contact scrubbed, role revoked,
// and hidden from Settings > Users. The AuthHex identity is purged below as usual.
// Update() flips the entity back from Deleted, or the next save would retry the delete.
_users.Update(user);
user.Username = User.DeletedUsernamePrefix + user.UserId;
user.Email = null;
user.RoleId = null;
user.Status = EntityStatus.Inactive;
await _uow.SaveChangesAsync(ct);
return new EmployeeDeleteResultDto(UserAccountDeleted: false, UserAccountDeactivated: true, AuthHexIdentityPurged: false);
}
// The local User row is gone, but AuthHex's own duplicate check (CanRegisterUser) has no
@@ -7,8 +7,7 @@ namespace ERPCore.Services.Interfaces;
public interface ICustomerService
{
/// <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<PagedResponse<CustomerDto>> ListAsync(PageQuery query, EntityStatus? status, CustomerType? customerType, 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);
@@ -24,16 +24,10 @@ public interface IFifoCostingService
/// stock. Throws <c>STOCK_NEGATIVE_BLOCKED</c> if issuable stock is insufficient,
/// <c>EXPIRED_BATCH_BLOCKED</c>/<c>ONHOLD_NOT_ISSUABLE</c> for an explicit batch
/// that is expired/held. Returns the consumed segments (for cost-preserving moves
/// and ledger costing). Must run inside a UoW transaction. Any shortfall layers left by an
/// earlier over-stock sale are settled against issuable stock first.
/// and ledger costing). Must run inside a UoW transaction.
/// </summary>
/// <param name="allowShortfall">
/// Sales only (Sales Configuration "Allow selling beyond stock"): instead of throwing, the
/// quantity stock can't cover is recorded as a negative layer at the last known unit cost.
/// </param>
Task<IReadOnlyList<ConsumedSegment>> ConsumeAsync(
int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default,
bool allowShortfall = false);
int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default);
/// <summary>
/// Append an immutable ledger entry. Value defaults to <c>round(qtyBase × unitCost, 4)</c>.
@@ -1,20 +0,0 @@
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);
}
@@ -1,16 +0,0 @@
using ERPCore.Dtos.Items;
namespace ERPCore.Services.Interfaces;
/// <summary>Item photos — upload gated by <c>ProductConfig.ItemImagesEnabled</c>; reads never gated.</summary>
public interface IItemImageService
{
Task<List<ItemImageDto>> ListAsync(int itemId, CancellationToken ct = default);
Task<ItemImageDto> UploadAsync(
int itemId, Stream content, string fileName, string contentType, int actorUserId, CancellationToken ct = default);
Task<(Stream Content, string ContentType)> OpenAsync(int itemId, int itemImageId, CancellationToken ct = default);
Task DeleteAsync(int itemId, int itemImageId, CancellationToken ct = default);
}
@@ -15,13 +15,6 @@ public interface IItemService
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
TrackingMode? trackingMode, CancellationToken ct = default);
/// <summary>Same filters as <see cref="ListAsync"/>, enriched with stock value, low-stock,
/// last-purchase and trailing-30-day sales aggregates for the Items list page.</summary>
Task<PagedResponse<ItemOverviewDto>> GetOverviewAsync(
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
TrackingMode? trackingMode, StockNature? stockNature, ItemStockFilter? stock,
CancellationToken ct = default);
Task<ETagged<ItemDetailDto>?> GetAsync(int itemId, CancellationToken ct = default);
Task<ETagged<ItemDetailDto>> CreateAsync(CreateItemRequest request, CancellationToken ct = default);
@@ -9,4 +9,5 @@ public interface IProductConfigService
Task<ETagged<ProductConfigDto>> GetAsync(CancellationToken ct = default);
Task<ETagged<ProductConfigDto>> UpdateAsync(
UpdateProductConfigRequest request, uint expectedRowVersion, CancellationToken ct = default);}
UpdateProductConfigRequest request, uint expectedRowVersion, CancellationToken ct = default);
}
@@ -1,14 +0,0 @@
using ERPCore.Common.Http;
using ERPCore.Dtos.Config;
namespace ERPCore.Services.Interfaces;
/// <summary>Sales configuration business logic. Singleton.</summary>
public interface ISalesConfigService
{
Task<ETagged<SalesConfigDto>> GetAsync(CancellationToken ct = default);
/// <summary>Switching <c>AllowCashCustomer</c> on creates the cash customer on first use.</summary>
Task<ETagged<SalesConfigDto>> UpdateAsync(
UpdateSalesConfigRequest request, uint expectedRowVersion, CancellationToken ct = default);
}
@@ -12,19 +12,6 @@ public interface ISalesDomainService
bool requireCashierUser,
CancellationToken ct = default);
/// <summary>Sales Configuration "Allow free items" — gates free qty on sales invoice lines.</summary>
Task<bool> AreFreeItemsAllowedAsync(CancellationToken ct = default);
/// <summary>Sales Configuration "Allow discounts" — gates line discounts on sales invoice lines.</summary>
Task<bool> AreDiscountsAllowedAsync(CancellationToken ct = default);
/// <summary>Sales Configuration "Allow selling beyond stock" — lets sales posting drive on-hand negative.</summary>
Task<bool> IsOverStockSaleAllowedAsync(CancellationToken ct = default);
/// <summary>Sales Configuration default sales warehouse: when one is saved, every sales invoice
/// must use it. Null only when none is saved and "Allow warehouse selection" is on.</summary>
Task<int?> GetForcedSalesWarehouseIdAsync(CancellationToken ct = default);
Task ValidateSalesLineAsync(
int headerWarehouseId,
int lineItemId,
@@ -6,7 +6,5 @@ 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);
}
@@ -7,7 +7,7 @@ namespace ERPCore.Services.Interfaces;
public interface ISalesInvoiceService
{
Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, CancellationToken ct = default);
Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, CancellationToken ct = default);
Task<ETagged<SalesInvoiceDto>?> GetAsync(int salesInvoiceId, CancellationToken ct = default);
Task<SalesInvoicePostingCheckDto> CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default);
Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default);
@@ -12,9 +12,6 @@ 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);
@@ -1,4 +1,3 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Uoms;
@@ -7,7 +6,6 @@ namespace ERPCore.Services.Interfaces;
/// <summary>UOM master business logic (docs/11-BACKEND-PHASE1.md §2.2).</summary>
public interface IUomService
{
Task<PagedResponse<UomDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
Task<PagedResponse<UomDto>> ListAsync(PageQuery query, CancellationToken ct = default);
Task<UomDto> CreateAsync(CreateUomRequest request, CancellationToken ct = default);
Task SetStatusAsync(int uomId, EntityStatus status, CancellationToken ct = default);
}
@@ -1,16 +0,0 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Items;
namespace ERPCore.Services.Interfaces;
/// <summary>Custom warranty period master. Never hard-deleted (FR-MD-08).</summary>
public interface IWarrantyPeriodService
{
Task<List<WarrantyPeriodDto>> ListAsync(EntityStatus? status, CancellationToken ct = default);
/// <summary>1–600 months, not a default length. Re-adding an inactive length reactivates it;
/// an active duplicate is a 409.</summary>
Task<WarrantyPeriodDto> CreateAsync(int months, CancellationToken ct = default);
Task SetStatusAsync(int warrantyPeriodId, EntityStatus status, CancellationToken ct = default);
}
@@ -1,131 +0,0 @@
using ERPCore.Domain.Entities;
using ERPCore.Dtos.Items;
using ERPCore.Infra.Storage;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Item photo service. Same upload rules as staff documents — extension allowlist, declared
/// content-type cross-check and a size cap, server-authoritative — narrowed to web image
/// formats, plus a per-item count cap. Uploads are refused with <c>CONFIG_DISABLED</c> while
/// <c>ProductConfig.ItemImagesEnabled</c> is off; listing, viewing and deleting stay open so
/// images added earlier remain manageable.
/// </summary>
public sealed class ItemImageService : IItemImageService
{
public const int MaxImagesPerItem = 10;
public const long MaxSizeBytes = 5 * 1024 * 1024;
private static readonly Dictionary<string, string> AllowedExtensionContentTypes = new(StringComparer.OrdinalIgnoreCase)
{
[".jpg"] = "image/jpeg",
[".jpeg"] = "image/jpeg",
[".png"] = "image/png",
[".webp"] = "image/webp"
};
private readonly IRepository<ItemImage> _images;
private readonly IRepository<Item> _items;
private readonly IProductConfigService _config;
private readonly IFileStorageService _storage;
private readonly IUnitOfWork _uow;
public ItemImageService(
IRepository<ItemImage> images, IRepository<Item> items, IProductConfigService config,
IFileStorageService storage, IUnitOfWork uow)
{
_images = images;
_items = items;
_config = config;
_storage = storage;
_uow = uow;
}
public async Task<List<ItemImageDto>> ListAsync(int itemId, CancellationToken ct = default)
{
await EnsureItemExistsAsync(itemId, ct);
return await _images.Query().AsNoTracking()
.Where(i => i.ItemId == itemId)
.OrderBy(i => i.SortOrder).ThenBy(i => i.ItemImageId)
.Select(i => new ItemImageDto(i.ItemImageId, i.ItemId, i.OriginalFileName, i.ContentType, i.SizeBytes, i.SortOrder, i.UploadedAt))
.ToListAsync(ct);
}
public async Task<ItemImageDto> UploadAsync(
int itemId, Stream content, string fileName, string contentType, int actorUserId, CancellationToken ct = default)
{
if (!(await _config.GetAsync(ct)).Value.ItemImagesEnabled)
throw new DomainException(ErrorCodes.ConfigDisabled,
"Item images are disabled in the product configuration.", 422);
await EnsureItemExistsAsync(itemId, ct);
var extension = Path.GetExtension(fileName);
if (!AllowedExtensionContentTypes.TryGetValue(extension, out var expectedContentType)
|| !string.Equals(expectedContentType, contentType, StringComparison.OrdinalIgnoreCase))
throw new DomainException(ErrorCodes.FileTypeNotAllowed,
$"File type '{extension}'/'{contentType}' is not allowed. Use JPG, PNG or WebP.", 422);
if (content.Length > MaxSizeBytes)
throw new DomainException(ErrorCodes.FileTooLarge,
$"Image exceeds the maximum allowed size of {MaxSizeBytes / (1024 * 1024)} MB.", 413);
var existing = await _images.Query().Where(i => i.ItemId == itemId).Select(i => i.SortOrder).ToListAsync(ct);
if (existing.Count >= MaxImagesPerItem)
throw new DomainException(ErrorCodes.Validation,
$"An item can have at most {MaxImagesPerItem} images.", 422);
var (storedFileName, relativePath, sizeBytes) = await _storage.SaveAsync(content, fileName, contentType, ct);
var image = new ItemImage
{
ItemId = itemId,
OriginalFileName = Path.GetFileName(fileName),
StoredFileName = storedFileName,
RelativePath = relativePath,
ContentType = expectedContentType,
SizeBytes = sizeBytes,
SortOrder = existing.Count == 0 ? 0 : existing.Max() + 1,
UploadedBy = actorUserId,
UploadedAt = DateTime.UtcNow
};
await _images.AddAsync(image, ct);
await _uow.SaveChangesAsync(ct);
return new ItemImageDto(image.ItemImageId, image.ItemId, image.OriginalFileName, image.ContentType,
image.SizeBytes, image.SortOrder, image.UploadedAt);
}
public async Task<(Stream Content, string ContentType)> OpenAsync(int itemId, int itemImageId, CancellationToken ct = default)
{
var image = await _images.Query().AsNoTracking()
.FirstOrDefaultAsync(i => i.ItemImageId == itemImageId && i.ItemId == itemId, ct)
?? throw new NotFoundException($"Image {itemImageId} was not found for item {itemId}.");
return (await _storage.OpenReadAsync(image.RelativePath, ct), image.ContentType);
}
public async Task DeleteAsync(int itemId, int itemImageId, CancellationToken ct = default)
{
var image = await _images.Query()
.FirstOrDefaultAsync(i => i.ItemImageId == itemImageId && i.ItemId == itemId, ct)
?? throw new NotFoundException($"Image {itemImageId} was not found for item {itemId}.");
_images.Remove(image);
await _uow.SaveChangesAsync(ct);
// File removed only after the row is gone, so a failed save never orphans a row.
await _storage.DeleteAsync(image.RelativePath, ct);
}
private async Task EnsureItemExistsAsync(int itemId, CancellationToken ct)
{
if (!await _items.Query().AnyAsync(i => i.ItemId == itemId, ct))
throw new NotFoundException($"Item {itemId} was not found.");
}
}
+8 -123
View File
@@ -1,5 +1,4 @@
using ERPCore.Common.Http;
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Config;
@@ -36,7 +35,6 @@ public sealed class ItemService : IItemService
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<StockLayer> _stockLayers;
private readonly IRepository<StockLedger> _stockLedger;
private readonly IRepository<GrnLine> _grnLines;
private readonly IRepository<Batch> _batches;
private readonly IRepository<Serial> _serials;
private readonly IProductConfigService _config;
@@ -54,7 +52,6 @@ public sealed class ItemService : IItemService
IRepository<Warehouse> warehouses,
IRepository<StockLayer> stockLayers,
IRepository<StockLedger> stockLedger,
IRepository<GrnLine> grnLines,
IRepository<Batch> batches,
IRepository<Serial> serials,
IProductConfigService config,
@@ -71,7 +68,6 @@ public sealed class ItemService : IItemService
_warehouses = warehouses;
_stockLayers = stockLayers;
_stockLedger = stockLedger;
_grnLines = grnLines;
_batches = batches;
_serials = serials;
_config = config;
@@ -112,101 +108,6 @@ public sealed class ItemService : IItemService
return PagedResponse<ItemListItemDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<PagedResponse<ItemOverviewDto>> GetOverviewAsync(
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
TrackingMode? trackingMode, StockNature? stockNature, ItemStockFilter? stock,
CancellationToken ct = default)
{
var q = _items.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(i => EF.Functions.ILike(i.Sku, $"%{term}%") || EF.Functions.ILike(i.Name, $"%{term}%"));
}
if (status is not null) q = q.Where(i => i.Status == status);
if (categoryId is not null) q = q.Where(i => i.CategoryId == categoryId);
if (subCategoryId is not null) q = q.Where(i => i.SubCategoryId == subCategoryId);
if (brandId is not null) q = q.Where(i => i.BrandId == brandId);
if (trackingMode is not null) q = q.Where(i => i.TrackingMode == trackingMode);
if (stockNature is not null) q = q.Where(i => i.StockNature == stockNature);
// Stock-state filters run as subqueries before paging, so totals and pages stay correct.
var layers = _stockLayers.Query();
switch (stock)
{
case ItemStockFilter.InStock:
q = q.Where(i => layers.Where(l => l.ItemId == i.ItemId).Sum(l => (decimal?)l.QtyRemaining) > 0);
break;
case ItemStockFilter.OutOfStock:
q = q.Where(i => (layers.Where(l => l.ItemId == i.ItemId).Sum(l => (decimal?)l.QtyRemaining) ?? 0) <= 0);
break;
case ItemStockFilter.LowStock:
q = q.Where(i => i.ReorderSettings.Any(rs =>
(layers.Where(l => l.ItemId == rs.ItemId && l.WarehouseId == rs.WarehouseId)
.Sum(l => (decimal?)l.QtyRemaining) ?? 0) <= rs.ReorderPoint));
break;
}
var total = await q.CountAsync(ct);
var page = await q.OrderBy(i => i.Sku)
.Skip(query.Skip).Take(query.PageSize)
.Select(i => new { i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId, i.Status, i.ReorderSettings })
.ToListAsync(ct);
var itemIds = page.Select(i => i.ItemId).ToList();
// Current FIFO stock value/qty — every open layer (negative = sold beyond stock), every warehouse.
var stockByItem = await _stockLayers.Query().AsNoTracking()
.Where(l => itemIds.Contains(l.ItemId) && l.QtyRemaining != 0)
.GroupBy(l => l.ItemId)
.Select(g => new { ItemId = g.Key, Qty = g.Sum(x => x.QtyRemaining), Value = g.Sum(x => x.QtyRemaining * x.UnitCost) })
.ToDictionaryAsync(x => x.ItemId, ct);
// On-hand per (item, warehouse), to compare against each item's reorder settings.
var onHandByItemWarehouse = (await _stockLayers.Query().AsNoTracking()
.Where(l => itemIds.Contains(l.ItemId) && l.QtyRemaining != 0)
.GroupBy(l => new { l.ItemId, l.WarehouseId })
.Select(g => new { g.Key.ItemId, g.Key.WarehouseId, Qty = g.Sum(x => x.QtyRemaining) })
.ToListAsync(ct))
.ToDictionary(x => (x.ItemId, x.WarehouseId), x => x.Qty);
// Last confirmed purchase (GRN) per item, with its vendor — pulled in bulk and
// reduced in memory rather than a "first per group" query EF can't always translate.
var grnCandidates = await _grnLines.Query().AsNoTracking()
.Where(l => itemIds.Contains(l.ItemId) && l.Grn!.PostedAt != null)
.Select(l => new { l.ItemId, PostedAt = l.Grn!.PostedAt!.Value, l.Grn.VendorId, VendorName = l.Grn.Vendor!.Name })
.ToListAsync(ct);
var lastPurchaseByItem = grnCandidates
.GroupBy(x => x.ItemId)
.ToDictionary(g => g.Key, g => g.OrderByDescending(x => x.PostedAt).First());
// Qty sold in the trailing 30 days, across every sale channel (invoice/slip/bundle),
// read off the stock ledger — the one place every sale channel posts uniformly.
var cutoff = DateTime.UtcNow.AddDays(-30);
var salesTypes = new[] { DocumentTypes.SalesInvoice, DocumentTypes.SalesSlip, DocumentTypes.BundleSale };
var salesByItem = await _stockLedger.Query().AsNoTracking()
.Where(l => itemIds.Contains(l.ItemId) && l.Direction == Direction.Out
&& l.CreatedAt >= cutoff && salesTypes.Contains(l.SourceDocType))
.GroupBy(l => l.ItemId)
.Select(g => new { ItemId = g.Key, Qty = g.Sum(x => x.QtyBase) })
.ToDictionaryAsync(x => x.ItemId, x => x.Qty, ct);
var rows = page.Select(i =>
{
var stock = stockByItem.GetValueOrDefault(i.ItemId);
var isLowStock = i.ReorderSettings.Any(rs =>
onHandByItemWarehouse.GetValueOrDefault((rs.ItemId, rs.WarehouseId)) <= rs.ReorderPoint);
var lastPurchase = lastPurchaseByItem.GetValueOrDefault(i.ItemId);
return new ItemOverviewDto(
i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId, i.Status,
stock?.Qty ?? 0m, stock?.Value ?? 0m, isLowStock,
lastPurchase?.PostedAt, lastPurchase?.VendorId, lastPurchase?.VendorName,
salesByItem.GetValueOrDefault(i.ItemId));
}).ToList();
return PagedResponse<ItemOverviewDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<ETagged<ItemDetailDto>?> GetAsync(int itemId, CancellationToken ct = default)
{
var item = await _items.Query().AsNoTracking()
@@ -234,7 +135,7 @@ public sealed class ItemService : IItemService
ItemContent.ValidatePair(request.ContentQty, request.ContentUnit);
var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(request.ContentQty, request.ContentUnit);
var warrantyPeriodMonths = ValidateWarrantyPeriod(request.Warranty, request.WarrantyPeriodMonths, config, null);
var warrantyPeriodMonths = ValidateWarrantyPeriod(request.Warranty, request.WarrantyPeriodMonths);
var item = new Item
{
@@ -311,8 +212,7 @@ public sealed class ItemService : IItemService
ItemContent.ValidatePair(request.ContentQty, request.ContentUnit);
var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(request.ContentQty, request.ContentUnit);
var warrantyPeriodMonths = ValidateWarrantyPeriod(
request.Warranty, request.WarrantyPeriodMonths, config, item.WarrantyPeriodMonths);
var warrantyPeriodMonths = ValidateWarrantyPeriod(request.Warranty, request.WarrantyPeriodMonths);
item.Sku = request.Sku.Trim();
item.Name = request.Name.Trim();
@@ -526,33 +426,18 @@ public sealed class ItemService : IItemService
}
/// <summary>
/// A warranty-tracked item must declare a coverage period. With custom periods off it must
/// be one of <see cref="WarrantyPeriods.AllowedMonths"/>; with them on, any length of
/// 1–<see cref="WarrantyPeriodService.MaxMonths"/> months — the Warranty Periods master is
/// just the shared presets, and an item may also carry a length of its own entered on the
/// item form (never added to the master). On update the item's <paramref name="currentMonths"/>
/// is also accepted, so switching the feature off never blocks editing an item that already
/// uses a custom length. A non-warranty item carries none — any value sent for one is
/// silently dropped rather than trusted from the client.
/// A warranty-tracked item must declare a coverage period (one of
/// <see cref="WarrantyPeriods.AllowedMonths"/>); a non-warranty item carries none —
/// any value sent for one is silently dropped rather than trusted from the client.
/// </summary>
private static int? ValidateWarrantyPeriod(
Warranty warranty, int? warrantyPeriodMonths, ProductConfigDto config, int? currentMonths)
private static int? ValidateWarrantyPeriod(Warranty warranty, int? warrantyPeriodMonths)
{
if (warranty != Warranty.Warranty) return null;
var valid = warrantyPeriodMonths is not null
&& (warrantyPeriodMonths == currentMonths
|| (config.CustomWarrantyPeriodsEnabled
? warrantyPeriodMonths is >= 1 and <= WarrantyPeriodService.MaxMonths
: WarrantyPeriods.AllowedMonths.Contains(warrantyPeriodMonths.Value)));
if (!valid)
if (warrantyPeriodMonths is null || !WarrantyPeriods.AllowedMonths.Contains(warrantyPeriodMonths.Value))
throw new DomainException(
ErrorCodes.Validation,
config.CustomWarrantyPeriodsEnabled
? $"Warranty period must be between 1 and {WarrantyPeriodService.MaxMonths} months."
: $"Warranty period must be one of {string.Join(", ", WarrantyPeriods.AllowedMonths)} months.",
422);
$"Warranty period must be one of {string.Join(", ", WarrantyPeriods.AllowedMonths)} months.", 422);
return warrantyPeriodMonths;
}
@@ -55,13 +55,6 @@ public sealed class ProductConfigService : IProductConfigService
config.SerialNumbersEnabled = request.SerialNumbersEnabled!.Value;
config.BatchListsEnabled = request.BatchListsEnabled!.Value;
config.WarrantyPeriodsEnabled = request.WarrantyPeriodsEnabled!.Value;
// Custom variants live in the item-variants section - switching product variants
// off switches this off too, so it can never be left on by itself.
config.CustomVariantsEnabled = config.ItemTypesEnabled && request.CustomVariantsEnabled!.Value;
config.ItemImagesEnabled = request.ItemImagesEnabled!.Value;
// Custom warranty periods depend on warranty periods - switching the parent off
// switches this off too, so it can never be left on by itself.
config.CustomWarrantyPeriodsEnabled = config.WarrantyPeriodsEnabled && request.CustomWarrantyPeriodsEnabled!.Value;
config.UpdatedAt = DateTime.UtcNow;
config.UpdatedBy = _currentUser.AuditUserId;
@@ -80,7 +73,5 @@ public sealed class ProductConfigService : IProductConfigService
private static ProductConfigDto Map(ProductConfig c) => new(
c.SubcategoriesEnabled, c.BrandsEnabled, c.ItemTypesEnabled,
c.SellingPricesEnabled, c.SerialNumbersEnabled, c.BatchListsEnabled, c.WarrantyPeriodsEnabled,
c.CustomVariantsEnabled, c.ItemImagesEnabled,
c.CustomWarrantyPeriodsEnabled,
c.UpdatedAt, c.UpdatedBy);
}
@@ -1,120 +0,0 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
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>
/// Sales configuration service over the singleton row seeded by DataSeeder. Also owns the
/// generic "Cash Customer" record — created the first time cash sales are allowed.
/// </summary>
public sealed class SalesConfigService : ISalesConfigService
{
private const string CashCustomerCode = "CUST-CASH";
private const string CashCustomerName = "Cash Customer";
private readonly IRepository<SalesConfig> _config;
private readonly IRepository<Customer> _customers;
private readonly IRepository<Warehouse> _warehouses;
private readonly IBranchConfigService _branchConfig;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
public SalesConfigService(
IRepository<SalesConfig> config, IRepository<Customer> customers, IRepository<Warehouse> warehouses,
IBranchConfigService branchConfig, ICurrentUser currentUser, IUnitOfWork uow)
{
_config = config;
_customers = customers;
_warehouses = warehouses;
_branchConfig = branchConfig;
_currentUser = currentUser;
_uow = uow;
}
public async Task<ETagged<SalesConfigDto>> GetAsync(CancellationToken ct = default)
{
var config = await _config.Query().AsNoTracking().Include(c => c.CashCustomer).Include(c => c.DefaultWarehouse)
.FirstOrDefaultAsync(c => c.ConfigId == SalesConfig.SingletonId, ct)
?? throw new NotFoundException("Sales configuration has not been seeded.");
return new ETagged<SalesConfigDto>(Map(config), config.RowVersion);
}
public async Task<ETagged<SalesConfigDto>> UpdateAsync(
UpdateSalesConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var config = await _config.GetByIdAsync(SalesConfig.SingletonId, ct)
?? throw new NotFoundException("Sales configuration has not been seeded.");
if (config.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
// Validated before anything is applied — ResolveCashCustomerIdAsync below saves, so a
// later throw would otherwise leave the row half-updated.
var allowWarehouseSelection = request.AllowWarehouseSelection!.Value;
if (!allowWarehouseSelection && request.DefaultWarehouseId is null)
throw new DomainException(ErrorCodes.Validation, "Pick a default sales 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.AllowCashCustomer =request.AllowCashCustomer!.Value;
if (config.AllowCashCustomer && config.CashCustomerId is null)
config.CashCustomerId = await ResolveCashCustomerIdAsync(ct);
config.AllowFreeItems = request.AllowFreeItems!.Value;
config.AllowDiscounts = request.AllowDiscounts!.Value;
config.AllowOverStockSale = request.AllowOverStockSale!.Value;
config.AllowWarehouseSelection = allowWarehouseSelection;
config.DefaultWarehouseId = request.DefaultWarehouseId;
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);
}
// Reuses an existing CUST-CASH row (e.g. one left behind by a config row reset) before
// creating a new one on the deployment's default branch.
private async Task<int> ResolveCashCustomerIdAsync(CancellationToken ct)
{
var existingId = await _customers.Query().AsNoTracking()
.Where(c => c.CustomerCode == CashCustomerCode).Select(c => c.CustomerId).FirstOrDefaultAsync(ct);
if (existingId != 0) return existingId;
var customer = new Customer
{
CustomerCode = CashCustomerCode,
CustomerType = CustomerType.WalkIn,
Name = CashCustomerName,
DisplayName = CashCustomerName,
CreditLimit = 0m,
CreditDays = 0,
BranchId = await _branchConfig.ResolveDefaultBranchIdAsync(ct),
Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow
};
await _customers.AddAsync(customer, ct);
await _uow.SaveChangesAsync(ct);
return customer.CustomerId;
}
private static SalesConfigDto Map(SalesConfig c) => new(
c.AllowCashCustomer, c.CashCustomerId, c.CashCustomer?.DisplayName ?? c.CashCustomer?.Name, c.AllowFreeItems, c.AllowDiscounts, c.AllowOverStockSale,
c.AllowWarehouseSelection, c.DefaultWarehouseId, c.DefaultWarehouse?.Name, c.UpdatedAt, c.UpdatedBy);
}
+1 -45
View File
@@ -15,7 +15,6 @@ public sealed class SalesDomainService : ISalesDomainService
private readonly IRepository<Item> _items;
private readonly IRepository<TaxRate> _taxRates;
private readonly ISalesPricingService _pricing;
private readonly IRepository<SalesConfig> _salesConfig;
public SalesDomainService(
IRepository<Customer> customers,
@@ -23,8 +22,7 @@ public sealed class SalesDomainService : ISalesDomainService
IRepository<User> users,
IRepository<Item> items,
IRepository<TaxRate> taxRates,
ISalesPricingService pricing,
IRepository<SalesConfig> salesConfig)
ISalesPricingService pricing)
{
_customers = customers;
_warehouses = warehouses;
@@ -32,20 +30,12 @@ public sealed class SalesDomainService : ISalesDomainService
_items = items;
_taxRates = taxRates;
_pricing = pricing;
_salesConfig = salesConfig;
}
public async Task ValidateSalesHeaderAsync(int customerId, int warehouseId, int? cashierUserId, bool requireCashierUser, CancellationToken ct = default)
{
if (!await _customers.Query().AnyAsync(x => x.CustomerId == customerId, ct))
throw new NotFoundException($"Customer {customerId} was not found.");
// The generic cash customer is only usable while Sales Configuration allows it.
var cashConfig = await _salesConfig.Query().AsNoTracking()
.Where(c => c.ConfigId == SalesConfig.SingletonId)
.Select(c => new { c.AllowCashCustomer, c.CashCustomerId })
.FirstOrDefaultAsync(ct);
if (cashConfig is { AllowCashCustomer: false } && cashConfig.CashCustomerId == customerId)
throw new DomainException(ErrorCodes.Validation, "Cash customer sales are turned off in Sales Configuration. Select a customer.", 422);
if (!await _warehouses.Query().AnyAsync(x => x.WarehouseId == warehouseId, ct))
throw new NotFoundException($"Warehouse {warehouseId} was not found.");
if (requireCashierUser)
@@ -57,40 +47,6 @@ public sealed class SalesDomainService : ISalesDomainService
}
}
// A missing config row means the option predates it — free items stay allowed.
public async Task<bool> AreFreeItemsAllowedAsync(CancellationToken ct = default) =>
await _salesConfig.Query().AsNoTracking()
.Where(c => c.ConfigId == SalesConfig.SingletonId)
.Select(c => (bool?)c.AllowFreeItems)
.FirstOrDefaultAsync(ct) ?? true;
// A missing config row means the option predates it — discounts stay allowed.
public async Task<bool> AreDiscountsAllowedAsync(CancellationToken ct = default) =>
await _salesConfig.Query().AsNoTracking()
.Where(c => c.ConfigId == SalesConfig.SingletonId)
.Select(c => (bool?)c.AllowDiscounts)
.FirstOrDefaultAsync(ct) ?? true;
public async Task<int?> GetForcedSalesWarehouseIdAsync(CancellationToken ct = default)
{
var config = await _salesConfig.Query().AsNoTracking()
.Where(c => c.ConfigId == SalesConfig.SingletonId)
.Select(c => new { c.AllowWarehouseSelection, c.DefaultWarehouseId })
.FirstOrDefaultAsync(ct);
if (config is null) return null;
// A saved default sales warehouse always wins — sales 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 Sales Configuration but no default sales warehouse is set.", 422);
}
public async Task<bool> IsOverStockSaleAllowedAsync(CancellationToken ct = default) =>
await _salesConfig.Query().AsNoTracking()
.Where(c => c.ConfigId == SalesConfig.SingletonId)
.Select(c => c.AllowOverStockSale)
.FirstOrDefaultAsync(ct);
public async Task ValidateSalesLineAsync(
int headerWarehouseId, int lineItemId, int lineWarehouseId, decimal qty, decimal freeQty, int? parentLineId, CancellationToken ct = default)
{
@@ -24,19 +24,17 @@ 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, IRepository<SalesConfig> salesConfig,
IGeneralLedgerService gl, ICurrentUser currentUser, IUnitOfWork uow, IConfiguration configuration)
IRepository<SalesInvoice> invoices, IRepository<SalesInvoicePayment> payments, IGeneralLedgerService gl,
ICurrentUser currentUser, IUnitOfWork uow, IConfiguration configuration)
{
_invoices = invoices;
_payments = payments;
_salesConfig = salesConfig;
_gl = gl;
_currentUser = currentUser;
_uow = uow;
@@ -60,18 +58,17 @@ 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(paymentDay, token);
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), token);
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{
PeriodId = period.PeriodId,
EntryDate = paymentDay,
EntryDate = DateOnly.FromDateTime(now),
SourceModule = "SALES_INVOICE_PAYMENT",
Reference = invoice.InvoiceNo,
Narration = $"Payment against invoice {invoice.InvoiceNo}",
@@ -86,7 +83,7 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
{
SalesInvoiceId = invoice.SalesInvoiceId,
Amount = request.Amount,
PaymentDate = paymentDate,
PaymentDate = now,
GlBankAccountId = account.AccountId,
BankAccountName = account.AccountName,
Reference = request.Reference,
@@ -105,134 +102,6 @@ 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))
@@ -51,7 +51,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
_uow = uow;
}
public async Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, CancellationToken ct = default)
public async Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, CancellationToken ct = default)
{
IQueryable<SalesInvoice> q = _invoices.Query().AsNoTracking().Include(x => x.Lines);
if (!string.IsNullOrWhiteSpace(query.Q))
@@ -64,9 +64,6 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
if (warehouseId is not null) q = q.Where(x => x.WarehouseId == warehouseId);
// Warehouses are shared across branches — the invoice carries its own owning BranchId.
if (branchId is not null) q = q.Where(x => x.BranchId == branchId);
// Whole days, inclusive of `to` — same InvoiceDate boundaries as SalesReportService.
if (from is not null) q = q.Where(x => x.InvoiceDate >= from.Value.ToDateTime(TimeOnly.MinValue));
if (to is not null) q = q.Where(x => x.InvoiceDate < to.Value.AddDays(1).ToDateTime(TimeOnly.MinValue));
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
@@ -86,7 +83,6 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
public async Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default)
{
request.WarehouseId = await ApplyForcedWarehouseAsync(request.WarehouseId, request.Lines, ct);
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, null, false, ct);
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
@@ -123,7 +119,6 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
{
var invoice = await _workflow.LoadEditableInvoiceAsync(salesInvoiceId, expectedRowVersion, ct);
request.WarehouseId = await ApplyForcedWarehouseAsync(request.WarehouseId, request.Lines, ct);
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, null, false, ct);
invoice.CustomerId = request.CustomerId;
invoice.WarehouseId = request.WarehouseId;
@@ -159,22 +154,9 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
return _mapping.MapInvoice(invoice);
}
// Sales Configuration "Allow warehouse selection" off: whatever the client sent, the header
// and every line use the configured default sales warehouse.
private async Task<int> ApplyForcedWarehouseAsync(int requestedWarehouseId, List<CreateSalesInvoiceLineRequest> lines, CancellationToken ct)
{
if (await _sales.GetForcedSalesWarehouseIdAsync(ct) is not int forced) return requestedWarehouseId;
foreach (var line in lines) line.WarehouseId = forced;
return forced;
}
private async Task<List<SalesInvoiceLine>> BuildLinesAsync(int headerWarehouseId, List<CreateSalesInvoiceLineRequest> requests, CancellationToken ct)
{
var lines = new List<SalesInvoiceLine>();
if (requests.Any(r => r.FreeQty > 0m || r.IsFreeIssue) && !await _sales.AreFreeItemsAllowedAsync(ct))
throw new DomainException(ErrorCodes.Validation, "Free items are turned off in Sales Configuration.", 422);
if (requests.Any(r => r.DiscountPct > 0m || r.DiscountAmount > 0m || r.DiscountValue > 0m) && !await _sales.AreDiscountsAllowedAsync(ct))
throw new DomainException(ErrorCodes.Validation, "Discounts are turned off in Sales Configuration.", 422);
foreach (var r in requests)
{
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
@@ -93,8 +93,7 @@ public sealed class SalesPostingService : ISalesPostingService
requestedQty, available, requestedQty - available, line.IsFreeIssue));
}
return new SalesInvoicePostingCheckDto(invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.Status,
issues.Count == 0 || await _sales.IsOverStockSaleAllowedAsync(ct), issues);
return new SalesInvoicePostingCheckDto(invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.Status, issues.Count == 0, issues);
}
public async Task<SalesSlipPostingCheckDto> CheckSlipAsync(int salesSlipId, CancellationToken ct = default)
@@ -125,8 +124,7 @@ public sealed class SalesPostingService : ISalesPostingService
requestedQty, available, requestedQty - available, line.IsFreeIssue));
}
return new SalesSlipPostingCheckDto(slip.SalesSlipId, slip.SlipNo, slip.Status,
issues.Count == 0 || await _sales.IsOverStockSaleAllowedAsync(ct), issues);
return new SalesSlipPostingCheckDto(slip.SalesSlipId, slip.SlipNo, slip.Status, issues.Count == 0, issues);
}
public async Task<BundleSalePostingCheckDto> CheckBundleAsync(int bundleSaleId, CancellationToken ct = default)
@@ -153,8 +151,7 @@ public sealed class SalesPostingService : ISalesPostingService
line.BundleSaleLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId, line.Qty, available, line.Qty - available));
}
return new BundleSalePostingCheckDto(bundle.BundleSaleId, bundle.BundleNo, bundle.Status,
issues.Count == 0 || await _sales.IsOverStockSaleAllowedAsync(ct), issues);
return new BundleSalePostingCheckDto(bundle.BundleSaleId, bundle.BundleNo, bundle.Status, issues.Count == 0, issues);
}
public Task PostInvoiceAsync(int salesInvoiceId, CancellationToken ct = default)
@@ -263,10 +260,6 @@ public sealed class SalesPostingService : ISalesPostingService
if (!string.Equals(statusValue, "Draft", StringComparison.Ordinal))
throw new ConflictException(ensureDraftMessage(doc));
// Sales Configuration "Allow selling beyond stock": a short line posts anyway and drives
// on-hand negative instead of failing with STOCK_NEGATIVE_BLOCKED.
var allowShortfall = await _sales.IsOverStockSaleAllowedAsync(ct);
await _uow.ExecuteInTransactionAsync(async token =>
{
var totalCogs = 0m;
@@ -278,7 +271,7 @@ public sealed class SalesPostingService : ISalesPostingService
// Line quantities are already a count of the item's base UOM — sales documents
// carry no unit of their own — so this is the quantity FIFO consumes verbatim.
var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, line.BatchId, line.Qty, token, allowShortfall);
var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, line.BatchId, line.Qty, token);
var cost = consumed.Count == 0 ? 0m : consumed.Sum(x => x.Qty * x.UnitCost) / consumed.Sum(x => x.Qty);
totalCogs += cost * line.Qty;
await _fifo.PostLedgerAsync(line.ItemId, line.WarehouseId, null, line.BatchId, line.SerialId, _currentUser.AuditUserId,
@@ -21,17 +21,8 @@ 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.");
@@ -63,11 +63,9 @@ public sealed class FifoCostingService : IFifoCostingService
}
public async Task<IReadOnlyList<ConsumedSegment>> ConsumeAsync(
int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default,
bool allowShortfall = false)
int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default)
{
var now = DateTime.UtcNow;
var today = DateOnly.FromDateTime(now);
var today = DateOnly.FromDateTime(DateTime.UtcNow);
if (batchId is not null)
{
@@ -78,43 +76,27 @@ public sealed class FifoCostingService : IFifoCostingService
$"Batch {batchId} expired on {expiry:yyyy-MM-dd} and cannot be issued.", 409);
}
// Row-lock the issuable open layers oldest-first (SELECT … FOR UPDATE, NFR-02), plus any
// shortfall (negative) layers left by an over-stock sale. Excludes on-hold and rejected
// (grn line) and expired-batch stock — rejected stock sits quarantined until a Purchase
// Return consumes it directly (see PurchaseReturnService), never through this
// general-issue path. No LINQ is composed on top of the raw SQL so the FOR UPDATE
// reaches the database intact.
var locked = await _db.StockLayers
// Row-lock the issuable open layers oldest-first (SELECT … FOR UPDATE, NFR-02).
// Excludes on-hold and rejected (grn line) and expired-batch stock — rejected stock
// sits quarantined until a Purchase Return consumes it directly (see
// PurchaseReturnService), never through this general-issue path. No LINQ is
// composed on top of the raw SQL so the FOR UPDATE reaches the database intact.
// {batchId}::bigint casts give Npgsql an explicit type for the (possibly null)
// parameter — without it a null batch filter fails with 42P18.
var layers = await _db.StockLayers
.FromSqlInterpolated($"""
SELECT * FROM stock_layers sl
WHERE sl."ItemId" = {itemId} AND sl."WarehouseId" = {warehouseId}
AND (sl."QtyRemaining" < 0 OR (sl."QtyRemaining" > 0
AND NOT EXISTS (SELECT 1 FROM grn_lines gl WHERE gl."GrnLineId" = sl."GrnLineId" AND gl."HoldStatus" IN ('OnHold', 'Rejected'))
AND NOT EXISTS (SELECT 1 FROM batches b WHERE b."BatchId" = sl."BatchId" AND b."ExpiryDate" < {today})))
WHERE sl."ItemId" = {itemId} AND sl."WarehouseId" = {warehouseId} AND sl."QtyRemaining" > 0
AND ({batchId}::bigint IS NULL OR sl."BatchId" = {batchId}::bigint)
AND NOT EXISTS (SELECT 1 FROM grn_lines gl WHERE gl."GrnLineId" = sl."GrnLineId" AND gl."HoldStatus" IN ('OnHold', 'Rejected'))
AND NOT EXISTS (SELECT 1 FROM batches b WHERE b."BatchId" = sl."BatchId" AND b."ExpiryDate" < {today})
ORDER BY sl."ReceiptDate", sl."LayerId"
FOR UPDATE
""")
.ToListAsync(ct);
// Stock sold before it arrived is owed first: net shortfall layers off against issuable
// stock (oldest-first) before anything new is issued, so it can't be sold twice. The
// sale already posted its ledger Out, so settling writes no ledger entry of its own.
var owed = locked.Where(l => l.QtyRemaining < 0).ToList();
var open = locked.Where(l => l.QtyRemaining > 0).ToList();
foreach (var shortfall in owed)
{
foreach (var layer in open)
{
if (shortfall.QtyRemaining >= 0) break;
var settle = Math.Min(-shortfall.QtyRemaining, layer.QtyRemaining);
layer.QtyRemaining -= settle;
shortfall.QtyRemaining += settle;
}
}
var layers = open.Where(l => l.QtyRemaining > 0 && (batchId == null || l.BatchId == batchId)).ToList();
var available = layers.Sum(l => l.QtyRemaining);
if (available < qtyBase && !allowShortfall)
if (available < qtyBase)
{
var heldExists = await _db.StockLayers.AsNoTracking().AnyAsync(l =>
l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining > 0
@@ -139,40 +121,9 @@ public sealed class FifoCostingService : IFifoCostingService
segments.Add(new ConsumedSegment(layer.LayerId, take, layer.UnitCost));
}
// Over-stock sale: whatever stock couldn't cover becomes a negative layer (on-hand goes
// below zero), costed at the item's last known unit cost — the next issue from this
// warehouse settles it against the stock received in the meantime (see above).
if (remaining > 0)
{
var unitCost = await LastKnownUnitCostAsync(itemId, warehouseId, ct);
var shortfall = new StockLayer
{
ItemId = itemId,
WarehouseId = warehouseId,
QtyReceived = 0m,
QtyRemaining = -remaining,
UnitCost = unitCost,
ReceiptDate = now
};
await _layers.AddAsync(shortfall, ct);
segments.Add(new ConsumedSegment(shortfall.LayerId, remaining, unitCost));
}
return segments;
}
// Most recent layer's cost at this warehouse, else anywhere, else 0 (never-stocked item).
private async Task<decimal> LastKnownUnitCostAsync(int itemId, int warehouseId, CancellationToken ct)
{
var layers = _layers.Query().AsNoTracking().Where(l => l.ItemId == itemId && l.QtyReceived > 0);
return await layers.Where(l => l.WarehouseId == warehouseId)
.OrderByDescending(l => l.ReceiptDate).ThenByDescending(l => l.LayerId)
.Select(l => (decimal?)l.UnitCost).FirstOrDefaultAsync(ct)
?? await layers.OrderByDescending(l => l.ReceiptDate).ThenByDescending(l => l.LayerId)
.Select(l => (decimal?)l.UnitCost).FirstOrDefaultAsync(ct)
?? 0m;
}
public async Task<StockLedger> PostLedgerAsync(
int itemId, int warehouseId, int? binId, int? batchId, int? serialId, int userId,
Direction direction, decimal qtyBase, decimal unitCost, decimal runningBalance,
@@ -244,8 +195,7 @@ public sealed class FifoCostingService : IFifoCostingService
public async Task<StockValuationDto> GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default)
{
var open = await _layers.Query().AsNoTracking()
// Negative (over-stock sale) layers included, so valuation qty always matches on-hand.
.Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining != 0)
.Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining > 0)
.OrderBy(l => l.ReceiptDate).ThenBy(l => l.LayerId)
.Select(l => new { l.LayerId, l.QtyRemaining, l.UnitCost, l.ReceiptDate })
.ToListAsync(ct);
@@ -132,18 +132,6 @@ 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)
+4 -16
View File
@@ -1,5 +1,4 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Uoms;
using ERPCore.Infra.UoW;
@@ -21,7 +20,7 @@ public sealed class UomService : IUomService
_uow = uow;
}
public async Task<PagedResponse<UomDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default)
public async Task<PagedResponse<UomDto>> ListAsync(PageQuery query, CancellationToken ct = default)
{
var q = _uoms.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
@@ -29,12 +28,11 @@ public sealed class UomService : IUomService
var term = query.Q.Trim();
q = q.Where(u => EF.Functions.ILike(u.Name, $"%{term}%"));
}
if (status is not null) q = q.Where(u => u.Status == status);
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(u => u.Name)
.Skip(query.Skip).Take(query.PageSize)
.Select(u => new UomDto(u.UomId, u.Name, u.Status, u.CreatedAt, u.UpdatedAt))
.Select(u => new UomDto(u.UomId, u.Name))
.ToListAsync(ct);
return PagedResponse<UomDto>.Create(rows, query.Page, query.PageSize, total);
@@ -46,20 +44,10 @@ public sealed class UomService : IUomService
if (await _uoms.Query().AnyAsync(u => u.Name == name, ct))
throw new ConflictException($"A UOM named '{name}' already exists.");
var uom = new Uom { Name = name, Status = EntityStatus.Active, CreatedAt = DateTime.UtcNow };
var uom = new Uom { Name = name };
await _uoms.AddAsync(uom, ct);
await _uow.SaveChangesAsync(ct);
return new UomDto(uom.UomId, uom.Name, uom.Status, uom.CreatedAt, uom.UpdatedAt);
}
public async Task SetStatusAsync(int uomId, EntityStatus status, CancellationToken ct = default)
{
var uom = await _uoms.GetByIdAsync(uomId, ct)
?? throw new NotFoundException($"UOM {uomId} was not found.");
uom.Status = status;
uom.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
return new UomDto(uom.UomId, uom.Name);
}
}
@@ -38,8 +38,7 @@ public sealed class UserManagementService : IUserManagementService
public async Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, EntityStatus? status, string? roleCode, CancellationToken ct = default)
{
IQueryable<User> q = _users.Query().AsNoTracking().Include(u => u.Role)
.Where(u => !u.Username.StartsWith(User.DeletedUsernamePrefix));
IQueryable<User> q = _users.Query().AsNoTracking().Include(u => u.Role);
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
@@ -1,78 +0,0 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Items;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Custom warranty period master — shared presets offered on every item's form on top of
/// <see cref="WarrantyPeriods.AllowedMonths"/>. Not an allow-list: with custom periods on, an
/// item may also carry a one-off length of its own. Nothing references these rows (items
/// store months), so deactivating is always safe and there is no in-use check.
/// </summary>
public sealed class WarrantyPeriodService : IWarrantyPeriodService
{
public const int MaxMonths = 600;
private readonly IRepository<WarrantyPeriod> _periods;
private readonly IUnitOfWork _uow;
public WarrantyPeriodService(IRepository<WarrantyPeriod> periods, IUnitOfWork uow)
{
_periods = periods;
_uow = uow;
}
public async Task<List<WarrantyPeriodDto>> ListAsync(EntityStatus? status, CancellationToken ct = default)
{
var q = _periods.Query().AsNoTracking();
if (status is not null) q = q.Where(p => p.Status == status);
return await q.OrderBy(p => p.Months)
.Select(p => new WarrantyPeriodDto(p.WarrantyPeriodId, p.Months, p.Status, p.CreatedAt, p.UpdatedAt))
.ToListAsync(ct);
}
public async Task<WarrantyPeriodDto> CreateAsync(int months, CancellationToken ct = default)
{
if (months < 1 || months > MaxMonths)
throw new DomainException(ErrorCodes.Validation,
$"A warranty period must be between 1 and {MaxMonths} months.", 422);
if (WarrantyPeriods.AllowedMonths.Contains(months))
throw new ConflictException($"{months} months is already a default warranty period.");
var existing = await _periods.Query().FirstOrDefaultAsync(p => p.Months == months, ct);
if (existing is not null)
{
if (existing.Status == EntityStatus.Active)
throw new ConflictException($"A {months}-month warranty period already exists.");
// Unique by length: re-adding a deactivated one brings it back rather than failing.
existing.Status = EntityStatus.Active;
existing.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
return Map(existing);
}
var period = new WarrantyPeriod { Months = months, Status = EntityStatus.Active, CreatedAt = DateTime.UtcNow };
await _periods.AddAsync(period, ct);
await _uow.SaveChangesAsync(ct);
return Map(period);
}
public async Task SetStatusAsync(int warrantyPeriodId, EntityStatus status, CancellationToken ct = default)
{
var period = await _periods.GetByIdAsync(warrantyPeriodId, ct)
?? throw new NotFoundException($"Warranty period {warrantyPeriodId} was not found.");
period.Status = status;
period.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
}
private static WarrantyPeriodDto Map(WarrantyPeriod p) => new(p.WarrantyPeriodId, p.Months, p.Status, p.CreatedAt, p.UpdatedAt);
}
+1 -1
View File
@@ -6,7 +6,7 @@
}
},
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5433;Database=ERPCoreTest;Username=postgres;Password=post@hexdive"
"DefaultConnection": "Host=127.0.0.1;Port=5433;Database=ERPCoreTest;Username=postgres;Password=post@hexdive"
},
"AuthHex": {
"BaseUrl": "http://localhost:5011"
+4 -1
View File
@@ -1,8 +1,11 @@
{
"ConnectionStrings": {
"DefaultConnection": ""
},
"Jwt": {
"SigningKey": ""
},
"AuthHex": {
"BaseUrl": "http://localhost:6703"
"BaseUrl": ""
}
}
+2 -3
View File
@@ -6,9 +6,8 @@
}
},
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCoreTest;Username=postgres;Password=post@hexdive"
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCore;Username=root;Password=root"
},
"Urls": "http://0.0.0.0:6701",
"Auth": {
"Issuer": "AuthHex",
"Audience": "AuthHexClient",
@@ -17,7 +16,7 @@
"RequiredRoleCode": ""
},
"AuthHex": {
"BaseUrl": "http://localhost:6703"
"BaseUrl": "http://localhost:5602"
},
"FileStorage": {
"RootPath": "App_Data/hr-documents",

Some files were not shown because too many files have changed in this diff Show More