Sms & Email

This commit is contained in:
2026-10-20 10:45:03 +05:30
parent a40e463e8b
commit 0e423b806c
29 changed files with 13845 additions and 26 deletions
@@ -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;
/// <summary>
/// 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).
/// </summary>
[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<ActionResult<OwnerReportConfigDto>> 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<ActionResult<OwnerReportConfigDto>> 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<ActionResult<OwnerReportPreviewDto>> Preview([FromQuery] DateOnly? date, CancellationToken ct)
=> Ok(await _service.PreviewAsync(date, ct));
[HttpPost("test")]
[ProducesResponseType(typeof(IReadOnlyList<OwnerReportSendResultDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status429TooManyRequests)]
public async Task<ActionResult<IReadOnlyList<OwnerReportSendResultDto>>> 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<OwnerReportDispatchDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<OwnerReportDispatchDto>>> Dispatches([FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _service.ListDispatchesAsync(query, ct));
}
@@ -0,0 +1,79 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Day-end owner report settings — a singleton row, same shape as <see cref="SalesConfig"/>
/// (docs/DAY_END_OWNER_REPORT_PLAN.md §3.1). When <see cref="Enabled"/>, OwnerReportJob sends the
/// day's figures to every active <see cref="OwnerReportRecipient"/> at <see cref="SendAtLocalTime"/>.
/// </summary>
public class OwnerReportConfig
{
/// <summary>Always 1 - the singleton row's id.</summary>
public const int SingletonId = 1;
public int ConfigId { get; set; }
/// <summary>Master switch — nothing is sent automatically while off ("Send test now" still works).</summary>
public bool Enabled { get; set; }
public bool SmsEnabled { get; set; } = true;
public bool EmailEnabled { get; set; } = true;
/// <summary>Local time of day (DayEndReport:TimeZone) after which the day's report goes out.</summary>
public TimeOnly SendAtLocalTime { get; set; } = new(21, 0);
/// <summary>Adds one summary row per active branch when branching is switched on.</summary>
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; }
}
/// <summary>Someone who receives the day-end report — by SMS, email or both.</summary>
public class OwnerReportRecipient
{
public int RecipientId { get; set; }
public string Name { get; set; } = string.Empty;
/// <summary>Normalised to +94XXXXXXXXX (Sri Lankan rule, see PhoneRules).</summary>
public string? Mobile { get; set; }
public string? Email { get; set; }
public bool IsActive { get; set; } = true;
}
/// <summary>
/// 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.
/// </summary>
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; }
/// <summary>Number / email address at send time — kept even if the recipient is edited later.</summary>
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; }
/// <summary>Test sends ("Send test now") — never block or count as the day's automatic send.</summary>
public bool IsTest { get; set; }
/// <summary>For tests: a unique token so repeated tests don't collide on the unique index.</summary>
public string DedupeKey { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public DateTime? SentAt { get; set; }
public DateTime? NextAttemptAt { get; set; }
}
@@ -0,0 +1,16 @@
namespace ERPCore.Domain.Enums;
/// <summary>How the day-end owner report is delivered (docs/DAY_END_OWNER_REPORT_PLAN.md).</summary>
public enum OwnerReportChannel
{
Sms,
Email,
}
/// <summary>State of one owner-report send.</summary>
public enum OwnerReportDispatchStatus
{
Pending,
Sent,
Failed,
}
@@ -0,0 +1,52 @@
using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Config;
/// <summary>Day-end owner report settings (docs/DAY_END_OWNER_REPORT_PLAN.md §3.5).</summary>
/// <param name="SendAtLocalTime">"HH:mm" in <paramref name="TimeZone"/>.</param>
public sealed record OwnerReportConfigDto(
bool Enabled, bool SmsEnabled, bool EmailEnabled, string SendAtLocalTime, bool IncludeBranchBreakdown,
IReadOnlyList<OwnerReportRecipientDto> 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; }
/// <summary>"HH:mm", 24-hour.</summary>
[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";
/// <summary>The full list — recipients not in it are removed. At most 5.</summary>
[Required, MaxLength(5, ErrorMessage = "At most 5 recipients.")]
public List<OwnerReportRecipientInput> Recipients { get; set; } = new();
}
public sealed class OwnerReportRecipientInput
{
/// <summary>Existing recipient to update; null adds a new one.</summary>
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;
}
/// <summary>The report as it would be sent for <paramref name="Date"/>.</summary>
public sealed record OwnerReportPreviewDto(DateOnly Date, string SmsText, string EmailSubject, string EmailHtml);
public sealed class OwnerReportTestRequest
{
/// <summary>Day to report; null = today in the report's time zone.</summary>
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);
@@ -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);
/// <summary>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.</summary>
/// 10 digits starting with 0, or 94 followed by 9 digits with or without a leading +; spaces and hyphens allowed between digits.</summary>
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
@@ -154,6 +154,67 @@ public sealed class AuthHexClient : IAuthHexClient
public Task<AuthHexSessionResult> VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken ct)
=> CallAsync<AuthHexSessionResult>("alt", "VerifyOTP", request, null, ct);
// ---- Notify -------------------------------------------------------------
public Task<IReadOnlyList<AuthHexNotifyResult>> SendEmailAsync(IReadOnlyList<string> to, string subject, string htmlBody, string? textBody, CancellationToken ct)
=> NotifyAsync("email", new { to, subject, htmlBody, textBody }, ct);
public Task<IReadOnlyList<AuthHexNotifyResult>> SendSmsAsync(IReadOnlyList<string> to, string text, CancellationToken ct)
=> NotifyAsync("sms", new { to, text }, ct);
/// <summary>
/// /api/notify/* is plain JSON (<c>{ results: [{ to, ok, error }] }</c>), not the function
/// envelope the manager routes use, so it bypasses <see cref="CallAsync{T}"/>.
/// </summary>
private async Task<IReadOnlyList<AuthHexNotifyResult>> 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<NotifyResponse>(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<AuthHexNotifyResult>? Results { get; set; }
public string? Message { get; set; }
}
// ---- Transport --------------------------------------------------------
private async Task CallVoidAsync(string routeGroup, string functionName, object payload, string? bearerToken, CancellationToken ct)
@@ -57,4 +57,12 @@ public interface IAuthHexClient
Task<IsAvailableResponse> IsAvailableAsync(IsAvailableRequest request, CancellationToken ct);
Task<SendOtpResponse> SendOtpAsync(SendOtpRequest request, CancellationToken ct);
Task<AuthHexSessionResult> 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<IReadOnlyList<AuthHexNotifyResult>> SendEmailAsync(IReadOnlyList<string> to, string subject, string htmlBody, string? textBody, CancellationToken ct);
Task<IReadOnlyList<AuthHexNotifyResult>> SendSmsAsync(IReadOnlyList<string> to, string text, CancellationToken ct);
}
/// <summary>Per-recipient outcome of an AuthHex notify call.</summary>
public sealed record AuthHexNotifyResult(string To, bool Ok, string? Error);
@@ -0,0 +1,55 @@
using ERPCore.Infra.Auth.AuthHex;
namespace ERPCore.Infra.Messaging;
/// <summary>Per-recipient outcome of a send.</summary>
public sealed record SendResult(string To, bool Ok, string? Error);
/// <summary>
/// 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).
/// </summary>
public interface IMessageSender
{
Task<IReadOnlyList<SendResult>> SendEmailAsync(IReadOnlyList<string> to, string subject, string htmlBody, string? textBody, CancellationToken ct);
Task<IReadOnlyList<SendResult>> SendSmsAsync(IReadOnlyList<string> to, string text, CancellationToken ct);
}
/// <summary>Default: sends through AuthHex's /api/notify endpoints (Messaging:Provider = AuthHex).</summary>
public sealed class AuthHexMessageSender : IMessageSender
{
private readonly IAuthHexClient _authHex;
public AuthHexMessageSender(IAuthHexClient authHex) => _authHex = authHex;
public async Task<IReadOnlyList<SendResult>> SendEmailAsync(IReadOnlyList<string> 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<IReadOnlyList<SendResult>> SendSmsAsync(IReadOnlyList<string> to, string text, CancellationToken ct)
=> (await _authHex.SendSmsAsync(to, text, ct)).Select(r => new SendResult(r.To, r.Ok, r.Error)).ToList();
}
/// <summary>
/// Writes messages to the log instead of sending (Messaging:Provider = Log) — for local
/// development and tests, so nothing reaches real phones or inboxes.
/// </summary>
public sealed class LogOnlyMessageSender : IMessageSender
{
private readonly ILogger<LogOnlyMessageSender> _logger;
public LogOnlyMessageSender(ILogger<LogOnlyMessageSender> logger) => _logger = logger;
public Task<IReadOnlyList<SendResult>> SendEmailAsync(IReadOnlyList<string> 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<IReadOnlyList<SendResult>>(to.Select(t => new SendResult(t, true, null)).ToList());
}
public Task<IReadOnlyList<SendResult>> SendSmsAsync(IReadOnlyList<string> to, string text, CancellationToken ct)
{
_logger.LogInformation("[LogOnly] SMS to {To}:\n{Text}", string.Join(", ", to), text);
return Task.FromResult<IReadOnlyList<SendResult>>(to.Select(t => new SendResult(t, true, null)).ToList());
}
}
@@ -0,0 +1,75 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
/// <summary>Singleton owner-report settings row (mirrors SalesConfigConfiguration).</summary>
public sealed class OwnerReportConfigConfiguration : IEntityTypeConfiguration<OwnerReportConfig>
{
public void Configure(EntityTypeBuilder<OwnerReportConfig> 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<OwnerReportRecipient>
{
public void Configure(EntityTypeBuilder<OwnerReportRecipient> 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<OwnerReportDispatch>
{
public void Configure(EntityTypeBuilder<OwnerReportDispatch> builder)
{
builder.ToTable("owner_report_dispatches");
builder.HasKey(d => d.DispatchId);
builder.Property(d => d.Channel).HasConversion<string>().HasMaxLength(10).IsRequired();
builder.Property(d => d.Status).HasConversion<string>().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);
}
}
@@ -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);
@@ -309,6 +310,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<bool> 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<bool> SeedNotificationConfigAsync(ErpDbContext db, CancellationToken ct)
{
@@ -55,6 +55,9 @@ public class ErpDbContext : DbContext
public DbSet<GlAccountConfig> GlAccountConfig => Set<GlAccountConfig>();
/// <summary>Singleton row for notification delivery preferences.</summary>
public DbSet<NotificationConfig> NotificationConfig => Set<NotificationConfig>();
public DbSet<OwnerReportConfig> OwnerReportConfig => Set<OwnerReportConfig>();
public DbSet<OwnerReportRecipient> OwnerReportRecipients => Set<OwnerReportRecipient>();
public DbSet<OwnerReportDispatch> OwnerReportDispatches => Set<OwnerReportDispatch>();
public DbSet<NotificationConfigModule> NotificationConfigModules => Set<NotificationConfigModule>();
public DbSet<Notification> Notifications => Set<Notification>();
public DbSet<NotificationRecipient> NotificationRecipients => Set<NotificationRecipient>();
@@ -17,8 +17,8 @@ namespace ERPCore.Migrations
/// <remarks>
/// Written by hand rather than scaffolded, and the drop/add is emitted as raw SQL instead of
/// <c>DropCheckConstraint</c>: MariaDB spells the removal <c>DROP CONSTRAINT</c> (MySQL's
/// <c>DROP CHECK</c> is not a safe assumption here), and <c>IF EXISTS</c> keeps this
/// re-runnable on a database where the broken constraint was already removed by hand.
/// <c>DROP CHECK</c> is not a safe assumption here). It is a plain drop, not <c>IF EXISTS</c>:
/// that form is MariaDB-only and a syntax error on MySQL 8, which broke fresh local databases.
/// </remarks>
/// <inheritdoc />
public partial class FixCheckConstraintQuotingForMySql : Migration
@@ -26,13 +26,13 @@ namespace ERPCore.Migrations
/// <inheritdoc />
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);");
}
/// <summary>
/// Plain <c>DROP CONSTRAINT</c> — valid on MariaDB and on MySQL 8.0.19+. Deliberately not
/// <c>DROP CONSTRAINT IF EXISTS</c>, 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.
/// </summary>
private static void DropCheck(MigrationBuilder migrationBuilder, string table, string constraint)
=> migrationBuilder.Sql($"ALTER TABLE `{table}` DROP CONSTRAINT `{constraint}`;");
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,131 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddOwnerReport : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "owner_report_config",
columns: table => new
{
ConfigId = table.Column<int>(type: "int", nullable: false),
Enabled = table.Column<bool>(type: "tinyint(1)", nullable: false, defaultValue: false),
SmsEnabled = table.Column<bool>(type: "tinyint(1)", nullable: false, defaultValue: true),
EmailEnabled = table.Column<bool>(type: "tinyint(1)", nullable: false, defaultValue: true),
SendAtLocalTime = table.Column<TimeOnly>(type: "time(6)", nullable: false),
IncludeBranchBreakdown = table.Column<bool>(type: "tinyint(1)", nullable: false, defaultValue: true),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
UpdatedBy = table.Column<int>(type: "int", nullable: true),
RowVersion = table.Column<uint>(type: "int unsigned", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_owner_report_config", x => x.ConfigId);
table.CheckConstraint("ck_owner_report_config_singleton", "`ConfigId` = 1");
table.ForeignKey(
name: "FK_owner_report_config_users_UpdatedBy",
column: x => x.UpdatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateTable(
name: "owner_report_recipients",
columns: table => new
{
RecipientId = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
Name = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false, collation: "utf8mb4_unicode_ci"),
Mobile = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
Email = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: true, collation: "utf8mb4_unicode_ci"),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false, defaultValue: true)
},
constraints: table =>
{
table.PrimaryKey("PK_owner_report_recipients", x => x.RecipientId);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateTable(
name: "owner_report_dispatches",
columns: table => new
{
DispatchId = table.Column<long>(type: "bigint", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
BusinessDate = table.Column<DateOnly>(type: "date", nullable: false),
Channel = table.Column<string>(type: "varchar(10)", maxLength: 10, nullable: false, collation: "utf8mb4_unicode_ci"),
RecipientId = table.Column<int>(type: "int", nullable: true),
Destination = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: false, collation: "utf8mb4_unicode_ci"),
RecipientName = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false, collation: "utf8mb4_unicode_ci"),
Status = table.Column<string>(type: "varchar(10)", maxLength: 10, nullable: false, collation: "utf8mb4_unicode_ci"),
Attempts = table.Column<int>(type: "int", nullable: false),
Error = table.Column<string>(type: "varchar(1000)", maxLength: 1000, nullable: true, collation: "utf8mb4_unicode_ci"),
IsTest = table.Column<bool>(type: "tinyint(1)", nullable: false),
DedupeKey = table.Column<string>(type: "varchar(40)", maxLength: 40, nullable: false, collation: "utf8mb4_unicode_ci"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
SentAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
NextAttemptAt = table.Column<DateTime>(type: "datetime(6)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_owner_report_dispatches", x => x.DispatchId);
table.ForeignKey(
name: "FK_owner_report_dispatches_owner_report_recipients_RecipientId",
column: x => x.RecipientId,
principalTable: "owner_report_recipients",
principalColumn: "RecipientId",
onDelete: ReferentialAction.SetNull);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateIndex(
name: "IX_owner_report_config_UpdatedBy",
table: "owner_report_config",
column: "UpdatedBy");
migrationBuilder.CreateIndex(
name: "IX_owner_report_dispatches_CreatedAt",
table: "owner_report_dispatches",
column: "CreatedAt");
migrationBuilder.CreateIndex(
name: "IX_owner_report_dispatches_RecipientId",
table: "owner_report_dispatches",
column: "RecipientId");
migrationBuilder.CreateIndex(
name: "IX_owner_report_dispatches_Status_NextAttemptAt",
table: "owner_report_dispatches",
columns: new[] { "Status", "NextAttemptAt" });
migrationBuilder.CreateIndex(
name: "ux_owner_report_dispatch_once",
table: "owner_report_dispatches",
columns: new[] { "BusinessDate", "Channel", "RecipientId", "IsTest", "DedupeKey" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "owner_report_config");
migrationBuilder.DropTable(
name: "owner_report_dispatches");
migrationBuilder.DropTable(
name: "owner_report_recipients");
}
}
}
@@ -3733,6 +3733,158 @@ namespace ERPCore.Migrations
b.ToTable("opening_stock_lines", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.OwnerReportConfig", b =>
{
b.Property<int>("ConfigId")
.HasColumnType("int");
b.Property<bool>("EmailEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(false);
b.Property<bool>("IncludeBranchBreakdown")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("int unsigned");
b.Property<TimeOnly>("SendAtLocalTime")
.HasColumnType("time(6)");
b.Property<bool>("SmsEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<int?>("UpdatedBy")
.HasColumnType("int");
b.HasKey("ConfigId");
b.HasIndex("UpdatedBy");
b.ToTable("owner_report_config", null, t =>
{
t.HasCheckConstraint("ck_owner_report_config_singleton", "`ConfigId` = 1");
});
});
modelBuilder.Entity("ERPCore.Domain.Entities.OwnerReportDispatch", b =>
{
b.Property<long>("DispatchId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("DispatchId"));
b.Property<int>("Attempts")
.HasColumnType("int");
b.Property<DateOnly>("BusinessDate")
.HasColumnType("date");
b.Property<string>("Channel")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("DedupeKey")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<string>("Destination")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<string>("Error")
.HasMaxLength(1000)
.HasColumnType("varchar(1000)");
b.Property<bool>("IsTest")
.HasColumnType("tinyint(1)");
b.Property<DateTime?>("NextAttemptAt")
.HasColumnType("datetime(6)");
b.Property<int?>("RecipientId")
.HasColumnType("int");
b.Property<string>("RecipientName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<DateTime?>("SentAt")
.HasColumnType("datetime(6)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.HasKey("DispatchId");
b.HasIndex("CreatedAt");
b.HasIndex("RecipientId");
b.HasIndex("Status", "NextAttemptAt");
b.HasIndex("BusinessDate", "Channel", "RecipientId", "IsTest", "DedupeKey")
.IsUnique()
.HasDatabaseName("ux_owner_report_dispatch_once");
b.ToTable("owner_report_dispatches", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.OwnerReportRecipient", b =>
{
b.Property<int>("RecipientId")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("RecipientId"));
b.Property<string>("Email")
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.Property<string>("Mobile")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.HasKey("RecipientId");
b.ToTable("owner_report_recipients", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLine", b =>
{
b.Property<int>("PayrollLineId")
@@ -9631,6 +9783,26 @@ namespace ERPCore.Migrations
b.Navigation("Warehouse");
});
modelBuilder.Entity("ERPCore.Domain.Entities.OwnerReportConfig", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser")
.WithMany()
.HasForeignKey("UpdatedBy")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("UpdatedByUser");
});
modelBuilder.Entity("ERPCore.Domain.Entities.OwnerReportDispatch", b =>
{
b.HasOne("ERPCore.Domain.Entities.OwnerReportRecipient", "Recipient")
.WithMany()
.HasForeignKey("RecipientId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Recipient");
});
modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLine", b =>
{
b.HasOne("ERPCore.Domain.Entities.Employee", "Employee")
+19 -1
View File
@@ -50,9 +50,17 @@ if (string.IsNullOrWhiteSpace(connectionString))
"ConnectionStrings:DefaultConnection is not configured. Set it in appsettings.<Environment>.json "
+ "or as the ConnectionStrings__DefaultConnection environment variable.");
// Optional override for a different local server, e.g. "8.0.44-mysql" in user-secrets
// (Database:ServerVersion) — Pomelo otherwise emits MariaDB-only SQL (INSERT … RETURNING) that
// MySQL rejects. Unset everywhere else, so the MariaDB 10.11 default above still applies.
var serverVersionOverride = builder.Configuration["Database:ServerVersion"];
ServerVersion serverVersion = string.IsNullOrWhiteSpace(serverVersionOverride)
? new MariaDbServerVersion(new Version(10, 11, 15))
: ServerVersion.Parse(serverVersionOverride);
builder.Services.AddDbContext<ErpDbContext>(o =>
{
o.UseMySql(connectionString, new MariaDbServerVersion(new Version(10, 11, 15)));
o.UseMySql(connectionString, serverVersion);
});
// ProblemDetails (RFC 7807) + domain-exception mapping
@@ -126,6 +134,16 @@ builder.Services.AddScoped<ISalesConfigService, SalesConfigService>();
builder.Services.AddScoped<IGrnConfigService, GrnConfigService>();
builder.Services.AddScoped<IProcurementConfigService, ProcurementConfigService>();
builder.Services.AddScoped<IAccessConfigService, AccessConfigService>();
// Day-end owner report (docs/DAY_END_OWNER_REPORT_PLAN.md). Messages go through AuthHex, which
// holds the SMTP / SMS credentials; Messaging:Provider = "Log" writes them to the log instead.
builder.Services.AddScoped<ERPCore.Services.OwnerReport.OwnerReportContentBuilder>();
builder.Services.AddScoped<ERPCore.Services.OwnerReport.IOwnerReportService, ERPCore.Services.OwnerReport.OwnerReportService>();
if (string.Equals(builder.Configuration["Messaging:Provider"], "Log", StringComparison.OrdinalIgnoreCase))
builder.Services.AddScoped<ERPCore.Infra.Messaging.IMessageSender, ERPCore.Infra.Messaging.LogOnlyMessageSender>();
else
builder.Services.AddScoped<ERPCore.Infra.Messaging.IMessageSender, ERPCore.Infra.Messaging.AuthHexMessageSender>();
builder.Services.AddHostedService<ERPCore.Services.OwnerReport.OwnerReportJob>();
builder.Services.AddScoped<IOpeningBalanceService, OpeningBalanceService>();
builder.Services.AddScoped<IOpeningStockService, OpeningStockService>();
builder.Services.AddScoped<IGlAccountConfigService, GlAccountConfigService>();
@@ -0,0 +1,193 @@
using System.Globalization;
using System.Net;
using System.Text;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Reports;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services.OwnerReport;
/// <summary>The day-end report rendered for each channel.</summary>
public sealed record OwnerReportContent(DateOnly Date, string SmsText, string EmailSubject, string EmailHtml, string EmailText);
/// <summary>
/// Turns one day's <see cref="DayEndReportDto"/> into the owner SMS (short, ASCII so it stays a
/// cheap GSM message) and email (full detail) — docs/DAY_END_OWNER_REPORT_PLAN.md §1 / §3.3.
/// Figures come straight from <see cref="IDayEndReportService"/>, so they always match the
/// Day-End Report screen for the same day.
/// </summary>
public sealed class OwnerReportContentBuilder
{
public const string AppName = "Hexa ERP";
private const int TopSales = 10;
private static readonly CultureInfo Inv = CultureInfo.InvariantCulture;
private readonly IDayEndReportService _report;
private readonly IRepository<Branch> _branches;
private readonly IRepository<BranchConfig> _branchConfig;
private readonly IConfiguration _configuration;
public OwnerReportContentBuilder(
IDayEndReportService report, IRepository<Branch> branches, IRepository<BranchConfig> branchConfig, IConfiguration configuration)
{
_report = report;
_branches = branches;
_branchConfig = branchConfig;
_configuration = configuration;
}
public async Task<OwnerReportContent> BuildAsync(DateOnly date, bool includeBranchBreakdown, CancellationToken ct)
{
// branchId null = every branch. Inside the background job there is no signed-in user, so
// ICurrentBranchContext is inactive and this really is the company-wide total.
var day = await _report.GetDayAsync(date, null, ct);
var branchRows = new List<(string Name, DayEndReportDayDto Summary)>();
var branchingOn = await _branchConfig.Query().AsNoTracking()
.Where(c => c.ConfigId == BranchConfig.SingletonId).Select(c => c.BranchingEnabled).FirstOrDefaultAsync(ct);
if (includeBranchBreakdown && branchingOn)
{
var branches = await _branches.Query().AsNoTracking()
.Where(b => b.Status == EntityStatus.Active).OrderBy(b => b.Name).Select(b => new { b.BranchId, b.Name }).ToListAsync(ct);
if (branches.Count > 1)
foreach (var b in branches)
branchRows.Add((b.Name, (await _report.GetDayAsync(date, b.BranchId, ct)).Summary));
}
var s = day.Summary;
var subject = $"Day-end report - {date.ToString("dd MMM yyyy", Inv)} - Sales LKR {Amt(s.TotalSales)}";
return new OwnerReportContent(date, BuildSms(date, s), subject, BuildHtml(date, day, branchRows), BuildText(date, day));
}
// ---- SMS ------------------------------------------------------------------
private static string BuildSms(DateOnly date, DayEndReportDayDto s)
{
var sb = new StringBuilder();
sb.Append(AppName).Append(" - Day end ").Append(date.ToString("dd/MM/yyyy", Inv)).Append('\n');
sb.Append("Sales: LKR ").Append(Amt(s.TotalSales)).Append(" (").Append(s.SalesCount).Append(")\n");
sb.Append("Paid: ").Append(Amt(s.PaidAmount)).Append(" | Due: ").Append(Amt(s.DueAmount)).Append('\n');
sb.Append("Cash: ").Append(Amt(s.PaidByCash)).Append(" | Card: ").Append(Amt(s.PaidByCard)).Append('\n');
sb.Append("Expenses: ").Append(Amt(s.ExpenseTotal)).Append('\n');
sb.Append(ProfitLabel(s.NetProfit)).Append(": ").Append(Amt(Math.Abs(s.NetProfit))).Append('\n');
sb.Append("Cash in hand: ").Append(Amt(s.ClosingCashInHand));
return sb.ToString();
}
// ---- Email ------------------------------------------------------------------
private string BuildHtml(DateOnly date, DayEndReportDto day, List<(string Name, DayEndReportDayDto Summary)> branches)
{
var s = day.Summary;
var h = new StringBuilder();
h.Append("<div style=\"font-family:Arial,Helvetica,sans-serif;color:#1f2937;max-width:680px;margin:0 auto\">");
h.Append($"<h2 style=\"margin:0 0 4px\">Day-end report</h2>");
h.Append($"<p style=\"margin:0 0 16px;color:#6b7280\">{E(date.ToString("dddd, dd MMMM yyyy", Inv))} &middot; all branches</p>");
Section(h, "Summary", new (string, string)[]
{
("Total sales", $"LKR {Amt(s.TotalSales)} ({s.SalesCount} sales)"),
("Paid", Amt(s.PaidAmount)),
("Due", Amt(s.DueAmount)),
("Paid by cash", Amt(s.PaidByCash)),
("Paid by card", Amt(s.PaidByCard)),
("Paid by other (transfer / cheque)", Amt(s.PaidByOther)),
("Expenses", Amt(s.ExpenseTotal)),
("Income (sales - expenses)", Signed(s.Income)),
("Cost of goods sold", Amt(s.CostOfGoodsSold)),
("Gross profit", Signed(s.GrossProfit)),
(ProfitLabel(s.NetProfit), Amt(Math.Abs(s.NetProfit))),
}, emphasiseLast: true, lastIsLoss: s.NetProfit < 0);
Section(h, "Cash", new (string, string)[]
{
("Opening cash in hand", Amt(s.OpeningCashInHand)),
("Net cash today", Signed(s.NetCash)),
("Deposited to bank", Amt(s.CashDeposited)),
("Closing cash in hand", Amt(s.ClosingCashInHand)),
("Collected on earlier invoices", Amt(s.EarlierInvoiceCollections)),
}, emphasiseLast: false, lastIsLoss: false);
if (branches.Count > 0)
Table(h, "By branch", new[] { "Branch", "Sales", "Paid", "Expenses", "Net profit" }, firstNumericColumn: 1,
branches.Select(x => new[] { x.Name, Amt(x.Summary.TotalSales), Amt(x.Summary.PaidAmount), Amt(x.Summary.ExpenseTotal), Signed(x.Summary.NetProfit) }));
var top = day.Sales.OrderByDescending(x => x.Total).Take(TopSales).ToList();
if (top.Count > 0)
Table(h, day.Sales.Count > TopSales ? $"Top {TopSales} sales (of {day.Sales.Count})" : "Sales",
new[] { "No.", "Customer", "Paid by", "Total" }, firstNumericColumn: 3,
top.Select(x => new[] { x.DocNo, x.CustomerName, x.PaidBy, Amt(x.Total) }));
if (day.Expenses.Count > 0)
Table(h, "Expenses", new[] { "No.", "Category / title", "Amount" }, firstNumericColumn: 2,
day.Expenses.Select(x => new[] { x.DocNo, $"{x.CategoryName} / {x.TitleName}", Amt(x.Amount) }));
var link = ReportLink(date);
if (link is not null)
h.Append($"<p style=\"margin:20px 0 0\"><a href=\"{E(link)}\" style=\"color:#e11d48\">Open the Day-End Report</a></p>");
h.Append($"<p style=\"margin:20px 0 0;font-size:12px;color:#9ca3af\">Sent automatically by {AppName}.</p>");
h.Append("</div>");
return h.ToString();
}
private string BuildText(DateOnly date, DayEndReportDto day)
{
var text = new StringBuilder(BuildSms(date, day.Summary));
var link = ReportLink(date);
if (link is not null) text.Append("\n\nOpen the Day-End Report: ").Append(link);
text.Append("\n\nSent automatically by ").Append(AppName).Append('.');
return text.ToString();
}
/// <summary>Link back to the report screen, when OwnerReport:AppUrl is configured (e.g. https://erp.example.com).</summary>
private string? ReportLink(DateOnly date)
{
var baseUrl = _configuration["OwnerReport:AppUrl"]?.TrimEnd('/');
return string.IsNullOrWhiteSpace(baseUrl) ? null : $"{baseUrl}/dashboard/sales/day-end-report?date={date:yyyy-MM-dd}";
}
private static void Section(StringBuilder h, string title, (string Label, string Value)[] rows, bool emphasiseLast, bool lastIsLoss)
{
h.Append(SectionTitle(title));
h.Append("<table style=\"width:100%;border-collapse:collapse;font-size:14px\">");
for (var i = 0; i < rows.Length; i++)
{
var last = i == rows.Length - 1 && emphasiseLast;
var colour = last ? (lastIsLoss ? "#dc2626" : "#16a34a") : "#1f2937";
var weight = last ? "bold" : "normal";
h.Append($"<tr><td style=\"padding:6px 8px;border-bottom:1px solid #f3f4f6;color:#4b5563\">{E(rows[i].Label)}</td>");
h.Append($"<td style=\"padding:6px 8px;border-bottom:1px solid #f3f4f6;text-align:right;font-weight:{weight};color:{colour}\">{E(rows[i].Value)}</td></tr>");
}
h.Append("</table>");
}
private static string SectionTitle(string title)
=> $"<h3 style=\"margin:20px 0 6px;font-size:15px;color:#111827\">{E(title)}</h3>";
/// <summary>A titled table; columns from <paramref name="firstNumericColumn"/> on are right-aligned amounts. Cells are HTML-encoded here.</summary>
private static void Table(StringBuilder h, string title, string[] headers, int firstNumericColumn, IEnumerable<string[]> rows)
{
string Align(int i) => i >= firstNumericColumn ? "right" : "left";
h.Append(SectionTitle(title));
h.Append("<table style=\"width:100%;border-collapse:collapse;font-size:13px\"><tr>");
for (var i = 0; i < headers.Length; i++)
h.Append($"<th style=\"padding:6px 8px;background:#f9fafb;border-bottom:1px solid #e5e7eb;text-align:{Align(i)}\">{E(headers[i])}</th>");
h.Append("</tr>");
foreach (var cells in rows)
{
h.Append("<tr>");
for (var i = 0; i < cells.Length; i++)
h.Append($"<td style=\"padding:6px 8px;border-bottom:1px solid #f3f4f6;text-align:{Align(i)}\">{E(cells[i])}</td>");
h.Append("</tr>");
}
h.Append("</table>");
}
private static string ProfitLabel(decimal net) => net < 0 ? "Net loss" : "Net profit";
private static string Amt(decimal v) => v.ToString("#,##0.00", Inv);
private static string Signed(decimal v) => (v < 0 ? "-" : "") + Amt(Math.Abs(v));
private static string E(string v) => WebUtility.HtmlEncode(v);
}
@@ -0,0 +1,49 @@
namespace ERPCore.Services.OwnerReport;
/// <summary>
/// Sends the day-end owner report once its configured time has passed
/// (docs/DAY_END_OWNER_REPORT_PLAN.md §3.4). Wakes every minute; all the "is it time / already
/// sent / retry" logic lives in <see cref="IOwnerReportService.RunScheduledAsync"/>.
/// Never lets an exception escape — a failed tick is logged and the next one tries again.
/// </summary>
public sealed class OwnerReportJob : BackgroundService
{
private static readonly TimeSpan Interval = TimeSpan.FromMinutes(1);
private readonly IServiceScopeFactory _scopes;
private readonly ILogger<OwnerReportJob> _logger;
public OwnerReportJob(IServiceScopeFactory scopes, ILogger<OwnerReportJob> logger)
{
_scopes = scopes;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Let startup (migrations, seeding) finish before the first tick.
try { await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken); }
catch (OperationCanceledException) { return; }
using var timer = new PeriodicTimer(Interval);
do
{
try
{
// Fresh scope per tick: its own DbContext, and no HTTP user (so the day-end
// figures are company-wide, not limited to someone's branch).
using var scope = _scopes.CreateScope();
await scope.ServiceProvider.GetRequiredService<IOwnerReportService>().RunScheduledAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
}
catch (Exception ex)
{
_logger.LogError(ex, "Owner report tick failed; will retry on the next tick.");
}
}
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false));
}
}
@@ -0,0 +1,360 @@
using System.Text.RegularExpressions;
using ERPCore.Common.Http;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Config;
using ERPCore.Dtos.Warehouses;
using ERPCore.Infra.Auth;
using ERPCore.Infra.Messaging;
using ERPCore.Infra.Persistence;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services.OwnerReport;
public interface IOwnerReportService
{
Task<ETagged<OwnerReportConfigDto>> GetAsync(CancellationToken ct = default);
Task<ETagged<OwnerReportConfigDto>> UpdateAsync(UpdateOwnerReportConfigRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task<OwnerReportPreviewDto> PreviewAsync(DateOnly? date, CancellationToken ct = default);
Task<IReadOnlyList<OwnerReportSendResultDto>> SendTestAsync(DateOnly? date, CancellationToken ct = default);
Task<PagedResponse<OwnerReportDispatchDto>> ListDispatchesAsync(PageQuery query, CancellationToken ct = default);
/// <summary>One scheduler tick (OwnerReportJob): sends today's report once its time has come.</summary>
Task RunScheduledAsync(CancellationToken ct = default);
}
/// <summary>
/// Day-end owner report: settings, preview, test sends, the send log and the scheduled daily
/// send (docs/DAY_END_OWNER_REPORT_PLAN.md). Messages go out through <see cref="IMessageSender"/>
/// (AuthHex), so this service never handles SMTP or SMS credentials.
/// </summary>
public sealed class OwnerReportService : IOwnerReportService
{
private const int MaxRecipients = 5;
private const int MaxAttempts = 3;
private static readonly TimeSpan RetryDelay = TimeSpan.FromMinutes(5);
private static readonly Regex SriLankanPhone = new(PhoneRules.Pattern, RegexOptions.Compiled);
private readonly ErpDbContext _db;
private readonly OwnerReportContentBuilder _content;
private readonly IMessageSender _sender;
private readonly BusinessDayClock _clock;
private readonly ICurrentUser _currentUser;
private readonly ILogger<OwnerReportService> _logger;
public OwnerReportService(
ErpDbContext db, OwnerReportContentBuilder content, IMessageSender sender, BusinessDayClock clock,
ICurrentUser currentUser, ILogger<OwnerReportService> logger)
{
_db = db;
_content = content;
_sender = sender;
_clock = clock;
_currentUser = currentUser;
_logger = logger;
}
// ---- Settings ------------------------------------------------------------------
public async Task<ETagged<OwnerReportConfigDto>> GetAsync(CancellationToken ct = default)
{
var config = await LoadConfigAsync(asNoTracking: true, ct);
var recipients = await _db.OwnerReportRecipients.AsNoTracking().OrderBy(r => r.RecipientId).ToListAsync(ct);
return new ETagged<OwnerReportConfigDto>(Map(config, recipients), config.RowVersion);
}
public async Task<ETagged<OwnerReportConfigDto>> UpdateAsync(UpdateOwnerReportConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
// Validate everything before touching the rows.
if (request.Recipients.Count > MaxRecipients)
throw Invalid($"At most {MaxRecipients} recipients.");
var cleaned = request.Recipients.Select(CleanRecipient).ToList();
var config = await LoadConfigAsync(asNoTracking: false, ct);
if (config.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The owner report settings were changed by someone else — reload and try again.", 412);
config.Enabled = request.Enabled!.Value;
config.SmsEnabled = request.SmsEnabled!.Value;
config.EmailEnabled = request.EmailEnabled!.Value;
config.IncludeBranchBreakdown = request.IncludeBranchBreakdown!.Value;
config.SendAtLocalTime = TimeOnly.ParseExact(request.SendAtLocalTime, "HH:mm");
config.UpdatedAt = DateTime.UtcNow;
config.UpdatedBy = _currentUser.AuditUserId;
if (config.Enabled && !cleaned.Any(r => r.IsActive))
throw Invalid("Add at least one active recipient before switching the report on.");
if (config.Enabled && !config.SmsEnabled && !config.EmailEnabled)
throw Invalid("Switch on SMS, email or both before switching the report on.");
// Replace the recipient list: update kept rows, add new ones, remove the rest.
var existing = await _db.OwnerReportRecipients.ToListAsync(ct);
var keptIds = cleaned.Where(r => r.RecipientId is not null).Select(r => r.RecipientId!.Value).ToHashSet();
_db.OwnerReportRecipients.RemoveRange(existing.Where(e => !keptIds.Contains(e.RecipientId)));
foreach (var input in cleaned)
{
var row = input.RecipientId is int id ? existing.FirstOrDefault(e => e.RecipientId == id) : null;
if (input.RecipientId is not null && row is null)
throw Invalid($"Recipient {input.RecipientId} no longer exists — reload and try again.");
row ??= _db.OwnerReportRecipients.Add(new OwnerReportRecipient()).Entity;
row.Name = input.Name;
row.Mobile = input.Mobile;
row.Email = input.Email;
row.IsActive = input.IsActive;
}
try
{
await _db.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The owner report settings were changed by someone else — reload and try again.", 412);
}
return await GetAsync(ct);
}
// ---- Preview / test ---------------------------------------------------------------
public async Task<OwnerReportPreviewDto> PreviewAsync(DateOnly? date, CancellationToken ct = default)
{
var config = await LoadConfigAsync(asNoTracking: true, ct);
var content = await _content.BuildAsync(date ?? _clock.Today, config.IncludeBranchBreakdown, ct);
return new OwnerReportPreviewDto(content.Date, content.SmsText, content.EmailSubject, content.EmailHtml);
}
public async Task<IReadOnlyList<OwnerReportSendResultDto>> SendTestAsync(DateOnly? date, CancellationToken ct = default)
{
var config = await LoadConfigAsync(asNoTracking: true, ct);
if (!config.SmsEnabled && !config.EmailEnabled)
throw Invalid("Switch on SMS, email or both to send a test.");
var recipients = await _db.OwnerReportRecipients.AsNoTracking().Where(r => r.IsActive).ToListAsync(ct);
if (recipients.Count == 0)
throw Invalid("Add at least one active recipient and save before sending a test.");
var day = date ?? _clock.Today;
var content = await _content.BuildAsync(day, config.IncludeBranchBreakdown, ct);
// Tests are logged like real sends but flagged, with a unique key so they never collide
// with (or stand in for) the day's automatic send.
var batch = Guid.NewGuid().ToString("N");
var rows = Targets(config, recipients)
.Select(t => NewDispatch(day, t.Channel, t.Recipient, t.Destination, isTest: true, dedupeKey: batch))
.ToList();
_db.OwnerReportDispatches.AddRange(rows);
await _db.SaveChangesAsync(ct);
await SendAsync(rows, content, ct);
return rows.Select(r => new OwnerReportSendResultDto(
r.Channel.ToString(), r.RecipientName, r.Destination, r.Status == OwnerReportDispatchStatus.Sent, r.Error)).ToList();
}
public async Task<PagedResponse<OwnerReportDispatchDto>> ListDispatchesAsync(PageQuery query, CancellationToken ct = default)
{
var q = _db.OwnerReportDispatches.AsNoTracking();
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(d => d.DispatchId).Skip(query.Skip).Take(query.PageSize)
.Select(d => new OwnerReportDispatchDto(
d.DispatchId, d.BusinessDate, d.Channel.ToString(), d.RecipientName, d.Destination,
d.Status.ToString(), d.Attempts, d.Error, d.IsTest, d.CreatedAt, d.SentAt))
.ToListAsync(ct);
return PagedResponse<OwnerReportDispatchDto>.Create(rows, query.Page, query.PageSize, total);
}
// ---- Scheduled send ----------------------------------------------------------------
public async Task RunScheduledAsync(CancellationToken ct = default)
{
var config = await _db.OwnerReportConfig.AsNoTracking().FirstOrDefaultAsync(c => c.ConfigId == OwnerReportConfig.SingletonId, ct);
if (config is null || !config.Enabled || (!config.SmsEnabled && !config.EmailEnabled)) return;
var nowLocal = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, _clock.Zone);
if (TimeOnly.FromDateTime(nowLocal) < config.SendAtLocalTime) return;
// Only today: a day missed while the server was down is sent once it is back, the same
// day — older days are never sent automatically (plan R8).
var today = DateOnly.FromDateTime(nowLocal);
await EnsureTodaysDispatchesAsync(config, today, ct);
var now = DateTime.UtcNow;
var due = await _db.OwnerReportDispatches
.Where(d => d.BusinessDate == today && !d.IsTest && d.Attempts < MaxAttempts
&& (d.Status == OwnerReportDispatchStatus.Pending || d.Status == OwnerReportDispatchStatus.Failed)
&& (d.NextAttemptAt == null || d.NextAttemptAt <= now))
.ToListAsync(ct);
if (due.Count == 0) return;
// Claim each row before sending (attempt counter as an optimistic lock), so a second
// server instance running the same tick skips it instead of sending it again.
var claimed = new List<OwnerReportDispatch>();
foreach (var d in due)
{
var updated = await _db.OwnerReportDispatches
.Where(x => x.DispatchId == d.DispatchId && x.Attempts == d.Attempts)
.ExecuteUpdateAsync(s => s
.SetProperty(x => x.Attempts, x => x.Attempts + 1)
.SetProperty(x => x.NextAttemptAt, now.Add(RetryDelay)), ct);
if (updated == 1)
{
d.Attempts += 1;
d.NextAttemptAt = now.Add(RetryDelay);
claimed.Add(d);
}
}
if (claimed.Count == 0) return;
var content = await _content.BuildAsync(today, config.IncludeBranchBreakdown, ct);
await SendAsync(claimed, content, ct);
_logger.LogInformation("Owner report for {Date}: {Sent} sent, {Failed} failed.",
today, claimed.Count(d => d.Status == OwnerReportDispatchStatus.Sent), claimed.Count(d => d.Status == OwnerReportDispatchStatus.Failed));
}
/// <summary>Creates today's Pending row for each active recipient × enabled channel that doesn't have one yet.</summary>
private async Task EnsureTodaysDispatchesAsync(OwnerReportConfig config, DateOnly today, CancellationToken ct)
{
var recipients = await _db.OwnerReportRecipients.AsNoTracking().Where(r => r.IsActive).ToListAsync(ct);
var existing = (await _db.OwnerReportDispatches.AsNoTracking()
.Where(d => d.BusinessDate == today && !d.IsTest)
.Select(d => new { d.Channel, d.RecipientId })
.ToListAsync(ct))
.Select(d => (d.Channel, d.RecipientId))
.ToHashSet();
var missing = Targets(config, recipients)
.Where(t => !existing.Contains((t.Channel, (int?)t.Recipient.RecipientId)))
.Select(t => NewDispatch(today, t.Channel, t.Recipient, t.Destination, isTest: false, dedupeKey: string.Empty))
.ToList();
if (missing.Count == 0) return;
_db.OwnerReportDispatches.AddRange(missing);
try
{
await _db.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
// Another instance created them first (unique index) — theirs stand.
_db.ChangeTracker.Clear();
}
}
// ---- Sending -----------------------------------------------------------------------
/// <summary>Sends <paramref name="rows"/> (grouped per channel) and records each outcome.</summary>
private async Task SendAsync(List<OwnerReportDispatch> rows, OwnerReportContent content, CancellationToken ct)
{
foreach (var group in rows.GroupBy(r => r.Channel))
{
var destinations = group.Select(r => r.Destination).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
IReadOnlyList<SendResult> results;
try
{
results = group.Key == OwnerReportChannel.Email
? await _sender.SendEmailAsync(destinations, content.EmailSubject, content.EmailHtml, content.EmailText, ct)
: await _sender.SendSmsAsync(destinations, content.SmsText, ct);
}
catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested)
{
// The whole call failed (e.g. AuthHex unreachable) — every row of this channel failed.
_logger.LogWarning(ex, "Owner report {Channel} send failed.", group.Key);
results = destinations.Select(d => new SendResult(d, false, ex.Message)).ToList();
}
foreach (var row in group)
{
var result = results.FirstOrDefault(r => string.Equals(r.To, row.Destination, StringComparison.OrdinalIgnoreCase));
if (result is { Ok: true })
{
row.Status = OwnerReportDispatchStatus.Sent;
row.SentAt = DateTime.UtcNow;
row.Error = null;
row.NextAttemptAt = null;
}
else
{
row.Status = OwnerReportDispatchStatus.Failed;
row.Error = Truncate(result?.Error ?? "No result returned for this recipient.", 1000);
}
}
}
// Rows may be tracked (test) or detached copies (scheduled) — attach updates either way.
foreach (var row in rows)
{
var entry = _db.Entry(row);
if (entry.State == EntityState.Detached) _db.OwnerReportDispatches.Attach(row);
entry = _db.Entry(row);
entry.Property(r => r.Status).IsModified = true;
entry.Property(r => r.SentAt).IsModified = true;
entry.Property(r => r.Error).IsModified = true;
entry.Property(r => r.NextAttemptAt).IsModified = true;
}
await _db.SaveChangesAsync(ct);
}
private static IEnumerable<(OwnerReportChannel Channel, OwnerReportRecipient Recipient, string Destination)> Targets(
OwnerReportConfig config, IEnumerable<OwnerReportRecipient> recipients)
{
foreach (var r in recipients)
{
if (config.SmsEnabled && !string.IsNullOrWhiteSpace(r.Mobile)) yield return (OwnerReportChannel.Sms, r, r.Mobile!);
if (config.EmailEnabled && !string.IsNullOrWhiteSpace(r.Email)) yield return (OwnerReportChannel.Email, r, r.Email!);
}
}
private static OwnerReportDispatch NewDispatch(
DateOnly day, OwnerReportChannel channel, OwnerReportRecipient recipient, string destination, bool isTest, string dedupeKey)
=> new()
{
BusinessDate = day,
Channel = channel,
RecipientId = recipient.RecipientId,
RecipientName = recipient.Name,
Destination = destination,
Status = OwnerReportDispatchStatus.Pending,
Attempts = isTest ? 1 : 0,
IsTest = isTest,
DedupeKey = dedupeKey,
CreatedAt = DateTime.UtcNow,
};
// ---- Helpers ------------------------------------------------------------------------
private async Task<OwnerReportConfig> LoadConfigAsync(bool asNoTracking, CancellationToken ct)
{
var q = asNoTracking ? _db.OwnerReportConfig.AsNoTracking() : _db.OwnerReportConfig;
return await q.FirstOrDefaultAsync(c => c.ConfigId == OwnerReportConfig.SingletonId, ct)
?? throw new NotFoundException("Owner report settings have not been seeded.");
}
private OwnerReportConfigDto Map(OwnerReportConfig c, IEnumerable<OwnerReportRecipient> recipients) => new(
c.Enabled, c.SmsEnabled, c.EmailEnabled, c.SendAtLocalTime.ToString("HH:mm"), c.IncludeBranchBreakdown,
recipients.Select(r => new OwnerReportRecipientDto(r.RecipientId, r.Name, r.Mobile, r.Email, r.IsActive)).ToList(),
_clock.Zone.Id, c.UpdatedAt, c.UpdatedBy);
/// <summary>Trims, validates and normalises one recipient (mobile → +94XXXXXXXXX, whatever local/94/+94 form was typed).</summary>
private static OwnerReportRecipientInput CleanRecipient(OwnerReportRecipientInput input)
{
var name = input.Name.Trim();
var mobile = string.IsNullOrWhiteSpace(input.Mobile) ? null : input.Mobile.Trim();
var email = string.IsNullOrWhiteSpace(input.Email) ? null : input.Email.Trim();
if (name.Length == 0) throw Invalid("Every recipient needs a name.");
if (mobile is null && email is null) throw Invalid($"{name}: add a mobile number, an email or both.");
if (mobile is not null)
{
if (!SriLankanPhone.IsMatch(mobile)) throw Invalid($"{name}: {PhoneRules.Message}");
var digits = Regex.Replace(mobile, @"\D", "");
mobile = "+94" + (digits.StartsWith("94") ? digits[2..] : digits[1..]);
}
return new OwnerReportRecipientInput { RecipientId = input.RecipientId, Name = name, Mobile = mobile, Email = email?.ToLowerInvariant(), IsActive = input.IsActive };
}
private static DomainException Invalid(string message) => new(ErrorCodes.Validation, message, 422);
private static string Truncate(string value, int max) => value.Length <= max ? value : value[..max];
}
+9
View File
@@ -661,3 +661,12 @@ Spec: `docs/30-BACKEND-PHASE2.md` (model + rules **and** API — one doc, unlike
- **Fix in code:** `GlAccountRole.AccruedExpenses` + `gl_account_config.AccruedExpensesCode` (migration `AddAccruedExpensesGlRole`, **not applied**, fills existing config with `2300`; fresh databases seed it from the old appsettings key). `ExpenseService` / `ExpensePaymentService` read the mapped role, so it is edited, validated (must exist and be postable) and checked at startup like every other GL role, on Settings -> GL Accounts.
- **GL data (done through the GL's own API, not SQL):** created `5200 Operating Expenses` (header) with `5200-01` Utility, `5200-02` Rental, `5200-03` Salaries and Wages, `5200-04` Other Expenses, and `2300 Accrued Expenses`.
- **Still true for a brand-new GL:** the chart of accounts itself is created in the GL service, not in the ERP; the ERP only maps roles to existing accounts and now tells you which are missing.
### 2026-10-19 — Day-end owner report (SMS + email to the owner)
- **What:** every evening (default 21:00, `DayEndReport:TimeZone`) the day's figures from `DayEndReportService.GetDayAsync` go to the owner — short SMS + full HTML email (summary, cash, per-branch, top 10 sales, expenses). Plan: `docs/DAY_END_OWNER_REPORT_PLAN.md`.
- **Data:** `owner_report_config` (singleton, seeded off), `owner_report_recipients` (≤ 5; mobile normalised to `+94XXXXXXXXX`, Sri Lankan `PhoneRules`), `owner_report_dispatches` (send log + "send once" guard: unique `(BusinessDate, Channel, RecipientId, IsTest, DedupeKey)`). Migration `AddOwnerReport` (additive; generated in MariaDB mode).
- **Sending:** `IMessageSender` → `AuthHexMessageSender` → AuthHex `POST /api/notify/email|sms` (new, admin-key gated) — **SMTP/SMS credentials stay in AuthHex**. `Messaging:Provider = Log` swaps in `LogOnlyMessageSender`. Optional `OwnerReport:AppUrl` adds a link back to the Day-End Report.
- **Schedule:** `OwnerReportJob` (`BackgroundService`, 1-min tick) → `OwnerReportService.RunScheduledAsync`: creates today's rows once the time passes, claims each row by bumping `Attempts` (multi-instance safe), retries failures up to 3× every 5 min; only today is ever sent automatically.
- **API:** `GET/PUT /api/v1/settings/owner-report` (ETag), `GET .../preview?date`, `POST .../test` (1/min), `GET .../dispatches` — `settings` nav policy, like the other config screens.
- **AuthHex (`ERP_Auth_Service`):** `NotifyController`, `ThirdPartyService.SendCustomEmailAsync` / `SendTextSmsAsync` (OTP methods untouched), `SmtpSettings:SenderName` → "Hexa ERP".
- **Verified locally (MySQL 8, `Messaging:Provider=Log`):** migration + seed applied; with a test recipient and send time 00:00 the job sent 1 SMS + 1 email, and later ticks sent nothing more. Real AuthHex delivery **not yet tested** (AuthHex must be redeployed with `/api/notify`).