diff --git a/Backend/ERPCore/Controllers/OwnerReportController.cs b/Backend/ERPCore/Controllers/OwnerReportController.cs
new file mode 100644
index 0000000..efb3a6f
--- /dev/null
+++ b/Backend/ERPCore/Controllers/OwnerReportController.cs
@@ -0,0 +1,77 @@
+using ERPCore.Dtos.Common;
+using ERPCore.Dtos.Config;
+using ERPCore.Infra.Auth;
+using ERPCore.Services.OwnerReport;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers;
+
+///
+/// Day-end owner report settings (docs/DAY_END_OWNER_REPORT_PLAN.md §3.5) — recipients, schedule,
+/// preview, "send test now" and the send log. Gated on the Settings nav policy like the other
+/// configuration screens (BranchConfigController, GlAccountConfigController).
+///
+[Route("api/v1/settings/owner-report")]
+[Authorize(Policy = NavPolicies.Prefix + "settings")]
+public sealed class OwnerReportController : ApiControllerBase
+{
+ // One test send per minute (whole deployment) so the button can't be used to spam the owner.
+ private static readonly SemaphoreSlim TestGate = new(1, 1);
+ private static DateTime _lastTestUtc = DateTime.MinValue;
+
+ private readonly IOwnerReportService _service;
+
+ public OwnerReportController(IOwnerReportService service) => _service = service;
+
+ [HttpGet]
+ [ProducesResponseType(typeof(OwnerReportConfigDto), StatusCodes.Status200OK)]
+ public async Task> Get(CancellationToken ct)
+ {
+ var result = await _service.GetAsync(ct);
+ SetETag(result.RowVersion);
+ return Ok(result.Value);
+ }
+
+ [HttpPut]
+ [ProducesResponseType(typeof(OwnerReportConfigDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
+ [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
+ public async Task> Update([FromBody] UpdateOwnerReportConfigRequest request, CancellationToken ct)
+ {
+ var result = await _service.UpdateAsync(request, RequireIfMatch(), ct);
+ SetETag(result.RowVersion);
+ return Ok(result.Value);
+ }
+
+ [HttpGet("preview")]
+ [ProducesResponseType(typeof(OwnerReportPreviewDto), StatusCodes.Status200OK)]
+ public async Task> Preview([FromQuery] DateOnly? date, CancellationToken ct)
+ => Ok(await _service.PreviewAsync(date, ct));
+
+ [HttpPost("test")]
+ [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status429TooManyRequests)]
+ public async Task>> SendTest([FromBody] OwnerReportTestRequest? request, CancellationToken ct)
+ {
+ await TestGate.WaitAsync(ct);
+ try
+ {
+ if (DateTime.UtcNow - _lastTestUtc < TimeSpan.FromMinutes(1))
+ return StatusCode(StatusCodes.Status429TooManyRequests,
+ new { title = "A test was sent less than a minute ago — wait a moment before sending another." });
+ _lastTestUtc = DateTime.UtcNow;
+ }
+ finally
+ {
+ TestGate.Release();
+ }
+
+ return Ok(await _service.SendTestAsync(request?.Date, ct));
+ }
+
+ [HttpGet("dispatches")]
+ [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
+ public async Task>> Dispatches([FromQuery] PageQuery query, CancellationToken ct)
+ => Ok(await _service.ListDispatchesAsync(query, ct));
+}
diff --git a/Backend/ERPCore/Controllers/SalesInvoicesController.cs b/Backend/ERPCore/Controllers/SalesInvoicesController.cs
index b462a60..5e66636 100644
--- a/Backend/ERPCore/Controllers/SalesInvoicesController.cs
+++ b/Backend/ERPCore/Controllers/SalesInvoicesController.cs
@@ -92,8 +92,9 @@ public sealed class SalesInvoicesController : ApiControllerBase
[FromQuery] DateOnly? to,
[FromQuery] int? createdBy,
[FromQuery] bool? paid,
+ [FromQuery] bool? overdue,
CancellationToken ct)
- => Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, from, to, createdBy, paid, ct));
+ => Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, from, to, createdBy, paid, overdue, ct));
// Totals for the list's stat cards: same filters as List, but over every match rather than one page.
[HttpGet("totals")]
@@ -108,8 +109,9 @@ public sealed class SalesInvoicesController : ApiControllerBase
[FromQuery] DateOnly? to,
[FromQuery] int? createdBy,
[FromQuery] bool? paid,
+ [FromQuery] bool? overdue,
CancellationToken ct)
- => Ok(await _invoices.TotalsAsync(q, status, customerId, warehouseId, branchId, from, to, createdBy, paid, ct));
+ => Ok(await _invoices.TotalsAsync(q, status, customerId, warehouseId, branchId, from, to, createdBy, paid, overdue, ct));
[HttpGet("{salesInvoiceId:int}")]
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
diff --git a/Backend/ERPCore/Domain/Entities/BundleSaleTemplate.cs b/Backend/ERPCore/Domain/Entities/BundleSaleTemplate.cs
index 974e272..faa44e9 100644
--- a/Backend/ERPCore/Domain/Entities/BundleSaleTemplate.cs
+++ b/Backend/ERPCore/Domain/Entities/BundleSaleTemplate.cs
@@ -8,6 +8,12 @@ public class BundleSaleTemplate
public string TemplateCode { get; set; } = string.Empty;
public string TemplateName { get; set; } = string.Empty;
public string? Description { get; set; }
+ ///
+ /// What the whole bundle sells for. On save it is split across the included lines' unit prices
+ /// (see BundleSaleService.AllocateBundlePrice), so selling the template still yields per-component prices.
+ /// 0 on templates created before bundle prices existed — their line prices stand as entered.
+ ///
+ public decimal BundlePrice { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
diff --git a/Backend/ERPCore/Domain/Entities/OwnerReport.cs b/Backend/ERPCore/Domain/Entities/OwnerReport.cs
new file mode 100644
index 0000000..166d178
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/OwnerReport.cs
@@ -0,0 +1,79 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// Day-end owner report settings — a singleton row, same shape as
+/// (docs/DAY_END_OWNER_REPORT_PLAN.md §3.1). When , OwnerReportJob sends the
+/// day's figures to every active at .
+///
+public class OwnerReportConfig
+{
+ /// Always 1 - the singleton row's id.
+ public const int SingletonId = 1;
+
+ public int ConfigId { get; set; }
+
+ /// Master switch — nothing is sent automatically while off ("Send test now" still works).
+ public bool Enabled { get; set; }
+
+ public bool SmsEnabled { get; set; } = true;
+ public bool EmailEnabled { get; set; } = true;
+
+ /// Local time of day (DayEndReport:TimeZone) after which the day's report goes out.
+ public TimeOnly SendAtLocalTime { get; set; } = new(21, 0);
+
+ /// Adds one summary row per active branch when branching is switched on.
+ public bool IncludeBranchBreakdown { get; set; } = true;
+
+ public DateTime? UpdatedAt { get; set; }
+ public int? UpdatedBy { get; set; }
+ public User? UpdatedByUser { get; set; }
+
+ public uint RowVersion { get; set; }
+}
+
+/// Someone who receives the day-end report — by SMS, email or both.
+public class OwnerReportRecipient
+{
+ public int RecipientId { get; set; }
+ public string Name { get; set; } = string.Empty;
+
+ /// Normalised to +94XXXXXXXXX (Sri Lankan rule, see PhoneRules).
+ public string? Mobile { get; set; }
+ public string? Email { get; set; }
+ public bool IsActive { get; set; } = true;
+}
+
+///
+/// One send of one day's report on one channel to one recipient — the send log, and the guard
+/// against sending twice: (BusinessDate, Channel, RecipientId, IsTest) is unique for automatic
+/// sends, so a restart or a second server instance can't send the same day again.
+///
+public class OwnerReportDispatch
+{
+ public long DispatchId { get; set; }
+ public DateOnly BusinessDate { get; set; }
+ public OwnerReportChannel Channel { get; set; }
+
+ public int? RecipientId { get; set; }
+ public OwnerReportRecipient? Recipient { get; set; }
+
+ /// Number / email address at send time — kept even if the recipient is edited later.
+ public string Destination { get; set; } = string.Empty;
+ public string RecipientName { get; set; } = string.Empty;
+
+ public OwnerReportDispatchStatus Status { get; set; }
+ public int Attempts { get; set; }
+ public string? Error { get; set; }
+
+ /// Test sends ("Send test now") — never block or count as the day's automatic send.
+ public bool IsTest { get; set; }
+
+ /// For tests: a unique token so repeated tests don't collide on the unique index.
+ public string DedupeKey { get; set; } = string.Empty;
+
+ public DateTime CreatedAt { get; set; }
+ public DateTime? SentAt { get; set; }
+ public DateTime? NextAttemptAt { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/ProductConfig.cs b/Backend/ERPCore/Domain/Entities/ProductConfig.cs
index 65dab8a..1588fef 100644
--- a/Backend/ERPCore/Domain/Entities/ProductConfig.cs
+++ b/Backend/ERPCore/Domain/Entities/ProductConfig.cs
@@ -51,6 +51,31 @@ public class ProductConfig
///
public bool CustomWarrantyPeriodsEnabled { get; set; }
+ ///
+ /// Shows the stock-nature choice (Stocked / Non-stocked / Service) on item screens. Off:
+ /// new items are Stocked. Enforced on item writes - an item keeps the nature it already
+ /// has, only a change to a non-Stocked nature is refused.
+ ///
+ public bool StockNatureEnabled { get; set; } = true;
+
+ ///
+ /// Shows the tracking-mode choice on item screens. Off: new items are untracked (None).
+ /// Enforced on item writes like - existing modes are kept.
+ ///
+ public bool TrackingModeEnabled { get; set; } = true;
+
+ ///
+ /// Shows the low-stock alert (reorder point / qty) section on item create and the item page.
+ /// Advisory: it gates the item screens; reorder rules already saved keep working.
+ ///
+ public bool LowStockAlertsEnabled { get; set; } = true;
+
+ ///
+ /// Lets the low-stock alert be pinned to one warehouse. Off: the alert watches every
+ /// warehouse. Depends on ; advisory like it.
+ ///
+ public bool AlertWarehouseEnabled { get; set; } = true;
+
public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; }
diff --git a/Backend/ERPCore/Domain/Enums/OwnerReportEnums.cs b/Backend/ERPCore/Domain/Enums/OwnerReportEnums.cs
new file mode 100644
index 0000000..925fcc9
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/OwnerReportEnums.cs
@@ -0,0 +1,16 @@
+namespace ERPCore.Domain.Enums;
+
+/// How the day-end owner report is delivered (docs/DAY_END_OWNER_REPORT_PLAN.md).
+public enum OwnerReportChannel
+{
+ Sms,
+ Email,
+}
+
+/// State of one owner-report send.
+public enum OwnerReportDispatchStatus
+{
+ Pending,
+ Sent,
+ Failed,
+}
diff --git a/Backend/ERPCore/Dtos/Config/OwnerReportDtos.cs b/Backend/ERPCore/Dtos/Config/OwnerReportDtos.cs
new file mode 100644
index 0000000..75b39e6
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Config/OwnerReportDtos.cs
@@ -0,0 +1,52 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace ERPCore.Dtos.Config;
+
+/// Day-end owner report settings (docs/DAY_END_OWNER_REPORT_PLAN.md §3.5).
+/// "HH:mm" in .
+public sealed record OwnerReportConfigDto(
+ bool Enabled, bool SmsEnabled, bool EmailEnabled, string SendAtLocalTime, bool IncludeBranchBreakdown,
+ IReadOnlyList Recipients, string TimeZone, DateTime? UpdatedAt, int? UpdatedBy);
+
+public sealed record OwnerReportRecipientDto(int RecipientId, string Name, string? Mobile, string? Email, bool IsActive);
+
+public sealed class UpdateOwnerReportConfigRequest
+{
+ [Required] public bool? Enabled { get; set; }
+ [Required] public bool? SmsEnabled { get; set; }
+ [Required] public bool? EmailEnabled { get; set; }
+ [Required] public bool? IncludeBranchBreakdown { get; set; }
+
+ /// "HH:mm", 24-hour.
+ [Required, RegularExpression(@"^([01]\d|2[0-3]):[0-5]\d$", ErrorMessage = "Send time must be HH:mm (24-hour).")]
+ public string SendAtLocalTime { get; set; } = "21:00";
+
+ /// The full list — recipients not in it are removed. At most 5.
+ [Required, MaxLength(5, ErrorMessage = "At most 5 recipients.")]
+ public List Recipients { get; set; } = new();
+}
+
+public sealed class OwnerReportRecipientInput
+{
+ /// Existing recipient to update; null adds a new one.
+ public int? RecipientId { get; set; }
+ [Required, StringLength(100, MinimumLength = 1)] public string Name { get; set; } = string.Empty;
+ [StringLength(20)] public string? Mobile { get; set; }
+ [StringLength(200), EmailAddress] public string? Email { get; set; }
+ public bool IsActive { get; set; } = true;
+}
+
+/// The report as it would be sent for .
+public sealed record OwnerReportPreviewDto(DateOnly Date, string SmsText, string EmailSubject, string EmailHtml);
+
+public sealed class OwnerReportTestRequest
+{
+ /// Day to report; null = today in the report's time zone.
+ public DateOnly? Date { get; set; }
+}
+
+public sealed record OwnerReportSendResultDto(string Channel, string RecipientName, string Destination, bool Ok, string? Error);
+
+public sealed record OwnerReportDispatchDto(
+ long DispatchId, DateOnly BusinessDate, string Channel, string RecipientName, string Destination,
+ string Status, int Attempts, string? Error, bool IsTest, DateTime CreatedAt, DateTime? SentAt);
diff --git a/Backend/ERPCore/Dtos/Config/ProductConfigDtos.cs b/Backend/ERPCore/Dtos/Config/ProductConfigDtos.cs
index 2d2e8a1..498efe3 100644
--- a/Backend/ERPCore/Dtos/Config/ProductConfigDtos.cs
+++ b/Backend/ERPCore/Dtos/Config/ProductConfigDtos.cs
@@ -14,6 +14,7 @@ public sealed record ProductConfigDto(
bool SellingPricesEnabled, bool SerialNumbersEnabled, bool BatchListsEnabled, bool WarrantyPeriodsEnabled,
bool CustomVariantsEnabled, bool ItemImagesEnabled,
bool CustomWarrantyPeriodsEnabled,
+ bool StockNatureEnabled, bool TrackingModeEnabled, bool LowStockAlertsEnabled, bool AlertWarehouseEnabled,
DateTime? UpdatedAt, int? UpdatedBy);
///
@@ -37,4 +38,8 @@ public sealed class UpdateProductConfigRequest
[Required] public bool? CustomVariantsEnabled { get; set; }
[Required] public bool? ItemImagesEnabled { get; set; }
[Required] public bool? CustomWarrantyPeriodsEnabled { get; set; }
+ [Required] public bool? StockNatureEnabled { get; set; }
+ [Required] public bool? TrackingModeEnabled { get; set; }
+ [Required] public bool? LowStockAlertsEnabled { get; set; }
+ [Required] public bool? AlertWarehouseEnabled { get; set; }
}
diff --git a/Backend/ERPCore/Dtos/Sales/BundleSaleDtos.cs b/Backend/ERPCore/Dtos/Sales/BundleSaleDtos.cs
index 80a6e19..0b7097b 100644
--- a/Backend/ERPCore/Dtos/Sales/BundleSaleDtos.cs
+++ b/Backend/ERPCore/Dtos/Sales/BundleSaleDtos.cs
@@ -24,11 +24,11 @@ public sealed record BundleSaleTemplateLineDto(
decimal UnitPrice, bool IncludeInBundle, int SortOrder);
public sealed record BundleSaleTemplateDto(
- int BundleSaleTemplateId, string TemplateCode, string TemplateName, string? Description,
+ int BundleSaleTemplateId, string TemplateCode, string TemplateName, string? Description, decimal BundlePrice,
EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt, IReadOnlyList Lines);
public sealed record BundleSaleTemplateSummaryDto(
- int BundleSaleTemplateId, string TemplateCode, string TemplateName, string? Description,
+ int BundleSaleTemplateId, string TemplateCode, string TemplateName, string? Description, decimal BundlePrice,
EntityStatus Status, int LineCount, DateTime CreatedAt, DateTime? UpdatedAt);
public sealed record BundleSalePostingIssueDto(
@@ -53,6 +53,12 @@ public sealed class CreateBundleSaleTemplateRequest
{
[Required] public string TemplateName { get; set; } = string.Empty;
public string? Description { get; set; }
+ ///
+ /// The whole bundle's selling price. When set, it replaces the lines' UnitPrice: it is split across the
+ /// included lines in proportion to their UnitPrice × Qty (sent as a weight, e.g. the item's normal price),
+ /// or by Qty when no weights are given. Omit to keep the per-line prices as sent.
+ ///
+ [Range(0, double.MaxValue)] public decimal? BundlePrice { get; set; }
[Required, MinLength(1)] public List Lines { get; set; } = new();
}
diff --git a/Backend/ERPCore/Dtos/Warehouses/WarehouseDtos.cs b/Backend/ERPCore/Dtos/Warehouses/WarehouseDtos.cs
index b22d624..f9e37e0 100644
--- a/Backend/ERPCore/Dtos/Warehouses/WarehouseDtos.cs
+++ b/Backend/ERPCore/Dtos/Warehouses/WarehouseDtos.cs
@@ -11,11 +11,11 @@ public sealed record WarehouseDto(int WarehouseId, string Code, string Name, str
public sealed record BinDto(int BinId, int WarehouseId, string Code, string? BinType);
/// Sri Lankan phone rule for master data — mirrors the frontend's `lib/validations/phone.ts`:
-/// 10 digits starting with 0, or +94 followed by 9 digits; spaces and hyphens allowed between digits.
+/// 10 digits starting with 0, or 94 followed by 9 digits with or without a leading +; spaces and hyphens allowed between digits.
public static class PhoneRules
{
- public const string Pattern = @"^(?:0(?:[\s-]*\d){9}|\+94(?:[\s-]*\d){9})[\s-]*$";
- public const string Message = "Enter a valid Sri Lankan phone number: 10 digits starting with 0 (0771234567) or +94 followed by 9 digits (+94771234567).";
+ public const string Pattern = @"^(?:0(?:[\s-]*\d){9}|\+?94(?:[\s-]*\d){9})[\s-]*$";
+ public const string Message = "Enter a valid Sri Lankan phone number: 10 digits starting with 0 (0771234567), or 94 followed by 9 digits (94771234567 or +94771234567).";
}
public sealed class CreateWarehouseRequest
diff --git a/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexClient.cs b/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexClient.cs
index 9f500a4..982797d 100644
--- a/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexClient.cs
+++ b/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexClient.cs
@@ -154,6 +154,67 @@ public sealed class AuthHexClient : IAuthHexClient
public Task VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken ct)
=> CallAsync("alt", "VerifyOTP", request, null, ct);
+ // ---- Notify -------------------------------------------------------------
+
+ public Task> SendEmailAsync(IReadOnlyList to, string subject, string htmlBody, string? textBody, CancellationToken ct)
+ => NotifyAsync("email", new { to, subject, htmlBody, textBody }, ct);
+
+ public Task> SendSmsAsync(IReadOnlyList to, string text, CancellationToken ct)
+ => NotifyAsync("sms", new { to, text }, ct);
+
+ ///
+ /// /api/notify/* is plain JSON ({ results: [{ to, ok, error }] }), not the function
+ /// envelope the manager routes use, so it bypasses .
+ ///
+ private async Task> NotifyAsync(string channel, object body, CancellationToken ct)
+ {
+ using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/notify/{channel}");
+ foreach (var (key, value) in AdminHeaders())
+ httpRequest.Headers.Add(key, value);
+ httpRequest.Content = JsonContent.Create(body, options: JsonOptions);
+
+ HttpResponseMessage httpResponse;
+ try
+ {
+ httpResponse = await _http.SendAsync(httpRequest, ct);
+ }
+ catch (HttpRequestException)
+ {
+ throw new DomainException(ErrorCodes.AuthServiceUnavailable, "The authentication service is unreachable.", 503);
+ }
+ catch (TaskCanceledException) when (!ct.IsCancellationRequested)
+ {
+ throw new DomainException(ErrorCodes.AuthServiceUnavailable, "The authentication service timed out.", 503);
+ }
+
+ NotifyResponse? payload = null;
+ try
+ {
+ payload = await httpResponse.Content.ReadFromJsonAsync(JsonOptions, ct);
+ }
+ catch (JsonException)
+ {
+ // Fall through — reported below with the HTTP status.
+ }
+
+ if (!httpResponse.IsSuccessStatusCode || payload?.Results is null)
+ {
+ var message = payload?.Message
+ ?? (httpResponse.StatusCode == global::System.Net.HttpStatusCode.NotFound
+ ? "The authentication service does not support sending messages yet (no /api/notify)."
+ : $"The authentication service could not send the {channel} (HTTP {(int)httpResponse.StatusCode}).");
+ throw new DomainException(ErrorCodes.AuthUpstreamError, message, 502);
+ }
+
+ return payload.Results;
+ }
+
+ private sealed class NotifyResponse
+ {
+ public List? Results { get; set; }
+ public string? Message { get; set; }
+ }
+
// ---- Transport --------------------------------------------------------
private async Task CallVoidAsync(string routeGroup, string functionName, object payload, string? bearerToken, CancellationToken ct)
diff --git a/Backend/ERPCore/Infra/Auth/AuthHex/IAuthHexClient.cs b/Backend/ERPCore/Infra/Auth/AuthHex/IAuthHexClient.cs
index 54efe32..f83e19d 100644
--- a/Backend/ERPCore/Infra/Auth/AuthHex/IAuthHexClient.cs
+++ b/Backend/ERPCore/Infra/Auth/AuthHex/IAuthHexClient.cs
@@ -57,4 +57,12 @@ public interface IAuthHexClient
Task IsAvailableAsync(IsAvailableRequest request, CancellationToken ct);
Task SendOtpAsync(SendOtpRequest request, CancellationToken ct);
Task VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken ct);
+
+ // Notify (POST /api/notify/email|sms) — service-to-service messaging through AuthHex's own
+ // SMTP / SMS gateway settings, gated by the admin API key (docs/DAY_END_OWNER_REPORT_PLAN.md §3.2).
+ Task> SendEmailAsync(IReadOnlyList to, string subject, string htmlBody, string? textBody, CancellationToken ct);
+ Task> SendSmsAsync(IReadOnlyList to, string text, CancellationToken ct);
}
+
+/// Per-recipient outcome of an AuthHex notify call.
+public sealed record AuthHexNotifyResult(string To, bool Ok, string? Error);
diff --git a/Backend/ERPCore/Infra/Messaging/MessageSenders.cs b/Backend/ERPCore/Infra/Messaging/MessageSenders.cs
new file mode 100644
index 0000000..c405cf2
--- /dev/null
+++ b/Backend/ERPCore/Infra/Messaging/MessageSenders.cs
@@ -0,0 +1,55 @@
+using ERPCore.Infra.Auth.AuthHex;
+
+namespace ERPCore.Infra.Messaging;
+
+/// Per-recipient outcome of a send.
+public sealed record SendResult(string To, bool Ok, string? Error);
+
+///
+/// Sends email and SMS. ERPCore holds no SMTP or SMS credentials — the real implementation asks
+/// AuthHex to send with its own settings (docs/DAY_END_OWNER_REPORT_PLAN.md §3.2).
+/// Implementations report per-recipient failures in the result and only throw when the whole
+/// call failed (e.g. AuthHex unreachable).
+///
+public interface IMessageSender
+{
+ Task> SendEmailAsync(IReadOnlyList to, string subject, string htmlBody, string? textBody, CancellationToken ct);
+ Task> SendSmsAsync(IReadOnlyList to, string text, CancellationToken ct);
+}
+
+/// Default: sends through AuthHex's /api/notify endpoints (Messaging:Provider = AuthHex).
+public sealed class AuthHexMessageSender : IMessageSender
+{
+ private readonly IAuthHexClient _authHex;
+
+ public AuthHexMessageSender(IAuthHexClient authHex) => _authHex = authHex;
+
+ public async Task> SendEmailAsync(IReadOnlyList to, string subject, string htmlBody, string? textBody, CancellationToken ct)
+ => (await _authHex.SendEmailAsync(to, subject, htmlBody, textBody, ct)).Select(r => new SendResult(r.To, r.Ok, r.Error)).ToList();
+
+ public async Task> SendSmsAsync(IReadOnlyList to, string text, CancellationToken ct)
+ => (await _authHex.SendSmsAsync(to, text, ct)).Select(r => new SendResult(r.To, r.Ok, r.Error)).ToList();
+}
+
+///
+/// Writes messages to the log instead of sending (Messaging:Provider = Log) — for local
+/// development and tests, so nothing reaches real phones or inboxes.
+///
+public sealed class LogOnlyMessageSender : IMessageSender
+{
+ private readonly ILogger _logger;
+
+ public LogOnlyMessageSender(ILogger logger) => _logger = logger;
+
+ public Task> SendEmailAsync(IReadOnlyList to, string subject, string htmlBody, string? textBody, CancellationToken ct)
+ {
+ _logger.LogInformation("[LogOnly] Email to {To}: {Subject}\n{Body}", string.Join(", ", to), subject, textBody ?? htmlBody);
+ return Task.FromResult>(to.Select(t => new SendResult(t, true, null)).ToList());
+ }
+
+ public Task> SendSmsAsync(IReadOnlyList to, string text, CancellationToken ct)
+ {
+ _logger.LogInformation("[LogOnly] SMS to {To}:\n{Text}", string.Join(", ", to), text);
+ return Task.FromResult>(to.Select(t => new SendResult(t, true, null)).ToList());
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/BundleSaleTemplateConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/BundleSaleTemplateConfiguration.cs
index c6e4fd5..3a101be 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/BundleSaleTemplateConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/BundleSaleTemplateConfiguration.cs
@@ -16,6 +16,7 @@ public sealed class BundleSaleTemplateConfiguration : IEntityTypeConfiguration x.TemplateCode).IsUnique();
builder.Property(x => x.TemplateName).IsRequired().HasMaxLength(200);
builder.Property(x => x.Description).HasMaxLength(1000);
+ builder.Property(x => x.BundlePrice).HasPrecision(18, 2).HasDefaultValue(0m);
builder.Property(x => x.Status)
.HasConversion().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/OwnerReportConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/OwnerReportConfiguration.cs
new file mode 100644
index 0000000..8198a06
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/OwnerReportConfiguration.cs
@@ -0,0 +1,75 @@
+using ERPCore.Domain.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace ERPCore.Infra.Persistence.Configurations;
+
+/// Singleton owner-report settings row (mirrors SalesConfigConfiguration).
+public sealed class OwnerReportConfigConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("owner_report_config", t =>
+ t.HasCheckConstraint("ck_owner_report_config_singleton", $"`ConfigId` = {OwnerReportConfig.SingletonId}"));
+
+ builder.HasKey(c => c.ConfigId);
+ builder.Property(c => c.ConfigId).ValueGeneratedNever();
+
+ builder.Property(c => c.Enabled).IsRequired().HasDefaultValue(false);
+ // Sentinel = the CLR initializer (true), so an explicit false is still written on insert.
+ builder.Property(c => c.SmsEnabled).IsRequired().HasDefaultValue(true).HasSentinel(true);
+ builder.Property(c => c.EmailEnabled).IsRequired().HasDefaultValue(true).HasSentinel(true);
+ builder.Property(c => c.IncludeBranchBreakdown).IsRequired().HasDefaultValue(true).HasSentinel(true);
+ builder.Property(c => c.SendAtLocalTime).IsRequired();
+
+ builder.Property(c => c.RowVersion).IsRowVersion();
+
+ builder.HasOne(c => c.UpdatedByUser)
+ .WithMany()
+ .HasForeignKey(c => c.UpdatedBy)
+ .OnDelete(DeleteBehavior.Restrict);
+ }
+}
+
+public sealed class OwnerReportRecipientConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("owner_report_recipients");
+ builder.HasKey(r => r.RecipientId);
+ builder.Property(r => r.Name).HasMaxLength(100).IsRequired();
+ builder.Property(r => r.Mobile).HasMaxLength(20);
+ builder.Property(r => r.Email).HasMaxLength(200);
+ builder.Property(r => r.IsActive).IsRequired().HasDefaultValue(true).HasSentinel(true);
+ }
+}
+
+public sealed class OwnerReportDispatchConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("owner_report_dispatches");
+ builder.HasKey(d => d.DispatchId);
+
+ builder.Property(d => d.Channel).HasConversion().HasMaxLength(10).IsRequired();
+ builder.Property(d => d.Status).HasConversion().HasMaxLength(10).IsRequired();
+ builder.Property(d => d.Destination).HasMaxLength(200).IsRequired();
+ builder.Property(d => d.RecipientName).HasMaxLength(100).IsRequired();
+ builder.Property(d => d.Error).HasMaxLength(1000);
+ builder.Property(d => d.DedupeKey).HasMaxLength(40).IsRequired();
+
+ // The "send once" guard: one automatic send per day, channel and recipient. Test sends get
+ // a unique DedupeKey so any number of them can coexist.
+ builder.HasIndex(d => new { d.BusinessDate, d.Channel, d.RecipientId, d.IsTest, d.DedupeKey })
+ .IsUnique()
+ .HasDatabaseName("ux_owner_report_dispatch_once");
+ builder.HasIndex(d => new { d.Status, d.NextAttemptAt });
+ builder.HasIndex(d => d.CreatedAt);
+
+ // Recipients can be removed; their past sends stay in the log (Destination/RecipientName keep the detail).
+ builder.HasOne(d => d.Recipient)
+ .WithMany()
+ .HasForeignKey(d => d.RecipientId)
+ .OnDelete(DeleteBehavior.SetNull);
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/ProductConfigConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/ProductConfigConfiguration.cs
index b7fcca9..86c7f19 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/ProductConfigConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/ProductConfigConfiguration.cs
@@ -32,6 +32,10 @@ public sealed class ProductConfigConfiguration : IEntityTypeConfiguration c.CustomVariantsEnabled).IsRequired().HasDefaultValue(false);
builder.Property(c => c.ItemImagesEnabled).IsRequired().HasDefaultValue(false);
builder.Property(c => c.CustomWarrantyPeriodsEnabled).IsRequired().HasDefaultValue(false);
+ builder.Property(c => c.StockNatureEnabled).IsRequired().HasDefaultValue(true);
+ builder.Property(c => c.TrackingModeEnabled).IsRequired().HasDefaultValue(true);
+ builder.Property(c => c.LowStockAlertsEnabled).IsRequired().HasDefaultValue(true);
+ builder.Property(c => c.AlertWarehouseEnabled).IsRequired().HasDefaultValue(true);
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(c => c.RowVersion).IsRowVersion();
diff --git a/Backend/ERPCore/Infra/Persistence/DataSeeder.cs b/Backend/ERPCore/Infra/Persistence/DataSeeder.cs
index 8bcc85e..243b845 100644
--- a/Backend/ERPCore/Infra/Persistence/DataSeeder.cs
+++ b/Backend/ERPCore/Infra/Persistence/DataSeeder.cs
@@ -64,6 +64,7 @@ public static class DataSeeder
dirty |= await SeedProcurementConfigAsync(db, ct);
dirty |= await SeedGlAccountConfigAsync(db, configuration, ct);
dirty |= await SeedNotificationConfigAsync(db, ct);
+ dirty |= await SeedOwnerReportConfigAsync(db, ct);
if (dirty) await db.SaveChangesAsync(ct);
@@ -212,7 +213,11 @@ public static class DataSeeder
WarrantyPeriodsEnabled = true,
CustomVariantsEnabled = false,
ItemImagesEnabled = false,
- CustomWarrantyPeriodsEnabled = false
+ CustomWarrantyPeriodsEnabled = false,
+ StockNatureEnabled = true,
+ TrackingModeEnabled = true,
+ LowStockAlertsEnabled = true,
+ AlertWarehouseEnabled = true
});
return true;
}
@@ -309,6 +314,15 @@ public static class DataSeeder
});
return true;
}
+ // Off by default: the owner report only starts sending once someone adds recipients and switches it on.
+ private static async Task SeedOwnerReportConfigAsync(ErpDbContext db, CancellationToken ct)
+ {
+ if (await db.OwnerReportConfig.AnyAsync(c => c.ConfigId == OwnerReportConfig.SingletonId, ct)) return false;
+
+ db.OwnerReportConfig.Add(new OwnerReportConfig { ConfigId = OwnerReportConfig.SingletonId, Enabled = false });
+ return true;
+ }
+
private static async Task SeedNotificationConfigAsync(ErpDbContext db, CancellationToken ct)
{
diff --git a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs
index b9bba97..8403c4a 100644
--- a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs
+++ b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs
@@ -55,6 +55,9 @@ public class ErpDbContext : DbContext
public DbSet GlAccountConfig => Set();
/// Singleton row for notification delivery preferences.
public DbSet NotificationConfig => Set();
+ public DbSet OwnerReportConfig => Set();
+ public DbSet OwnerReportRecipients => Set();
+ public DbSet OwnerReportDispatches => Set();
public DbSet NotificationConfigModules => Set();
public DbSet Notifications => Set();
public DbSet NotificationRecipients => Set();
diff --git a/Backend/ERPCore/Migrations/20261005190000_FixCheckConstraintQuotingForMySql.cs b/Backend/ERPCore/Migrations/20261005190000_FixCheckConstraintQuotingForMySql.cs
index d187ec9..f81051a 100644
--- a/Backend/ERPCore/Migrations/20261005190000_FixCheckConstraintQuotingForMySql.cs
+++ b/Backend/ERPCore/Migrations/20261005190000_FixCheckConstraintQuotingForMySql.cs
@@ -17,8 +17,8 @@ namespace ERPCore.Migrations
///
/// Written by hand rather than scaffolded, and the drop/add is emitted as raw SQL instead of
/// DropCheckConstraint: MariaDB spells the removal DROP CONSTRAINT (MySQL's
- /// DROP CHECK is not a safe assumption here), and IF EXISTS keeps this
- /// re-runnable on a database where the broken constraint was already removed by hand.
+ /// DROP CHECK is not a safe assumption here). It is a plain drop, not IF EXISTS:
+ /// that form is MariaDB-only and a syntax error on MySQL 8, which broke fresh local databases.
///
///
public partial class FixCheckConstraintQuotingForMySql : Migration
@@ -26,13 +26,13 @@ namespace ERPCore.Migrations
///
protected override void Up(MigrationBuilder migrationBuilder)
{
- migrationBuilder.Sql("ALTER TABLE `grn_config` DROP CONSTRAINT IF EXISTS `ck_grn_config_singleton`;");
+ DropCheck(migrationBuilder, "grn_config", "ck_grn_config_singleton");
migrationBuilder.Sql("ALTER TABLE `grn_config` ADD CONSTRAINT `ck_grn_config_singleton` CHECK (`ConfigId` = 1);");
- migrationBuilder.Sql("ALTER TABLE `sales_config` DROP CONSTRAINT IF EXISTS `ck_sales_config_singleton`;");
+ DropCheck(migrationBuilder, "sales_config", "ck_sales_config_singleton");
migrationBuilder.Sql("ALTER TABLE `sales_config` ADD CONSTRAINT `ck_sales_config_singleton` CHECK (`ConfigId` = 1);");
- migrationBuilder.Sql("ALTER TABLE `warranty_periods` DROP CONSTRAINT IF EXISTS `ck_warranty_periods_months`;");
+ DropCheck(migrationBuilder, "warranty_periods", "ck_warranty_periods_months");
migrationBuilder.Sql("ALTER TABLE `warranty_periods` ADD CONSTRAINT `ck_warranty_periods_months` CHECK (`Months` BETWEEN 1 AND 600);");
}
@@ -40,14 +40,23 @@ namespace ERPCore.Migrations
protected override void Down(MigrationBuilder migrationBuilder)
{
// Restores the original (broken) expressions so the migration is a faithful inverse.
- migrationBuilder.Sql("ALTER TABLE `grn_config` DROP CONSTRAINT IF EXISTS `ck_grn_config_singleton`;");
+ DropCheck(migrationBuilder, "grn_config", "ck_grn_config_singleton");
migrationBuilder.Sql("ALTER TABLE `grn_config` ADD CONSTRAINT `ck_grn_config_singleton` CHECK (\"ConfigId\" = 1);");
- migrationBuilder.Sql("ALTER TABLE `sales_config` DROP CONSTRAINT IF EXISTS `ck_sales_config_singleton`;");
+ DropCheck(migrationBuilder, "sales_config", "ck_sales_config_singleton");
migrationBuilder.Sql("ALTER TABLE `sales_config` ADD CONSTRAINT `ck_sales_config_singleton` CHECK (\"ConfigId\" = 1);");
- migrationBuilder.Sql("ALTER TABLE `warranty_periods` DROP CONSTRAINT IF EXISTS `ck_warranty_periods_months`;");
+ DropCheck(migrationBuilder, "warranty_periods", "ck_warranty_periods_months");
migrationBuilder.Sql("ALTER TABLE `warranty_periods` ADD CONSTRAINT `ck_warranty_periods_months` CHECK (\"Months\" BETWEEN 1 AND 600);");
}
+
+ ///
+ /// Plain DROP CONSTRAINT — valid on MariaDB and on MySQL 8.0.19+. Deliberately not
+ /// DROP CONSTRAINT IF EXISTS, which is MariaDB-only and is a syntax error on MySQL 8,
+ /// breaking fresh local databases. The constraints always exist at this point: the earlier
+ /// migrations create them.
+ ///
+ private static void DropCheck(MigrationBuilder migrationBuilder, string table, string constraint)
+ => migrationBuilder.Sql($"ALTER TABLE `{table}` DROP CONSTRAINT `{constraint}`;");
}
}
diff --git a/Backend/ERPCore/Migrations/20261019053150_AddOwnerReport.Designer.cs b/Backend/ERPCore/Migrations/20261019053150_AddOwnerReport.Designer.cs
new file mode 100644
index 0000000..aac0958
--- /dev/null
+++ b/Backend/ERPCore/Migrations/20261019053150_AddOwnerReport.Designer.cs
@@ -0,0 +1,11546 @@
+//
+using System;
+using ERPCore.Infra.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace ERPCore.Migrations
+{
+ [DbContext(typeof(ErpDbContext))]
+ [Migration("20261019053150_AddOwnerReport")]
+ partial class AddOwnerReport
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .UseCollation("utf8mb4_unicode_ci")
+ .HasAnnotation("ProductVersion", "9.0.20")
+ .HasAnnotation("Relational:MaxIdentifierLength", 64);
+
+ MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.AccessConfig", b =>
+ {
+ b.Property("ConfigId")
+ .HasColumnType("int");
+
+ b.Property("DefaultRoleId")
+ .HasColumnType("int");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("int unsigned");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("int");
+
+ b.HasKey("ConfigId");
+
+ b.HasIndex("DefaultRoleId");
+
+ b.HasIndex("UpdatedBy");
+
+ b.ToTable("access_config", null, t =>
+ {
+ t.HasCheckConstraint("ck_access_config_singleton", "`ConfigId` = 1");
+ });
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.ApprovalConfig", b =>
+ {
+ b.Property("ApprovalConfigId")
+ .HasColumnType("int");
+
+ b.Property("Level2RoleIds")
+ .HasMaxLength(500)
+ .HasColumnType("varchar(500)");
+
+ b.Property("PriceAdjustmentLevel2ThresholdPercent")
+ .HasPrecision(9, 4)
+ .HasColumnType("decimal(9,4)");
+
+ b.Property("PriceAdjustmentLevels")
+ .HasColumnType("int");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("int unsigned");
+
+ b.Property("StockAdjustmentLevel2ThresholdValue")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("StockAdjustmentLevels")
+ .HasColumnType("int");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("int");
+
+ b.Property("WastageLevels")
+ .HasColumnType("int");
+
+ b.HasKey("ApprovalConfigId");
+
+ b.ToTable("approval_config", null, t =>
+ {
+ t.HasCheckConstraint("ck_approval_config_singleton", "`ApprovalConfigId` = 1");
+ });
+
+ b.HasData(
+ new
+ {
+ ApprovalConfigId = 1,
+ PriceAdjustmentLevel2ThresholdPercent = 0m,
+ PriceAdjustmentLevels = 2,
+ RowVersion = 1u,
+ StockAdjustmentLevel2ThresholdValue = 0m,
+ StockAdjustmentLevels = 2,
+ WastageLevels = 0
+ });
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.AttendanceRecord", b =>
+ {
+ b.Property("AttendanceRecordId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("AttendanceRecordId"));
+
+ b.Property("AttendanceDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("AttendanceStatus")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.Property("AttendanceUploadBatchId")
+ .HasColumnType("int");
+
+ b.Property("CheckIn")
+ .HasColumnType("time(6)");
+
+ b.Property("CheckOut")
+ .HasColumnType("time(6)");
+
+ b.Property("DuplicateOfAttendanceRecordId")
+ .HasColumnType("int");
+
+ b.Property("EarlyLeaveMinutes")
+ .HasColumnType("int");
+
+ b.Property("EditedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("EditedBy")
+ .HasColumnType("int");
+
+ b.Property("EmployeeId")
+ .HasColumnType("int");
+
+ b.Property("IsManualOverride")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("LateMinutes")
+ .HasColumnType("int");
+
+ b.Property("Notes")
+ .HasMaxLength(1000)
+ .HasColumnType("varchar(1000)");
+
+ b.Property("OvertimeMinutes")
+ .HasColumnType("int");
+
+ b.Property("RowValidationStatus")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("int unsigned");
+
+ b.Property("WorkShiftId")
+ .HasColumnType("int");
+
+ b.Property("WorkingMinutes")
+ .HasColumnType("int");
+
+ 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("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("AttendanceUploadBatchId"));
+
+ b.Property("ConfirmedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("ConfirmedBy")
+ .HasColumnType("int");
+
+ b.Property("DocNo")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("varchar(30)");
+
+ b.Property("OriginalFileName")
+ .HasMaxLength(260)
+ .HasColumnType("varchar(260)");
+
+ b.Property("PeriodEnd")
+ .HasColumnType("datetime(6)");
+
+ b.Property("PeriodStart")
+ .HasColumnType("datetime(6)");
+
+ b.Property("RowCountDuplicate")
+ .HasColumnType("int");
+
+ b.Property("RowCountError")
+ .HasColumnType("int");
+
+ b.Property("RowCountTotal")
+ .HasColumnType("int");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("int unsigned");
+
+ b.Property("SourceType")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.Property("UploadedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("UploadedBy")
+ .HasColumnType("int");
+
+ 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("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("AuditId"));
+
+ b.Property("Action")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("varchar(10)");
+
+ b.Property("ChangeSet")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("EntityId")
+ .HasColumnType("int");
+
+ b.Property("EntityType")
+ .IsRequired()
+ .HasMaxLength(80)
+ .HasColumnType("varchar(80)");
+
+ b.Property("UserId")
+ .HasColumnType("int");
+
+ b.HasKey("AuditId");
+
+ b.HasIndex("CreatedAt");
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("EntityType", "EntityId");
+
+ b.ToTable("audit_logs", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.BankDeposit", b =>
+ {
+ b.Property("BankDepositId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("BankDepositId"));
+
+ b.Property("Amount")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("BranchId")
+ .HasColumnType("int");
+
+ b.Property("BusinessDate")
+ .HasColumnType("date");
+
+ b.Property("CancelReason")
+ .HasMaxLength(250)
+ .HasColumnType("varchar(250)");
+
+ b.Property("CancelledAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("CancelledBy")
+ .HasColumnType("int");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("CreatedBy")
+ .HasColumnType("int");
+
+ b.Property("DepositDate")
+ .HasColumnType("date");
+
+ b.Property("DepositSlipNo")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("DepositedBy")
+ .HasMaxLength(150)
+ .HasColumnType("varchar(150)");
+
+ b.Property("DocNo")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("varchar(30)");
+
+ b.Property("FromCashAccountCode")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.Property("FromCashAccountName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("varchar(200)");
+
+ b.Property("FromGlCashAccountId")
+ .HasColumnType("bigint");
+
+ b.Property("GlJournalNo")
+ .HasMaxLength(30)
+ .HasColumnType("varchar(30)");
+
+ b.Property("GlPostedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("GlReversalJournalNo")
+ .HasMaxLength(30)
+ .HasColumnType("varchar(30)");
+
+ b.Property("Remarks")
+ .HasMaxLength(500)
+ .HasColumnType("varchar(500)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("int unsigned");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)")
+ .HasDefaultValue("Posted");
+
+ b.Property("ToAccountNumber")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("ToBankAccountCode")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.Property("ToBankAccountName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("varchar(200)");
+
+ b.Property("ToBankName")
+ .HasMaxLength(150)
+ .HasColumnType("varchar(150)");
+
+ b.Property("ToGlBankAccountId")
+ .HasColumnType("bigint");
+
+ b.HasKey("BankDepositId");
+
+ b.HasIndex("CreatedBy");
+
+ b.HasIndex("DocNo")
+ .IsUnique();
+
+ b.HasIndex("ToGlBankAccountId");
+
+ b.HasIndex("BranchId", "BusinessDate", "Status");
+
+ b.ToTable("bank_deposits", null, t =>
+ {
+ t.HasCheckConstraint("ck_bank_deposits_amount", "`Amount` > 0");
+ });
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.Batch", b =>
+ {
+ b.Property("BatchId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("BatchId"));
+
+ b.Property("Barcode")
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.Property("BatchNo")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("ExpiryDate")
+ .HasColumnType("date");
+
+ b.Property("ItemId")
+ .HasColumnType("int");
+
+ 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("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("BinId"));
+
+ b.Property("BinType")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("WarehouseId")
+ .HasColumnType("int");
+
+ 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("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("BranchId"));
+
+ b.Property("Address")
+ .HasMaxLength(500)
+ .HasColumnType("varchar(500)");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("varchar(200)");
+
+ b.Property("PhoneNumber")
+ .HasMaxLength(30)
+ .HasColumnType("varchar(30)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("int unsigned");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)")
+ .HasDefaultValue("Active");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ 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("int");
+
+ b.Property("BranchingEnabled")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("tinyint(1)")
+ .HasDefaultValue(true);
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("int unsigned");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("int");
+
+ b.Property("WarehouseBranchingEnabled")
+ .HasColumnType("tinyint(1)");
+
+ 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("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("BrandId"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("varchar(200)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("int unsigned");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)")
+ .HasDefaultValue("Active");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ 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("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("BundleSaleId"));
+
+ b.Property("BundleCode")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("BundleDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("BundleName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("varchar(200)");
+
+ b.Property("BundleNo")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("BundlePrice")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("BundleSaleTemplateId")
+ .HasColumnType("int");
+
+ b.Property("CardAmount")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("CashAmount")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("CashierUserId")
+ .HasColumnType("int");
+
+ b.Property("ComponentSubtotal")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("CustomerId")
+ .HasColumnType("int");
+
+ b.Property("CustomerSnapshotName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("varchar(200)");
+
+ b.Property("DayEndId")
+ .HasColumnType("int");
+
+ b.Property("DiscountTotal")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("GrandTotal")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("MarginAmount")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("int unsigned");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)")
+ .HasDefaultValue("Draft");
+
+ b.Property("TaxTotal")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("WarehouseId")
+ .HasColumnType("int");
+
+ 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("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("BundleSaleLineId"));
+
+ b.Property("BundleSaleId")
+ .HasColumnType("int");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("varchar(200)");
+
+ b.Property("IncludeInBundle")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("tinyint(1)")
+ .HasDefaultValue(true);
+
+ b.Property("IsComponent")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("tinyint(1)")
+ .HasDefaultValue(true);
+
+ b.Property("ItemId")
+ .HasColumnType("int");
+
+ b.Property("LineTotal")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("ParentLineId")
+ .HasColumnType("int");
+
+ b.Property("Qty")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("UnitPrice")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("WarehouseId")
+ .HasColumnType("int");
+
+ 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("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("BundleSaleTemplateId"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Description")
+ .HasMaxLength(1000)
+ .HasColumnType("varchar(1000)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("int unsigned");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)")
+ .HasDefaultValue("Active");
+
+ b.Property("TemplateCode")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("TemplateName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("varchar(200)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ 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("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("BundleSaleTemplateLineId"));
+
+ b.Property("BundleSaleTemplateId")
+ .HasColumnType("int");
+
+ b.Property("IncludeInBundle")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("ItemId")
+ .HasColumnType("int");
+
+ b.Property("Qty")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("SortOrder")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int")
+ .HasDefaultValue(0);
+
+ b.Property("UnitPrice")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("WarehouseId")
+ .HasColumnType("int");
+
+ 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("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CategoryId"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("varchar(200)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("int unsigned");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)")
+ .HasDefaultValue("Active");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ 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("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CustomerId"));
+
+ b.Property