diff --git a/Backend/ERPCore/Controllers/ItemImagesController.cs b/Backend/ERPCore/Controllers/ItemImagesController.cs
new file mode 100644
index 0000000..498831c
--- /dev/null
+++ b/Backend/ERPCore/Controllers/ItemImagesController.cs
@@ -0,0 +1,64 @@
+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;
+
+/// Item photos. Same access as the item master itself.
+[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), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task>> List(int itemId, CancellationToken ct)
+ => Ok(await _images.ListAsync(itemId, ct));
+
+ /// Upload one image (multipart file). 422 CONFIG_DISABLED while item images are off.
+ [HttpPost]
+ [RequestSizeLimit(ItemImageService.MaxSizeBytes + 64 * 1024)]
+ [ProducesResponseType(typeof(ItemImageDto), StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status413PayloadTooLarge)]
+ [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
+ public async Task> 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);
+ }
+
+ /// The image bytes, served inline so an <img> can point straight at it.
+ [HttpGet("{itemImageId:int}/content")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task 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 Delete(int itemId, int itemImageId, CancellationToken ct)
+ {
+ await _images.DeleteAsync(itemId, itemImageId, ct);
+ return NoContent();
+ }
+}
diff --git a/Backend/ERPCore/Controllers/ItemsController.cs b/Backend/ERPCore/Controllers/ItemsController.cs
index 0a17b3f..b420a19 100644
--- a/Backend/ERPCore/Controllers/ItemsController.cs
+++ b/Backend/ERPCore/Controllers/ItemsController.cs
@@ -40,8 +40,11 @@ public sealed class ItemsController : ApiControllerBase
[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, ct));
+ => Ok(await _items.GetOverviewAsync(query, status, categoryId, subCategoryId, brandId, trackingMode, stockNature, stock, ct));
/// Barcode/RFID lookup — matches an item's barcode/SKU, or a batch/serial
/// barcode/no (optionally scoped to first).
diff --git a/Backend/ERPCore/Controllers/ProductConfigController.cs b/Backend/ERPCore/Controllers/ProductConfigController.cs
index e20261a..7e77857 100644
--- a/Backend/ERPCore/Controllers/ProductConfigController.cs
+++ b/Backend/ERPCore/Controllers/ProductConfigController.cs
@@ -45,5 +45,4 @@ public sealed class ProductConfigController : ApiControllerBase
var result = await _config.UpdateAsync(request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
- }
-}
+ }}
diff --git a/Backend/ERPCore/Controllers/SalesConfigController.cs b/Backend/ERPCore/Controllers/SalesConfigController.cs
new file mode 100644
index 0000000..6c8ba3e
--- /dev/null
+++ b/Backend/ERPCore/Controllers/SalesConfigController.cs
@@ -0,0 +1,46 @@
+using Microsoft.AspNetCore.Authorization;
+using ERPCore.Infra.Auth;
+using ERPCore.Dtos.Config;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers;
+
+///
+/// Sales configuration endpoints — the singleton "allow cash customer" toggle.
+///
+/// Authorization: gated on the Sales nav policy (the New Sale form has to read it), same
+/// Phase-1 posture as — any Sales user can flip the flag
+/// until per-endpoint RBAC lands.
+///
+///
+[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> 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> Update(
+ [FromBody] UpdateSalesConfigRequest request, CancellationToken ct)
+ {
+ var expected = RequireIfMatch();
+ var result = await _config.UpdateAsync(request, expected, ct);
+ SetETag(result.RowVersion);
+ return Ok(result.Value);
+ }
+}
diff --git a/Backend/ERPCore/Controllers/SalesInvoicesController.cs b/Backend/ERPCore/Controllers/SalesInvoicesController.cs
index cbbe163..f0bbcfb 100644
--- a/Backend/ERPCore/Controllers/SalesInvoicesController.cs
+++ b/Backend/ERPCore/Controllers/SalesInvoicesController.cs
@@ -14,11 +14,60 @@ 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)
+ public SalesInvoicesController(
+ ISalesInvoiceService invoices, ISalesInvoicePaymentService payments, ISalesPricingService pricing,
+ ISalesDomainService sales, IStockService stock)
{
_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> 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));
+ }
+
+ // 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> 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]
diff --git a/Backend/ERPCore/Controllers/WarrantyPeriodsController.cs b/Backend/ERPCore/Controllers/WarrantyPeriodsController.cs
new file mode 100644
index 0000000..d68593b
--- /dev/null
+++ b/Backend/ERPCore/Controllers/WarrantyPeriodsController.cs
@@ -0,0 +1,44 @@
+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;
+
+/// Custom warranty period master — managed under Products like Item Variants.
+[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;
+
+ /// Custom periods only (defaults are fixed); filter status=Active for selectable rows.
+ [HttpGet]
+ [ProducesResponseType(typeof(List), StatusCodes.Status200OK)]
+ public async Task>> 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> Create([FromBody] CreateWarrantyPeriodRequest request, CancellationToken ct)
+ {
+ var result = await _periods.CreateAsync(request.Months!.Value, ct);
+ return Created($"/api/v1/warranty-periods/{result.WarrantyPeriodId}", result);
+ }
+
+ /// Deactivate/reactivate. Never hard-deleted (FR-MD-08).
+ [HttpPatch("{warrantyPeriodId:int}/status")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task SetStatus(int warrantyPeriodId, [FromBody] UpdateWarrantyPeriodStatusRequest request, CancellationToken ct)
+ {
+ await _periods.SetStatusAsync(warrantyPeriodId, request.Status, ct);
+ return NoContent();
+ }
+}
diff --git a/Backend/ERPCore/Domain/Entities/ItemImage.cs b/Backend/ERPCore/Domain/Entities/ItemImage.cs
new file mode 100644
index 0000000..01a7246
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/ItemImage.cs
@@ -0,0 +1,27 @@
+namespace ERPCore.Domain.Entities;
+
+///
+/// A product photo attached to an item. Like , the file lives
+/// behind under a server-generated name and is
+/// only reachable through the authenticated items API — never a static path. Only uploadable
+/// while is on; existing images stay readable
+/// after the flag is switched off (reads are never gated).
+///
+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; }
+
+ /// Display order; the lowest is the item's main image.
+ public int SortOrder { get; set; }
+
+ public int UploadedBy { get; set; }
+ public DateTime UploadedAt { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/ProductConfig.cs b/Backend/ERPCore/Domain/Entities/ProductConfig.cs
index ca55f16..65dab8a 100644
--- a/Backend/ERPCore/Domain/Entities/ProductConfig.cs
+++ b/Backend/ERPCore/Domain/Entities/ProductConfig.cs
@@ -31,6 +31,26 @@ public class ProductConfig
public bool BatchListsEnabled { get; set; } = true;
public bool WarrantyPeriodsEnabled { get; set; } = true;
+ ///
+ /// 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
+ /// - it gates a UI shortcut; the Item Variants master itself stays available either way.
+ ///
+ public bool CustomVariantsEnabled { get; set; }
+
+ ///
+ /// Allows photos to be uploaded to items (). Enforced on upload
+ /// (CONFIG_DISABLED); images added earlier stay viewable when switched off.
+ ///
+ public bool ItemImagesEnabled { get; set; }
+
+ ///
+ /// Lets items use the active master rows alongside the default
+ /// . Enforced on item writes. The periods
+ /// themselves are managed under Products, not here.
+ ///
+ public bool CustomWarrantyPeriodsEnabled { get; set; }
+
public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; }
diff --git a/Backend/ERPCore/Domain/Entities/SalesConfig.cs b/Backend/ERPCore/Domain/Entities/SalesConfig.cs
new file mode 100644
index 0000000..d7f3a58
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/SalesConfig.cs
@@ -0,0 +1,61 @@
+namespace ERPCore.Domain.Entities;
+
+///
+/// Sales configuration — a singleton row (single-tenant, docs/00-CORE §1), same shape as
+/// . When is on, sales documents may
+/// be raised against the generic "Cash Customer" () 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).
+///
+public class SalesConfig
+{
+ /// Always 1 - the singleton row's id.
+ public const int SingletonId = 1;
+
+ public int ConfigId { get; set; }
+
+ public bool AllowCashCustomer { get; set; }
+
+ /// The Customer row used for cash (walk-in, unnamed) sales. Created on first
+ /// enabling and kept afterwards, so switching the option
+ /// off and on again reuses the same customer (and its sales history).
+ public int? CashCustomerId { get; set; }
+ public Customer? CashCustomer { get; set; }
+
+ /// 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.
+ public bool AllowFreeItems { get; set; } = true;
+
+ /// 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.
+ public bool AllowDiscounts { get; set; } = true;
+
+ /// 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.
+ public bool AllowOverStockSale { get; set; }
+
+ /// When on (default) and no is saved, New Sale shows
+ /// a Warehouse picker. When off, a default must be saved.
+ public bool AllowWarehouseSelection { get; set; } = true;
+
+ /// 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
+ /// is off; clearing it brings the picker back.
+ 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; }
+
+ /// PostgreSQL xmin-backed optimistic concurrency token (ETag source).
+ public uint RowVersion { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/User.cs b/Backend/ERPCore/Domain/Entities/User.cs
index aaec55d..96b3abc 100644
--- a/Backend/ERPCore/Domain/Entities/User.cs
+++ b/Backend/ERPCore/Domain/Entities/User.cs
@@ -15,6 +15,12 @@ public class User
/// Seeded fallback actor for unauthenticated/system operations.
public const int SystemUserId = 1;
+ ///
+ /// 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.
+ ///
+ 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;
diff --git a/Backend/ERPCore/Domain/Entities/WarrantyPeriod.cs b/Backend/ERPCore/Domain/Entities/WarrantyPeriod.cs
new file mode 100644
index 0000000..c1563ae
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/WarrantyPeriod.cs
@@ -0,0 +1,23 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// Custom warranty period master — a coverage length offered to items alongside the fixed
+/// defaults in , while
+/// is on.
+///
+/// Always in months (a "2 year" entry is stored as 24). Like 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.
+///
+///
+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; }
+}
diff --git a/Backend/ERPCore/Dtos/Config/ProductConfigDtos.cs b/Backend/ERPCore/Dtos/Config/ProductConfigDtos.cs
index 67d98d1..2d2e8a1 100644
--- a/Backend/ERPCore/Dtos/Config/ProductConfigDtos.cs
+++ b/Backend/ERPCore/Dtos/Config/ProductConfigDtos.cs
@@ -12,6 +12,8 @@ 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);
///
@@ -32,4 +34,7 @@ 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; }
}
diff --git a/Backend/ERPCore/Dtos/Config/SalesConfigDtos.cs b/Backend/ERPCore/Dtos/Config/SalesConfigDtos.cs
new file mode 100644
index 0000000..bd13155
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Config/SalesConfigDtos.cs
@@ -0,0 +1,25 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace ERPCore.Dtos.Config;
+
+/// Sales configuration resource. CashCustomerId/CashCustomerName identify
+/// the generic cash customer (null until the option has been switched on at least once).
+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);
+
+///
+/// Full replacement of the flags. UpdatedBy is derived from the token, never posted.
+/// Nullable bool so an omitted flag is a 400 rather than silently binding to false
+/// (same reasoning as ).
+///
+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; }
+ /// Required when is false.
+ public int? DefaultWarehouseId { get; set; }
+}
diff --git a/Backend/ERPCore/Dtos/Items/ItemImageDtos.cs b/Backend/ERPCore/Dtos/Items/ItemImageDtos.cs
new file mode 100644
index 0000000..343e01b
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Items/ItemImageDtos.cs
@@ -0,0 +1,7 @@
+namespace ERPCore.Dtos.Items;
+
+/// An item photo's metadata. The bytes are served by
+/// GET /items/{itemId}/images/{itemImageId}/content.
+public sealed record ItemImageDto(
+ int ItemImageId, int ItemId, string FileName, string ContentType, long SizeBytes,
+ int SortOrder, DateTime UploadedAt);
diff --git a/Backend/ERPCore/Dtos/Items/ItemOverviewDtos.cs b/Backend/ERPCore/Dtos/Items/ItemOverviewDtos.cs
index a6fbb99..438ef01 100644
--- a/Backend/ERPCore/Dtos/Items/ItemOverviewDtos.cs
+++ b/Backend/ERPCore/Dtos/Items/ItemOverviewDtos.cs
@@ -16,3 +16,12 @@ public sealed record ItemOverviewDto(
decimal StockQty, decimal StockValue, bool IsLowStock,
DateTime? LastPurchaseDate, int? LastSupplierId, string? LastSupplierName,
decimal SalesQtyLast30Days);
+
+/// Stock-state filter for the Items overview. LowStock uses the same rule
+/// as : on-hand at or below any warehouse's reorder point.
+public enum ItemStockFilter
+{
+ InStock,
+ OutOfStock,
+ LowStock
+}
diff --git a/Backend/ERPCore/Dtos/Items/WarrantyPeriodDtos.cs b/Backend/ERPCore/Dtos/Items/WarrantyPeriodDtos.cs
new file mode 100644
index 0000000..59470b4
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Items/WarrantyPeriodDtos.cs
@@ -0,0 +1,17 @@
+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);
+
+/// A new custom length, always in months (the client converts years).
+public sealed class CreateWarrantyPeriodRequest
+{
+ [Required] public int? Months { get; set; }
+}
+
+public sealed class UpdateWarrantyPeriodStatusRequest
+{
+ [Required] public EntityStatus Status { get; set; }
+}
diff --git a/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs b/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs
index 9f3ebad..cf09947 100644
--- a/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs
+++ b/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs
@@ -44,6 +44,14 @@ public sealed record SalesInvoicePostingCheckDto(
int SalesInvoiceId, string InvoiceNo, SalesInvoiceStatus Status, bool CanPost,
IReadOnlyList Issues);
+/// 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).
+public sealed record SalesSuggestedPriceDto(int ItemId, int WarehouseId, decimal UnitPrice, string PriceSource);
+
+/// Issuable stock for one item at one warehouse (on-hand less on-hold), for New Sale's
+/// per-line shortage warning. IsStocked false = non-stock item, never limited.
+public sealed record SalesStockAvailabilityDto(int ItemId, int WarehouseId, bool IsStocked, decimal Available);
+
public sealed class CreateSalesInvoiceLineRequest
{
[Required] public int ItemId { get; set; }
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/ItemImageConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/ItemImageConfiguration.cs
new file mode 100644
index 0000000..b97235f
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/ItemImageConfiguration.cs
@@ -0,0 +1,25 @@
+using ERPCore.Domain.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace ERPCore.Infra.Persistence.Configurations;
+
+public sealed class ItemImageConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder 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 });
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/ProductConfigConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/ProductConfigConfiguration.cs
index 7580b94..e578af7 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/ProductConfigConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/ProductConfigConfiguration.cs
@@ -29,7 +29,9 @@ public sealed class ProductConfigConfiguration : IEntityTypeConfiguration 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();
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/SalesConfigConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/SalesConfigConfiguration.cs
new file mode 100644
index 0000000..86c9049
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/SalesConfigConfiguration.cs
@@ -0,0 +1,49 @@
+using ERPCore.Domain.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace ERPCore.Infra.Persistence.Configurations;
+
+///
+/// Configures the singleton sales-configuration row. The check constraint is what makes
+/// "singleton" a database guarantee rather than a convention (mirrors BranchConfig).
+///
+public sealed class SalesConfigConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder 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);
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/WarrantyPeriodConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/WarrantyPeriodConfiguration.cs
new file mode 100644
index 0000000..1316d3a
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/WarrantyPeriodConfiguration.cs
@@ -0,0 +1,27 @@
+using ERPCore.Domain.Entities;
+using ERPCore.Domain.Enums;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace ERPCore.Infra.Persistence.Configurations;
+
+/// Custom warranty period master. No relationships — items store months, not a reference.
+public sealed class WarrantyPeriodConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder 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().HasMaxLength(20).IsRequired()
+ .HasDefaultValue(EntityStatus.Active);
+
+ builder.Property(p => p.CreatedAt).IsRequired();
+ builder.HasIndex(p => p.Status);
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/DataSeeder.cs b/Backend/ERPCore/Infra/Persistence/DataSeeder.cs
index eea8688..634578f 100644
--- a/Backend/ERPCore/Infra/Persistence/DataSeeder.cs
+++ b/Backend/ERPCore/Infra/Persistence/DataSeeder.cs
@@ -46,6 +46,7 @@ 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 SeedNotificationConfigAsync(db, ct);
dirty |= await SeedSalesMastersAsync(db, ct);
dirty |= await SeedSalesStockAsync(db, ct);
@@ -106,7 +107,10 @@ public static class DataSeeder
SellingPricesEnabled = true,
SerialNumbersEnabled = true,
BatchListsEnabled = true,
- WarrantyPeriodsEnabled = true
+ WarrantyPeriodsEnabled = true,
+ CustomVariantsEnabled = false,
+ ItemImagesEnabled = false,
+ CustomWarrantyPeriodsEnabled = false
});
return true;
}
@@ -128,6 +132,18 @@ public static class DataSeeder
return true;
}
+ ///
+ /// 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.
+ ///
+ private static async Task 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;
+ }
+
private static async Task SeedNotificationConfigAsync(ErpDbContext db, CancellationToken ct)
{
if (await db.NotificationConfig.AnyAsync(c => c.NotificationConfigId == NotificationConfig.SingletonId, ct)) return false;
diff --git a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs
index 8f1c0df..6d00c7b 100644
--- a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs
+++ b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs
@@ -35,6 +35,8 @@ public class ErpDbContext : DbContext
public DbSet TaxRates => Set();
public DbSet- Items => Set
- ();
public DbSet ItemReorders => Set();
+ public DbSet ItemImages => Set();
+ public DbSet WarrantyPeriods => Set();
public DbSet Vendors => Set();
public DbSet Warehouses => Set();
public DbSet Bins => Set();
@@ -43,6 +45,7 @@ public class ErpDbContext : DbContext
public DbSet ProductConfig => Set();
/// Singleton row gating whether this deployment uses branches at all.
public DbSet BranchConfig => Set();
+ public DbSet SalesConfig => Set();
/// Singleton row for notification delivery preferences.
public DbSet NotificationConfig => Set();
public DbSet NotificationConfigModules => Set();
diff --git a/Backend/ERPCore/Migrations/20260929131905_AddSalesConfig.Designer.cs b/Backend/ERPCore/Migrations/20260929131905_AddSalesConfig.Designer.cs
new file mode 100644
index 0000000..a4f063f
--- /dev/null
+++ b/Backend/ERPCore/Migrations/20260929131905_AddSalesConfig.Designer.cs
@@ -0,0 +1,8799 @@
+//
+using System;
+using ERPCore.Infra.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace ERPCore.Migrations
+{
+ [DbContext(typeof(ErpDbContext))]
+ [Migration("20260929131905_AddSalesConfig")]
+ partial class AddSalesConfig
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.9")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.AttendanceRecord", b =>
+ {
+ b.Property("AttendanceRecordId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AttendanceRecordId"));
+
+ b.Property("AttendanceDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("AttendanceStatus")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("AttendanceUploadBatchId")
+ .HasColumnType("integer");
+
+ b.Property("CheckIn")
+ .HasColumnType("interval");
+
+ b.Property("CheckOut")
+ .HasColumnType("interval");
+
+ b.Property("DuplicateOfAttendanceRecordId")
+ .HasColumnType("integer");
+
+ b.Property("EarlyLeaveMinutes")
+ .HasColumnType("integer");
+
+ b.Property("EditedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EditedBy")
+ .HasColumnType("integer");
+
+ b.Property("EmployeeId")
+ .HasColumnType("integer");
+
+ b.Property("IsManualOverride")
+ .HasColumnType("boolean");
+
+ b.Property("LateMinutes")
+ .HasColumnType("integer");
+
+ b.Property("Notes")
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)");
+
+ b.Property("OvertimeMinutes")
+ .HasColumnType("integer");
+
+ b.Property("RowValidationStatus")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("WorkShiftId")
+ .HasColumnType("integer");
+
+ b.Property("WorkingMinutes")
+ .HasColumnType("integer");
+
+ b.HasKey("AttendanceRecordId");
+
+ b.HasIndex("AttendanceUploadBatchId");
+
+ b.HasIndex("WorkShiftId");
+
+ b.HasIndex("EmployeeId", "AttendanceDate");
+
+ b.ToTable("hr_attendance_records", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.AttendanceUploadBatch", b =>
+ {
+ b.Property("AttendanceUploadBatchId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AttendanceUploadBatchId"));
+
+ b.Property("ConfirmedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ConfirmedBy")
+ .HasColumnType("integer");
+
+ b.Property("DocNo")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("OriginalFileName")
+ .HasMaxLength(260)
+ .HasColumnType("character varying(260)");
+
+ b.Property("PeriodEnd")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("PeriodStart")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("RowCountDuplicate")
+ .HasColumnType("integer");
+
+ b.Property("RowCountError")
+ .HasColumnType("integer");
+
+ b.Property("RowCountTotal")
+ .HasColumnType("integer");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("SourceType")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("UploadedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UploadedBy")
+ .HasColumnType("integer");
+
+ b.HasKey("AttendanceUploadBatchId");
+
+ b.HasIndex("DocNo")
+ .IsUnique();
+
+ b.HasIndex("Status");
+
+ b.HasIndex("PeriodStart", "PeriodEnd");
+
+ b.ToTable("hr_attendance_upload_batches", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.AuditLog", b =>
+ {
+ b.Property("AuditId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AuditId"));
+
+ b.Property("Action")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("character varying(10)");
+
+ b.Property("ChangeSet")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EntityId")
+ .HasColumnType("integer");
+
+ b.Property("EntityType")
+ .IsRequired()
+ .HasMaxLength(80)
+ .HasColumnType("character varying(80)");
+
+ b.Property("UserId")
+ .HasColumnType("integer");
+
+ b.HasKey("AuditId");
+
+ b.HasIndex("CreatedAt");
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("EntityType", "EntityId");
+
+ b.ToTable("audit_logs", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.Batch", b =>
+ {
+ b.Property("BatchId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BatchId"));
+
+ b.Property("Barcode")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("BatchNo")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("ExpiryDate")
+ .HasColumnType("date");
+
+ b.Property("ItemId")
+ .HasColumnType("integer");
+
+ b.HasKey("BatchId");
+
+ b.HasIndex("Barcode")
+ .IsUnique();
+
+ b.HasIndex("ItemId", "BatchNo")
+ .IsUnique();
+
+ b.ToTable("batches", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b =>
+ {
+ b.Property("BinId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BinId"));
+
+ b.Property("BinType")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("WarehouseId")
+ .HasColumnType("integer");
+
+ b.HasKey("BinId");
+
+ b.HasIndex("WarehouseId", "Code")
+ .IsUnique();
+
+ b.ToTable("bins", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.Branch", b =>
+ {
+ b.Property("BranchId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BranchId"));
+
+ b.Property("Address")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("PhoneNumber")
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasDefaultValue("Active");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("BranchId");
+
+ b.HasIndex("Code")
+ .IsUnique();
+
+ b.HasIndex("Status");
+
+ b.ToTable("hr_branches", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.BranchConfig", b =>
+ {
+ b.Property("ConfigId")
+ .HasColumnType("integer");
+
+ b.Property("BranchingEnabled")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true);
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("integer");
+
+ b.Property("WarehouseBranchingEnabled")
+ .HasColumnType("boolean");
+
+ b.HasKey("ConfigId");
+
+ b.HasIndex("UpdatedBy");
+
+ b.ToTable("branch_config", null, t =>
+ {
+ t.HasCheckConstraint("ck_branch_config_singleton", "\"ConfigId\" = 1");
+ });
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.Brand", b =>
+ {
+ b.Property("BrandId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BrandId"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasDefaultValue("Active");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("BrandId");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.HasIndex("Status");
+
+ b.ToTable("brands", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.BundleSale", b =>
+ {
+ b.Property("BundleSaleId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BundleSaleId"));
+
+ b.Property("BundleCode")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("BundleDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("BundleName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("BundleNo")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("BundlePrice")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("BundleSaleTemplateId")
+ .HasColumnType("integer");
+
+ b.Property("CashierUserId")
+ .HasColumnType("integer");
+
+ b.Property("ComponentSubtotal")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(0);
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CustomerId")
+ .HasColumnType("integer");
+
+ b.Property("CustomerSnapshotName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("DayEndId")
+ .HasColumnType("integer");
+
+ b.Property("DiscountTotal")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("GrandTotal")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("MarginAmount")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasDefaultValue("Draft");
+
+ b.Property("TaxTotal")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("WarehouseId")
+ .HasColumnType("integer");
+
+ b.HasKey("BundleSaleId");
+
+ b.HasIndex("BundleNo")
+ .IsUnique();
+
+ b.HasIndex("BundleSaleTemplateId");
+
+ b.HasIndex("CashierUserId");
+
+ b.HasIndex("CustomerId");
+
+ b.HasIndex("DayEndId");
+
+ b.HasIndex("WarehouseId");
+
+ b.ToTable("bundle_sales", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleLine", b =>
+ {
+ b.Property("BundleSaleLineId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BundleSaleLineId"));
+
+ b.Property("BundleSaleId")
+ .HasColumnType("integer");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("IncludeInBundle")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true);
+
+ b.Property("IsComponent")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true);
+
+ b.Property("ItemId")
+ .HasColumnType("integer");
+
+ b.Property("LineTotal")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("ParentLineId")
+ .HasColumnType("integer");
+
+ b.Property("Qty")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("UnitPrice")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("WarehouseId")
+ .HasColumnType("integer");
+
+ b.HasKey("BundleSaleLineId");
+
+ b.HasIndex("BundleSaleId");
+
+ b.HasIndex("ItemId");
+
+ b.HasIndex("WarehouseId");
+
+ b.ToTable("bundle_sale_lines", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleTemplate", b =>
+ {
+ b.Property("BundleSaleTemplateId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BundleSaleTemplateId"));
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(0);
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasDefaultValue("Active");
+
+ b.Property("TemplateCode")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("TemplateName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("BundleSaleTemplateId");
+
+ b.HasIndex("TemplateCode")
+ .IsUnique();
+
+ b.ToTable("bundle_sale_templates", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleTemplateLine", b =>
+ {
+ b.Property("BundleSaleTemplateLineId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BundleSaleTemplateLineId"));
+
+ b.Property("BundleSaleTemplateId")
+ .HasColumnType("integer");
+
+ b.Property("IncludeInBundle")
+ .HasColumnType("boolean");
+
+ b.Property("ItemId")
+ .HasColumnType("integer");
+
+ b.Property("Qty")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("SortOrder")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(0);
+
+ b.Property("UnitPrice")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("WarehouseId")
+ .HasColumnType("integer");
+
+ b.HasKey("BundleSaleTemplateLineId");
+
+ b.HasIndex("BundleSaleTemplateId");
+
+ b.HasIndex("ItemId");
+
+ b.HasIndex("WarehouseId");
+
+ b.ToTable("bundle_sale_template_lines", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
+ {
+ b.Property("CategoryId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CategoryId"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasDefaultValue("Active");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("CategoryId");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.HasIndex("Status");
+
+ b.ToTable("categories", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.Customer", b =>
+ {
+ b.Property("CustomerId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CustomerId"));
+
+ b.Property("AddressLine1")
+ .HasMaxLength(250)
+ .HasColumnType("character varying(250)");
+
+ b.Property("AddressLine2")
+ .HasMaxLength(250)
+ .HasColumnType("character varying(250)");
+
+ b.Property("BranchId")
+ .HasColumnType("integer");
+
+ b.Property("City")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("Country")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreditDays")
+ .HasColumnType("integer");
+
+ b.Property("CreditLimit")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("CustomerCode")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("CustomerType")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasDefaultValue("B2C");
+
+ b.Property("DefaultWarehouseId")
+ .HasColumnType("integer");
+
+ b.Property("DisplayName")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("Email")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("Phone")
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasDefaultValue("Active");
+
+ b.Property("TaxRegistrationNo")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("CustomerId");
+
+ b.HasIndex("BranchId");
+
+ b.HasIndex("CustomerCode")
+ .IsUnique();
+
+ b.HasIndex("CustomerType");
+
+ b.HasIndex("DefaultWarehouseId");
+
+ b.HasIndex("Status");
+
+ b.ToTable("customers", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.Department", b =>
+ {
+ b.Property("DepartmentId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("DepartmentId"));
+
+ b.Property("BranchId")
+ .HasColumnType("integer");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("HeadEmployeeId")
+ .HasColumnType("integer");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("ParentDepartmentId")
+ .HasColumnType("integer");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasDefaultValue("Active");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("DepartmentId");
+
+ b.HasIndex("BranchId");
+
+ b.HasIndex("Code")
+ .IsUnique();
+
+ b.HasIndex("HeadEmployeeId");
+
+ b.HasIndex("ParentDepartmentId");
+
+ b.HasIndex("Status");
+
+ b.ToTable("hr_departments", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.Designation", b =>
+ {
+ b.Property("DesignationId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("DesignationId"));
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasDefaultValue("Active");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("DesignationId");
+
+ b.HasIndex("Code")
+ .IsUnique();
+
+ b.HasIndex("Status");
+
+ b.ToTable("hr_designations", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.Employee", b =>
+ {
+ b.Property("EmployeeId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("EmployeeId"));
+
+ b.Property("AddressLine1")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("AddressLine2")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("BranchId")
+ .HasColumnType("integer");
+
+ b.Property("City")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("ConfirmationDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Country")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatedBy")
+ .HasColumnType("integer");
+
+ b.Property("DateOfBirth")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DepartmentId")
+ .HasColumnType("integer");
+
+ b.Property("DesignationId")
+ .HasColumnType("integer");
+
+ b.Property("Email")
+ .HasMaxLength(320)
+ .HasColumnType("character varying(320)");
+
+ b.Property("EmergencyContactName")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("EmergencyContactPhone")
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("EmergencyContactRelationship")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("EmployeeCode")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("EmploymentTypeId")
+ .HasColumnType("integer");
+
+ b.Property("EpfNumber")
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("EtfNumber")
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("FullName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("Gender")
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("HireDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastWorkingDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Nationality")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property