Sms & Email
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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))} · 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];
|
||||
}
|
||||
@@ -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`).
|
||||
|
||||
@@ -407,3 +407,8 @@ Spec: `docs/21-FRONTEND-PHASE2.md` (flows/screens) · contract: `docs/30-BACKEND
|
||||
5. **Inline "add variant category":** a "+" icon button next to the checkboxes opens an inline name field that calls `variantCategoriesApi.create()` directly from `/new`, appends the result to the in-memory list, and auto-checks it — so a brand-new dimension (e.g. "Material") can be added without leaving the Item form, and it also then appears on `/dashboard/products/variants`.
|
||||
6. **Color gets a real color picker:** for whichever checked category is literally named "Color" (case-insensitive), the free-text input is replaced with a native `<input type="color">` swatch picker *plus* a required "Color name" text field — picking red alone isn't enough, a name is mandatory too. The pair is encoded as a single string `"<name>|<hex>"` in `valuesByCategory` (helpers `encodeColorValue`/`decodeColorValue`/`partLabel` in `new/page.tsx`) so the existing generic value-list plumbing didn't need a parallel data shape; every place that displays or SKU-generates from a color value decodes it back to just the name (the hex only ever drives the swatch dot next to chips and table cells) — so SKUs read `HAR-CRI` (from "Crimson"), never `HAR-EF4`.
|
||||
- Verified: `tsc --noEmit` clean after every step (same pre-existing `login/page.tsx` error only, confirmed unchanged throughout). Each change was driven end-to-end through a headless Playwright session against the dev server and screenshotted — field removal, subcategory always-visible + disabled state, checkbox show/hide of category builders, cartesian flat table with 2+ active categories, inline category creation followed by its builder appearing immediately, and the color picker + name → chip swatch → table swatch → final SKU/item name chain — with `console --errors` clean at every step and at least one full create-and-redirect-to-Items-list confirmed per major change.
|
||||
|
||||
### 2026-10-19 — Settings → Configuration → Day-End Owner Report
|
||||
- New page `app/dashboard/settings/owner-report` (`lib/api/owner-report.ts`, `types/owner-report.ts`), card on Settings → Configuration, header + breadcrumb titles.
|
||||
- Sections: Delivery (master switch, SMS, Email, branch breakdown, send time), Recipients (≤ 5; name / mobile with the Sri Lankan phone rule + input limiter / email; active switch), Preview (SMS bubble + sandboxed email iframe for any day), Send test now (confirm dialog; blocked while there are unsaved changes), Send log (cards on phones, table on desktop, pagination). Draft + sticky Save/Discard bar; every validation error also toasts.
|
||||
- `tsc` + `eslint` clean for the new files; not yet clicked through in a browser.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Link from "next/link"
|
||||
import { BookOpen, Building2, ClipboardList, LayoutTemplate, MapPin, PackageCheck, ShieldCheck, ShoppingCart, SlidersHorizontal } from "lucide-react"
|
||||
import { BookOpen, Building2, ClipboardList, LayoutTemplate, MapPin, PackageCheck, Send, ShieldCheck, ShoppingCart, SlidersHorizontal } from "lucide-react"
|
||||
|
||||
const cards = [
|
||||
{
|
||||
@@ -56,6 +56,12 @@ const cards = [
|
||||
icon: LayoutTemplate,
|
||||
description: "Logo, footer, terms, and template style for printed invoices",
|
||||
},
|
||||
{
|
||||
title: "Day-End Owner Report",
|
||||
href: "/dashboard/settings/owner-report",
|
||||
icon: Send,
|
||||
description: "Send the day's revenue to the owner by SMS and email",
|
||||
},
|
||||
]
|
||||
|
||||
export default function ConfigurationPage() {
|
||||
|
||||
@@ -0,0 +1,524 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Clock, Eye, Mail, MessageSquare, Plus, Send, Trash2, Users } from "lucide-react"
|
||||
|
||||
import { ownerReportApi } from "@/lib/api/owner-report"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { limitPhoneInput, phoneError } from "@/lib/validations/phone"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { OwnerReportConfig, OwnerReportDispatch, OwnerReportPreview, OwnerReportRecipientInput } from "@/types/owner-report"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { FieldError } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
const MAX_RECIPIENTS = 5
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
const PANEL = "flex flex-col gap-4 rounded-2xl border bg-card p-4 shadow-sm sm:p-5"
|
||||
|
||||
/** Editable copy of the settings — saved together with one PUT (the recipients are a list). */
|
||||
type Draft = {
|
||||
enabled: boolean
|
||||
smsEnabled: boolean
|
||||
emailEnabled: boolean
|
||||
sendAtLocalTime: string
|
||||
includeBranchBreakdown: boolean
|
||||
recipients: (OwnerReportRecipientInput & { key: number })[]
|
||||
}
|
||||
|
||||
function toDraft(c: OwnerReportConfig): Draft {
|
||||
return {
|
||||
enabled: c.enabled,
|
||||
smsEnabled: c.smsEnabled,
|
||||
emailEnabled: c.emailEnabled,
|
||||
sendAtLocalTime: c.sendAtLocalTime,
|
||||
includeBranchBreakdown: c.includeBranchBreakdown,
|
||||
recipients: c.recipients.map((r) => ({ ...r, key: r.recipientId })),
|
||||
}
|
||||
}
|
||||
|
||||
function localToday(): string {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
/** Per-recipient field errors, keyed `${key}-name|mobile|email`. */
|
||||
function validate(draft: Draft): Record<string, string> {
|
||||
const errors: Record<string, string> = {}
|
||||
for (const r of draft.recipients) {
|
||||
if (!r.name.trim()) errors[`${r.key}-name`] = "Enter a name"
|
||||
const mobile = r.mobile?.trim() ?? ""
|
||||
const email = r.email?.trim() ?? ""
|
||||
if (!mobile && !email) errors[`${r.key}-mobile`] = "Add a mobile number, an email or both"
|
||||
const pe = mobile ? phoneError(mobile) : null
|
||||
if (pe) errors[`${r.key}-mobile`] = pe
|
||||
if (email && !EMAIL_PATTERN.test(email)) errors[`${r.key}-email`] = "Enter a valid email address"
|
||||
}
|
||||
if (draft.enabled && !draft.recipients.some((r) => r.isActive)) errors.recipients = "Add at least one active recipient before switching the report on"
|
||||
if (draft.enabled && !draft.smsEnabled && !draft.emailEnabled) errors.channels = "Switch on SMS, email or both"
|
||||
return errors
|
||||
}
|
||||
|
||||
/**
|
||||
* Day-End Owner Report — sends the day's revenue and details to the owner by SMS and email at a
|
||||
* set time (docs/DAY_END_OWNER_REPORT_PLAN.md). Messages go out through the auth service's own
|
||||
* SMTP / SMS settings, so nothing here holds mail credentials.
|
||||
*/
|
||||
export default function OwnerReportSettingsPage() {
|
||||
const [config, setConfig] = useState<OwnerReportConfig | null>(null)
|
||||
const [etag, setEtag] = useState<string | null>(null)
|
||||
const [draft, setDraft] = useState<Draft | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [nextKey, setNextKey] = useState(-1)
|
||||
|
||||
const [previewDate, setPreviewDate] = useState(localToday())
|
||||
const [preview, setPreview] = useState<OwnerReportPreview | null>(null)
|
||||
const [previewLoading, setPreviewLoading] = useState(false)
|
||||
|
||||
const [testOpen, setTestOpen] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
|
||||
const [log, setLog] = useState<OwnerReportDispatch[] | null>(null)
|
||||
const [logPagination, setLogPagination] = useState<PaginationMeta | null>(null)
|
||||
const [logPage, setLogPage] = useState(1)
|
||||
const [logPageSize, setLogPageSize] = useState(10)
|
||||
const [logReload, setLogReload] = useState(0)
|
||||
|
||||
function load() {
|
||||
ownerReportApi
|
||||
.get()
|
||||
.then((res) => {
|
||||
setConfig(res.data)
|
||||
setEtag(res.etag)
|
||||
setDraft(toDraft(res.data))
|
||||
setErrors({})
|
||||
})
|
||||
.catch((err) => {
|
||||
setLoadError(errorMessage(err))
|
||||
toast.error("Could not load the owner report settings", errorMessage(err))
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(load, [])
|
||||
|
||||
useEffect(() => {
|
||||
ownerReportApi
|
||||
.dispatches({ page: logPage, pageSize: logPageSize })
|
||||
.then((res) => {
|
||||
setLog(res.items)
|
||||
setLogPagination(res.pagination)
|
||||
})
|
||||
.catch((err) => {
|
||||
setLog([])
|
||||
toast.error("Could not load the send log", errorMessage(err))
|
||||
})
|
||||
}, [logPage, logPageSize, logReload])
|
||||
|
||||
const dirty = config !== null && draft !== null && JSON.stringify(toDraft(config)) !== JSON.stringify(draft)
|
||||
|
||||
function patch(next: Partial<Draft>) {
|
||||
setDraft((d) => (d ? { ...d, ...next } : d))
|
||||
}
|
||||
|
||||
function patchRecipient(key: number, next: Partial<OwnerReportRecipientInput>) {
|
||||
setDraft((d) => (d ? { ...d, recipients: d.recipients.map((r) => (r.key === key ? { ...r, ...next } : r)) } : d))
|
||||
setErrors((e) => {
|
||||
const copy = { ...e }
|
||||
for (const field of Object.keys(next)) delete copy[`${key}-${field}`]
|
||||
delete copy.recipients
|
||||
return copy
|
||||
})
|
||||
}
|
||||
|
||||
function addRecipient() {
|
||||
if (!draft || draft.recipients.length >= MAX_RECIPIENTS) return
|
||||
patch({ recipients: [...draft.recipients, { key: nextKey, recipientId: null, name: draft.recipients.length === 0 ? "Owner" : "", mobile: "", email: "", isActive: true }] })
|
||||
setNextKey((k) => k - 1)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!draft) return
|
||||
const nextErrors = validate(draft)
|
||||
setErrors(nextErrors)
|
||||
const messages = [...new Set(Object.values(nextErrors))]
|
||||
if (messages.length > 0) {
|
||||
toast.error("Please fix the highlighted fields", messages.length === 1 ? messages[0] : `${messages[0]} (and ${messages.length - 1} more)`)
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await ownerReportApi.update(
|
||||
{
|
||||
enabled: draft.enabled,
|
||||
smsEnabled: draft.smsEnabled,
|
||||
emailEnabled: draft.emailEnabled,
|
||||
sendAtLocalTime: draft.sendAtLocalTime,
|
||||
includeBranchBreakdown: draft.includeBranchBreakdown,
|
||||
recipients: draft.recipients.map((r) => ({
|
||||
recipientId: r.recipientId,
|
||||
name: r.name.trim(),
|
||||
mobile: r.mobile?.trim() || null,
|
||||
email: r.email?.trim() || null,
|
||||
isActive: r.isActive,
|
||||
})),
|
||||
},
|
||||
etag ?? "",
|
||||
)
|
||||
setConfig(res.data)
|
||||
setEtag(res.etag)
|
||||
setDraft(toDraft(res.data))
|
||||
toast.success("Owner report saved", res.data.enabled ? `It will be sent daily at ${res.data.sendAtLocalTime}.` : "The daily report is switched off.")
|
||||
} catch (err) {
|
||||
toast.error("Could not save the owner report settings", errorMessage(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPreview() {
|
||||
setPreviewLoading(true)
|
||||
try {
|
||||
setPreview(await ownerReportApi.preview(previewDate))
|
||||
} catch (err) {
|
||||
toast.error("Could not build the preview", errorMessage(err))
|
||||
} finally {
|
||||
setPreviewLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function sendTest() {
|
||||
setTesting(true)
|
||||
try {
|
||||
const results = await ownerReportApi.sendTest(previewDate)
|
||||
const failed = results.filter((r) => !r.ok)
|
||||
if (failed.length === 0) toast.success("Test sent", `${results.length} message${results.length === 1 ? "" : "s"} sent.`)
|
||||
else toast.error(`${failed.length} of ${results.length} messages failed`, failed.map((f) => `${f.destination}: ${f.error ?? "failed"}`).join(" · "))
|
||||
setTestOpen(false)
|
||||
setLogPage(1)
|
||||
setLogReload((n) => n + 1)
|
||||
} catch (err) {
|
||||
toast.error("Could not send the test", errorMessage(err))
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 pb-24 sm:gap-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-foreground sm:text-2xl">Day-End Owner Report</h1>
|
||||
<p className="text-sm text-muted-foreground sm:text-base">
|
||||
Send the day's revenue and details to the owner by SMS and email, automatically every evening.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loadError && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">{loadError}</div>}
|
||||
{!loadError && !draft && <Skeleton className="h-64 w-full rounded-2xl" />}
|
||||
|
||||
{draft && config && (
|
||||
<>
|
||||
{/* Delivery */}
|
||||
<section className={PANEL}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="size-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold text-foreground">Delivery</h2>
|
||||
</div>
|
||||
|
||||
<ToggleRow
|
||||
checked={draft.enabled}
|
||||
onChange={(v) => patch({ enabled: v })}
|
||||
label="Send the daily report"
|
||||
help="Off: nothing is sent automatically (you can still send a test)."
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<ToggleRow checked={draft.smsEnabled} onChange={(v) => patch({ smsEnabled: v })} label="SMS" help="A short revenue summary." icon={MessageSquare} />
|
||||
<ToggleRow checked={draft.emailEnabled} onChange={(v) => patch({ emailEnabled: v })} label="Email" help="The full details: summary, cash, top sales and expenses." icon={Mail} />
|
||||
</div>
|
||||
{errors.channels && <FieldError errors={[{ message: errors.channels }]} />}
|
||||
<ToggleRow
|
||||
checked={draft.includeBranchBreakdown}
|
||||
onChange={(v) => patch({ includeBranchBreakdown: v })}
|
||||
label="Include a per-branch breakdown"
|
||||
help="Adds one row per branch to the email when branches are switched on."
|
||||
/>
|
||||
<div className="flex flex-col gap-2 sm:max-w-xs">
|
||||
<Label htmlFor="or-time" className="text-base">Send time</Label>
|
||||
<Input
|
||||
id="or-time"
|
||||
type="time"
|
||||
value={draft.sendAtLocalTime}
|
||||
onChange={(e) => patch({ sendAtLocalTime: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Time zone: {config.timeZone}. If the server is down then, it sends as soon as it is back the same day.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Recipients */}
|
||||
<section className={PANEL}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="size-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold text-foreground">Recipients</h2>
|
||||
<span className="text-sm text-muted-foreground">({draft.recipients.length}/{MAX_RECIPIENTS})</span>
|
||||
</div>
|
||||
<Button type="button" variant="outline" className="h-10" onClick={addRecipient} disabled={draft.recipients.length >= MAX_RECIPIENTS}>
|
||||
<Plus className="size-4" />
|
||||
Add recipient
|
||||
</Button>
|
||||
</div>
|
||||
{errors.recipients && <FieldError errors={[{ message: errors.recipients }]} />}
|
||||
|
||||
{draft.recipients.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
No recipients yet — add the owner's mobile number and email.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-3">
|
||||
{draft.recipients.map((r) => (
|
||||
<li key={r.key} className={cn("grid grid-cols-1 gap-3 rounded-xl border p-3 sm:grid-cols-[1fr_1fr_1.4fr_auto] sm:items-start", !r.isActive && "opacity-60")}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-xs text-muted-foreground">Name</Label>
|
||||
<Input value={r.name} maxLength={100} onChange={(e) => patchRecipient(r.key, { name: e.target.value })} placeholder="Owner" aria-invalid={!!errors[`${r.key}-name`]} className="h-11 text-base sm:h-10" />
|
||||
<FieldError errors={[errors[`${r.key}-name`] ? { message: errors[`${r.key}-name`] } : undefined]} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-xs text-muted-foreground">Mobile (SMS)</Label>
|
||||
<Input
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
maxLength={20}
|
||||
value={r.mobile ?? ""}
|
||||
onChange={(e) => patchRecipient(r.key, { mobile: limitPhoneInput(e.target.value) })}
|
||||
placeholder="0771234567"
|
||||
aria-invalid={!!errors[`${r.key}-mobile`]}
|
||||
className="h-11 text-base sm:h-10"
|
||||
/>
|
||||
<FieldError errors={[errors[`${r.key}-mobile`] ? { message: errors[`${r.key}-mobile`] } : undefined]} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-xs text-muted-foreground">Email</Label>
|
||||
<Input
|
||||
type="email"
|
||||
inputMode="email"
|
||||
maxLength={200}
|
||||
value={r.email ?? ""}
|
||||
onChange={(e) => patchRecipient(r.key, { email: e.target.value })}
|
||||
placeholder="owner@company.com"
|
||||
aria-invalid={!!errors[`${r.key}-email`]}
|
||||
className="h-11 text-base sm:h-10"
|
||||
/>
|
||||
<FieldError errors={[errors[`${r.key}-email`] ? { message: errors[`${r.key}-email`] } : undefined]} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 sm:flex-col sm:items-end sm:pt-5">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Switch checked={r.isActive} onCheckedChange={(v) => patchRecipient(r.key, { isActive: v })} aria-label={`${r.name || "Recipient"} active`} />
|
||||
Active
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => patch({ recipients: draft.recipients.filter((x) => x.key !== r.key) })}
|
||||
aria-label={`Remove ${r.name || "recipient"}`}
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Preview & test */}
|
||||
<section className={PANEL}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Eye className="size-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold text-foreground">Preview & test</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 items-end gap-2 sm:flex sm:flex-wrap sm:gap-3">
|
||||
<div className="col-span-2 flex flex-col gap-1 sm:col-span-1">
|
||||
<Label htmlFor="or-date" className="text-xs text-muted-foreground">Day</Label>
|
||||
<Input id="or-date" type="date" value={previewDate} max={localToday()} onChange={(e) => setPreviewDate(e.target.value)} className="h-11 text-base sm:h-10 sm:w-44" />
|
||||
</div>
|
||||
<Button type="button" variant="outline" className="h-11 sm:h-10" onClick={loadPreview} disabled={previewLoading}>
|
||||
<Eye className="size-4" />
|
||||
{previewLoading ? "Building…" : "Preview"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="h-11 sm:h-10"
|
||||
onClick={() => {
|
||||
if (dirty) {
|
||||
toast.error("Save your changes first", "The test is sent to the saved recipients.")
|
||||
return
|
||||
}
|
||||
setTestOpen(true)
|
||||
}}
|
||||
>
|
||||
<Send className="size-4" />
|
||||
Send test now
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{preview && (
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,18rem)_1fr]">
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium text-foreground">SMS</p>
|
||||
<div className="rounded-2xl rounded-tl-sm bg-muted px-4 py-3 font-mono text-sm leading-relaxed whitespace-pre-wrap">{preview.smsText}</div>
|
||||
<p className="text-xs text-muted-foreground">{preview.smsText.length} characters</p>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<p className="text-sm font-medium text-foreground">Email — {preview.emailSubject}</p>
|
||||
{/* Sandboxed: the HTML is our own template, but it never gets scripts or same-origin access. */}
|
||||
<iframe title="Email preview" sandbox="" srcDoc={preview.emailHtml} className="h-112 w-full rounded-xl border bg-white" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Send log */}
|
||||
<section className={PANEL}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Send className="size-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold text-foreground">Send log</h2>
|
||||
</div>
|
||||
{log === null ? (
|
||||
<Skeleton className="h-24 w-full" />
|
||||
) : log.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">Nothing sent yet.</p>
|
||||
) : (
|
||||
<>
|
||||
<ul className="flex flex-col gap-2 md:hidden">
|
||||
{log.map((d) => (
|
||||
<li key={d.dispatchId} className="flex flex-col gap-1 rounded-lg border p-3 text-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">{d.businessDate} · {d.channel === "Sms" ? "SMS" : "Email"}</span>
|
||||
<StatusBadge status={d.status} isTest={d.isTest} />
|
||||
</div>
|
||||
<span className="truncate text-muted-foreground">{d.recipientName} — {d.destination}</span>
|
||||
{d.error && <span className="text-xs text-destructive">{d.error}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="hidden overflow-x-auto md:block">
|
||||
<Table className="text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Day</TableHead>
|
||||
<TableHead>Channel</TableHead>
|
||||
<TableHead>Recipient</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Sent at</TableHead>
|
||||
<TableHead>Error</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{log.map((d) => (
|
||||
<TableRow key={d.dispatchId}>
|
||||
<TableCell>{d.businessDate}</TableCell>
|
||||
<TableCell>{d.channel === "Sms" ? "SMS" : "Email"}</TableCell>
|
||||
<TableCell>
|
||||
{d.recipientName}
|
||||
<span className="block text-xs text-muted-foreground">{d.destination}</span>
|
||||
</TableCell>
|
||||
<TableCell><StatusBadge status={d.status} isTest={d.isTest} /></TableCell>
|
||||
<TableCell>{d.sentAt ? new Date(d.sentAt + (d.sentAt.endsWith("Z") ? "" : "Z")).toLocaleString() : "—"}</TableCell>
|
||||
<TableCell className="max-w-64 truncate text-destructive" title={d.error ?? undefined}>{d.error ?? ""}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{logPagination && logPagination.totalItems > 10 && (
|
||||
<PaginationFooter
|
||||
pagination={logPagination}
|
||||
onPageChange={setLogPage}
|
||||
pageSizeOptions={[10, 25, 50]}
|
||||
onPageSizeChange={(size) => {
|
||||
setLogPageSize(size)
|
||||
setLogPage(1)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Save bar — sticky so it stays reachable on a long page / phone. */}
|
||||
<div className="sticky bottom-0 z-10 -mx-6 -mb-6 flex flex-col-reverse gap-3 border-t bg-background/95 px-6 py-4 backdrop-blur sm:flex-row sm:items-center sm:justify-end">
|
||||
{dirty && <p className="text-center text-sm text-warning sm:mr-auto sm:text-left">You have unsaved changes.</p>}
|
||||
<Button type="button" variant="outline" size="lg" className="w-full sm:w-auto" disabled={!dirty || saving} onClick={() => { setDraft(toDraft(config)); setErrors({}) }}>
|
||||
Discard
|
||||
</Button>
|
||||
<Button type="button" size="lg" className="w-full sm:w-auto sm:min-w-40" disabled={!dirty || saving} onClick={save}>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Dialog open={testOpen} onOpenChange={(open) => !testing && setTestOpen(open)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Send a test now?</DialogTitle>
|
||||
<DialogDescription>
|
||||
The report for {previewDate} will be sent right away to every active recipient
|
||||
{config ? ` (${[config.smsEnabled && "SMS", config.emailEnabled && "email"].filter(Boolean).join(" + ")})` : ""}. It is marked as a test in the send log.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" className="h-11 sm:h-9" onClick={() => setTestOpen(false)} disabled={testing}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="h-11 sm:h-9" onClick={sendTest} disabled={testing}>
|
||||
{testing ? "Sending…" : "Send test"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ToggleRow({
|
||||
checked, onChange, label, help, icon: Icon,
|
||||
}: { checked: boolean; onChange: (v: boolean) => void; label: string; help: string; icon?: typeof Mail }) {
|
||||
return (
|
||||
<label className="flex cursor-pointer items-start gap-3 rounded-xl border p-3 hover:bg-muted/30">
|
||||
<Switch checked={checked} onCheckedChange={onChange} className="mt-0.5 shrink-0" aria-label={label} />
|
||||
<span className="flex flex-col gap-0.5">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
{Icon && <Icon className="size-4 text-muted-foreground" />}
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{help}</span>
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ status, isTest }: { status: OwnerReportDispatch["status"]; isTest: boolean }) {
|
||||
const tone =
|
||||
status === "Sent" ? "border-success/30 bg-success/10 text-success" : status === "Failed" ? "border-destructive/30 bg-destructive/10 text-destructive" : "border-warning/30 bg-warning/10 text-warning"
|
||||
return (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Badge variant="outline" className={cn("text-xs", tone)}>{status}</Badge>
|
||||
{isTest && <Badge variant="outline" className="text-xs">Test</Badge>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -45,6 +45,7 @@ const SEGMENT_LABELS: Record<string, string> = {
|
||||
runs: "Runs",
|
||||
invoices: "Sales",
|
||||
"day-end": "Approved Invoices",
|
||||
"owner-report": "Day-End Owner Report",
|
||||
}
|
||||
|
||||
function labelFor(segment: string): string {
|
||||
|
||||
@@ -110,6 +110,7 @@ function titleFromPath(pathname: string) {
|
||||
if (pathname === "/dashboard/sales/warranties") return "Warranties"
|
||||
if (pathname === "/dashboard/sales/day-end-report") return "Day-End Report"
|
||||
if (pathname === "/dashboard/sales/day-end") return "Approved Invoices"
|
||||
if (pathname === "/dashboard/settings/owner-report") return "Day-End Owner Report"
|
||||
if (pathname === "/dashboard/hrm") return "HRM"
|
||||
|
||||
if (pathname === "/dashboard/receiving/grn") return "Goods Received Notes"
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// Day-end owner report settings: recipients, schedule, preview, test send and send log
|
||||
// (docs/DAY_END_OWNER_REPORT_PLAN.md §3.5). Singleton settings — no id; saves need the ETag.
|
||||
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
|
||||
import { ApiResult, PagedResponse } from "@/types/common"
|
||||
import {
|
||||
OwnerReportConfig,
|
||||
OwnerReportDispatch,
|
||||
OwnerReportPreview,
|
||||
OwnerReportSendResult,
|
||||
UpdateOwnerReportConfigRequest,
|
||||
} from "@/types/owner-report"
|
||||
|
||||
const BASE = "/settings/owner-report"
|
||||
|
||||
export const ownerReportApi = {
|
||||
get(): Promise<ApiResult<OwnerReportConfig>> {
|
||||
return apiRequestWithETag<OwnerReportConfig>(BASE)
|
||||
},
|
||||
|
||||
update(request: UpdateOwnerReportConfigRequest, ifMatch: string): Promise<ApiResult<OwnerReportConfig>> {
|
||||
return apiRequestWithETag<OwnerReportConfig>(BASE, { method: "PUT", body: request, ifMatch })
|
||||
},
|
||||
|
||||
/** The report as it would be sent for `date` (yyyy-MM-dd; default today) — nothing is sent. */
|
||||
preview(date?: string): Promise<OwnerReportPreview> {
|
||||
return apiRequest<OwnerReportPreview>(`${BASE}/preview${buildQuery({ date })}`)
|
||||
},
|
||||
|
||||
/** Sends the report now to every active recipient (logged as a test). One per minute. */
|
||||
sendTest(date?: string): Promise<OwnerReportSendResult[]> {
|
||||
return apiRequest<OwnerReportSendResult[]>(`${BASE}/test`, { method: "POST", body: { date: date || null } })
|
||||
},
|
||||
|
||||
dispatches(params: { page?: number; pageSize?: number } = {}): Promise<PagedResponse<OwnerReportDispatch>> {
|
||||
return apiRequest<PagedResponse<OwnerReportDispatch>>(`${BASE}/dispatches${buildQuery(params)}`)
|
||||
},
|
||||
}
|
||||
@@ -1,17 +1,18 @@
|
||||
/**
|
||||
* Sri Lankan phone number rule shared by master-data forms (warehouses, …). Mirrors the
|
||||
* backend's `PhoneRules.Pattern` so the client rejects exactly what the API would.
|
||||
* Sri Lankan phone number rule shared by master-data forms (warehouses, owner report, …). Mirrors
|
||||
* the backend's `PhoneRules.Pattern` so the client rejects exactly what the API would.
|
||||
*
|
||||
* Accepted: 10 digits starting with 0 (0771234567, 011 234 5678) or +94 followed by 9 digits
|
||||
* (+94771234567, +94 77 123 4567). Spaces and hyphens are allowed between digits.
|
||||
* Accepted: 10 digits starting with 0 (0771234567, 011 234 5678), +94 followed by 9 digits
|
||||
* (+94771234567, +94 77 123 4567), or 94 followed by 9 digits without the "+" (94771234567 — the
|
||||
* SMS gateway's own format). Spaces and hyphens are allowed between digits.
|
||||
*/
|
||||
export const PHONE_PATTERN = /^(?:0(?:[\s-]*\d){9}|\+94(?:[\s-]*\d){9})[\s-]*$/
|
||||
export const PHONE_PATTERN = /^(?:0(?:[\s-]*\d){9}|\+?94(?:[\s-]*\d){9})[\s-]*$/
|
||||
|
||||
/** Digit count allowed after the prefix's own digits, per format. */
|
||||
const LOCAL_DIGITS = 10 // 0 + 9
|
||||
const INTL_DIGITS = 11 // 94 + 9
|
||||
|
||||
const PHONE_EXAMPLE = "e.g. 0771234567 or +94 77 123 4567"
|
||||
const PHONE_EXAMPLE = "e.g. 0771234567, 94771234567 or +94 77 123 4567"
|
||||
|
||||
/**
|
||||
* Input filter for a phone field's onChange: drops disallowed characters and stops accepting
|
||||
@@ -19,7 +20,7 @@ const PHONE_EXAMPLE = "e.g. 0771234567 or +94 77 123 4567"
|
||||
*/
|
||||
export function limitPhoneInput(raw: string): string {
|
||||
let out = ""
|
||||
let digits = 0
|
||||
let digits = ""
|
||||
for (const ch of raw) {
|
||||
if (ch === "+") {
|
||||
if (out.length === 0) out += ch // "+" only as the very first character
|
||||
@@ -30,10 +31,11 @@ export function limitPhoneInput(raw: string): string {
|
||||
continue
|
||||
}
|
||||
if (!/\d/.test(ch)) continue
|
||||
const max = out.startsWith("+") ? INTL_DIGITS : LOCAL_DIGITS
|
||||
if (digits >= max) continue
|
||||
// International (+94… or 94…) numbers carry two more digits than a local 0… number.
|
||||
const max = out.startsWith("+") || digits.startsWith("94") || (digits === "9" && ch === "4") ? INTL_DIGITS : LOCAL_DIGITS
|
||||
if (digits.length >= max) continue
|
||||
out += ch
|
||||
digits += 1
|
||||
digits += ch
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -44,11 +46,11 @@ export function phoneError(value: string, { required = false }: { required?: boo
|
||||
if (!phone) return required ? "Enter a phone number" : null
|
||||
if (/[^\d\s\-+]/.test(phone)) return "Phone number can only contain digits, spaces, - and a leading +"
|
||||
const digits = phone.replace(/\D/g, "")
|
||||
if (phone.startsWith("+")) {
|
||||
if (phone.startsWith("+") || digits.startsWith("94")) {
|
||||
if (!digits.startsWith("94")) return `International numbers must start with +94 — ${PHONE_EXAMPLE}`
|
||||
if (digits.length !== INTL_DIGITS) return `+94 numbers need exactly 9 digits after +94 (${digits.length - 2} entered)`
|
||||
if (digits.length !== INTL_DIGITS) return `94 numbers need exactly 9 digits after 94 (${digits.length - 2} entered)`
|
||||
} else {
|
||||
if (!digits.startsWith("0")) return `Phone number must start with 0 or +94 — ${PHONE_EXAMPLE}`
|
||||
if (!digits.startsWith("0")) return `Phone number must start with 0, 94 or +94 — ${PHONE_EXAMPLE}`
|
||||
if (digits.length !== LOCAL_DIGITS) return `Phone number must be exactly 10 digits (${digits.length} entered)`
|
||||
}
|
||||
if (!PHONE_PATTERN.test(phone)) return `Enter a valid phone number, ${PHONE_EXAMPLE}`
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/** Day-end owner report (docs/DAY_END_OWNER_REPORT_PLAN.md). */
|
||||
export interface OwnerReportRecipient {
|
||||
recipientId: number
|
||||
name: string
|
||||
/** Stored as +94XXXXXXXXX. */
|
||||
mobile: string | null
|
||||
email: string | null
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export interface OwnerReportConfig {
|
||||
enabled: boolean
|
||||
smsEnabled: boolean
|
||||
emailEnabled: boolean
|
||||
/** "HH:mm" in `timeZone`. */
|
||||
sendAtLocalTime: string
|
||||
includeBranchBreakdown: boolean
|
||||
recipients: OwnerReportRecipient[]
|
||||
timeZone: string
|
||||
updatedAt: string | null
|
||||
updatedBy: number | null
|
||||
}
|
||||
|
||||
export interface OwnerReportRecipientInput {
|
||||
/** Null for a new recipient. */
|
||||
recipientId: number | null
|
||||
name: string
|
||||
mobile: string | null
|
||||
email: string | null
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export interface UpdateOwnerReportConfigRequest {
|
||||
enabled: boolean
|
||||
smsEnabled: boolean
|
||||
emailEnabled: boolean
|
||||
sendAtLocalTime: string
|
||||
includeBranchBreakdown: boolean
|
||||
recipients: OwnerReportRecipientInput[]
|
||||
}
|
||||
|
||||
export interface OwnerReportPreview {
|
||||
date: string
|
||||
smsText: string
|
||||
emailSubject: string
|
||||
emailHtml: string
|
||||
}
|
||||
|
||||
export interface OwnerReportSendResult {
|
||||
channel: "Sms" | "Email"
|
||||
recipientName: string
|
||||
destination: string
|
||||
ok: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface OwnerReportDispatch {
|
||||
dispatchId: number
|
||||
businessDate: string
|
||||
channel: "Sms" | "Email"
|
||||
recipientName: string
|
||||
destination: string
|
||||
status: "Pending" | "Sent" | "Failed"
|
||||
attempts: number
|
||||
error: string | null
|
||||
isTest: boolean
|
||||
createdAt: string
|
||||
sentAt: string | null
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
# Day-End Owner Report — Implementation Plan
|
||||
|
||||
At the end of each business day, send the owner a summary of the day's revenue and key details
|
||||
by **SMS** and **email**. The owner's mobile number(s) and email address(es) are set in
|
||||
**Settings → Configuration**.
|
||||
|
||||
> **Naming:** this builds on the existing read-only **Day-End Sales Report**
|
||||
> (`docs/DAY_END_SALES_REPORT_PLAN.md`, permission `sales.day-end-report`). It does not change the
|
||||
> cashier close-out `SalesDayEnd` (shown as "Approved Invoices"). Access: the existing `settings`
|
||||
> nav permission, like the other configuration screens.
|
||||
>
|
||||
> **Status (2026-10-19): implemented** — phases 1–5 (PDF attachment, phase 6, not done). Deviations
|
||||
> from the design below: mobile numbers are stored as `+94XXXXXXXXX` (AuthHex converts to the gateway's `94…` form), one
|
||||
> `IMessageSender` replaces separate SMS/email senders, and the API uses the `settings` policy.
|
||||
|
||||
---
|
||||
|
||||
## 1. Requirements
|
||||
|
||||
| # | Requirement |
|
||||
|---|---|
|
||||
| R1 | Once per business day, at a configured time (default **21:00 Asia/Colombo**), send the day's summary to the owner. |
|
||||
| R2 | Owner contacts are configured in Settings: **one or more mobile numbers** and **one or more email addresses**. |
|
||||
| R3 | Each channel (SMS, email) can be switched on/off independently; the whole feature has a master on/off. |
|
||||
| R4 | SMS carries a short revenue summary; email carries the full details (summary + breakdowns). |
|
||||
| R5 | A **"Send test now"** button sends today's report immediately to the configured contacts. |
|
||||
| R6 | Every send attempt is logged (date, channel, recipient, success/failure, error) and visible in Settings. |
|
||||
| R7 | A day is sent at most once per channel per recipient, even if the server restarts or runs on several instances. |
|
||||
| R8 | If the server was down at send time, the missed day is sent when it comes back (same day only — no backfill of older days). |
|
||||
| R9 | Branch-aware: one company-wide summary, plus a per-branch breakdown when branching is enabled. |
|
||||
|
||||
### Message content
|
||||
|
||||
**SMS** (≤ 2 SMS segments, ~300 chars, English, plain text):
|
||||
|
||||
```
|
||||
Hexa ERP – Day end 19/10/2026
|
||||
Sales: LKR 125,400.00 (42)
|
||||
Paid: 98,200.00 | Due: 27,200.00
|
||||
Cash: 61,000.00 | Card: 37,200.00
|
||||
Expenses: 8,500.00
|
||||
Net profit: 21,300.00
|
||||
Cash in hand: 54,300.00
|
||||
```
|
||||
|
||||
**Email** (HTML + plain-text alternative), subject `Day-end report — 19 Oct 2026 — Sales LKR 125,400.00`:
|
||||
|
||||
| Section | Content (all from `DayEndReportDto`) |
|
||||
|---|---|
|
||||
| Summary | Total sales, sales count, paid, due, paid by cash / card / other, expenses, income, COGS, gross profit, net profit (labelled **Loss** when negative) |
|
||||
| Cash | Opening cash in hand, net cash today, deposited to bank, closing cash in hand |
|
||||
| Collections | Collected on earlier invoices |
|
||||
| Per branch | One summary row per branch (when branching is on) |
|
||||
| Top sales | Largest 10 sales of the day (doc no., customer, total, paid by) |
|
||||
| Expenses | Every expense of the day (doc no., category · title, amount) |
|
||||
| Footer | Link to `/dashboard/sales/day-end-report?date=YYYY-MM-DD`; "Sent automatically by Hexa ERP" |
|
||||
|
||||
Optional (phase 2): attach the day's PDF (same layout as the Day-End Report's **Download PDF**).
|
||||
|
||||
---
|
||||
|
||||
## 2. Current state
|
||||
|
||||
| Area | What exists | Gap |
|
||||
|---|---|---|
|
||||
| Day figures | `DayEndReportService.GetDayAsync(date, branchId)` → `DayEndReportDto` (summary, sales, expenses, deposits) | Branch scoping reads `ICurrentBranchContext` (the HTTP user) — **no user in a background job** |
|
||||
| Business day | `BusinessDayClock` (`DayEndReport:TimeZone`, `Asia/Colombo`) | — |
|
||||
| Config pattern | Singleton config rows + service + `ETag`/`If-Match` (e.g. `SalesConfig`, `GrnConfig`, `NotificationConfig`) | — |
|
||||
| Settings UI | `app/dashboard/settings/configuration/page.tsx` (card per config screen) | No owner-report card / page |
|
||||
| Phone validation | `lib/validations/phone.ts` (Sri Lankan: `0XXXXXXXXX` / `+94XXXXXXXXX`), backend `PhoneRules` in `Dtos/Warehouses/WarehouseDtos.cs` | Move `PhoneRules` to a shared place |
|
||||
| In-app notifications | `NotificationService`, `NotificationConfig` (per-module in-app alerts) | In-app only — **no SMS or email sending anywhere** |
|
||||
| AuthHex (`D:\HexDive\ERP_Auth_Service`) | Owns the **SMTP** (`SmtpSettings` — Hostinger, port 587, SSL) and **SMS gateway** (`Sms` — Introps `Sms_common/send_sms`) credentials. `Utility/ThirdPartyService` already sends a custom-subject/HTML-body email (`EmailConfiguration`) and a custom-text SMS (`SendSmsAsync`), but only from its own OTP/recovery flows | **No endpoint** lets another service send a message — needs one (§3.2) |
|
||||
| ERPCore → AuthHex | `IAuthHexClient` with `AuthHex:BaseUrl`; admin calls send `AuthHex:AdminApiKey` (`AdminHeaders()`) | — |
|
||||
| Background jobs | None (no `BackgroundService`, Hangfire or Quartz) | **Need a scheduler** |
|
||||
|
||||
---
|
||||
|
||||
## 3. Design
|
||||
|
||||
### 3.1 Data model (migration `AddOwnerReportConfig`)
|
||||
|
||||
**`owner_report_config`** — singleton (`ConfigId = 1`, CHECK constraint like the other config rows):
|
||||
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `ConfigId` | int PK | always 1 |
|
||||
| `Enabled` | bool | master switch, default `false` |
|
||||
| `SmsEnabled` | bool | default `true` |
|
||||
| `EmailEnabled` | bool | default `true` |
|
||||
| `SendAtLocalTime` | time | default `21:00`, in `DayEndReport:TimeZone` |
|
||||
| `IncludeBranchBreakdown` | bool | default `true` |
|
||||
| `UpdatedAt`, `UpdatedBy`, `RowVersion` | | same as other configs |
|
||||
|
||||
**`owner_report_recipients`**:
|
||||
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `RecipientId` | int PK | |
|
||||
| `Name` | varchar(100) | e.g. "Owner", "Director" |
|
||||
| `Mobile` | varchar(20) null | normalised to `+94XXXXXXXXX`; validated with the shared Sri Lankan phone rule |
|
||||
| `Email` | varchar(200) null | validated email |
|
||||
| `IsActive` | bool | |
|
||||
|
||||
At least one of `Mobile` / `Email` required. Max **5** recipients.
|
||||
|
||||
**`owner_report_dispatch`** — the send log *and* the idempotency guard:
|
||||
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `DispatchId` | bigint PK | |
|
||||
| `BusinessDate` | date | |
|
||||
| `Channel` | enum `Sms` / `Email` | |
|
||||
| `RecipientId` | int FK | |
|
||||
| `Destination` | varchar(200) | number/email at send time (kept if the recipient changes later) |
|
||||
| `Status` | enum `Pending` / `Sent` / `Failed` | |
|
||||
| `Attempts` | int | |
|
||||
| `Error` | varchar(1000) null | provider error, truncated |
|
||||
| `ProviderMessageId` | varchar(100) null | |
|
||||
| `IsTest` | bool | "Send test now" sends |
|
||||
| `CreatedAt`, `SentAt` | datetime | |
|
||||
|
||||
Unique index `(BusinessDate, Channel, RecipientId, IsTest)` → R7: a second worker or a restart can't
|
||||
insert the same automatic send twice (`INSERT` wins once; the loser skips).
|
||||
|
||||
### 3.2 Sending channels — through AuthHex
|
||||
|
||||
**SMTP and SMS details come from AuthHex.** ERPCore does not hold any mail or SMS credentials; it
|
||||
asks AuthHex to send, and AuthHex uses the `SmtpSettings` / `Sms` configuration it already has.
|
||||
One place to rotate a password, one sender identity, nothing new to secure in ERPCore.
|
||||
|
||||
```
|
||||
OwnerReportJob ──► IEmailSender / ISmsSender (ERPCore)
|
||||
│ AuthHexEmailSender / AuthHexSmsSender
|
||||
▼ POST {AuthHex:BaseUrl}/api/notify/... + AdminApiKey header
|
||||
AuthHex NotifyController ──► ThirdPartyService ──► Hostinger SMTP / Introps SMS
|
||||
```
|
||||
|
||||
**AuthHex side (`ERP_Auth_Service`) — new, small:**
|
||||
|
||||
| Method | Route | Body | Uses |
|
||||
|---|---|---|---|
|
||||
| `POST` | `/api/notify/email` | `{ to: string[], subject, htmlBody, textBody? }` | `ThirdPartyService.EmailConfiguration(to, code: "", subject, htmlBody)` per recipient |
|
||||
| `POST` | `/api/notify/sms` | `{ to: string[], text }` | `ThirdPartyService.SendSmsAsync(to, otpCode: "-", message: text)` per recipient |
|
||||
|
||||
- Protected by the **same admin API key** ERPCore already sends for admin calls (`AuthHex:AdminApiKey`); no user session. Reject anything without it (401).
|
||||
- Response: per-recipient `{ to, ok, error }` in the usual AuthHex envelope.
|
||||
- Changes needed in `ThirdPartyService` (keep the OTP flows working):
|
||||
- `SendSmsAsync` rejects an empty `otpCode` even when a custom `message` is given → allow an empty code when `message` is set (or add a `SendTextAsync(mobile, text)` overload).
|
||||
- `SendEmailAsync` runs `body.Replace("{code}", code)` — harmless for the report, but add a `SendCustomEmailAsync(to, subject, html)` that skips OTP templating.
|
||||
- `SmtpSettings:SenderName` is currently **"Gym Os"** — set it to **"Hexa ERP"** so owner emails show the right sender (`From: Hexa ERP <sender address>`).
|
||||
- Uses `System.Net.Mail.SmtpClient`, 10 s timeout, SSL — fine for a few emails a day.
|
||||
- Limits: ≤ 10 recipients per call; email body ≤ 200 KB; SMS text ≤ 480 chars.
|
||||
|
||||
**ERPCore side (`Infra/Messaging/`):**
|
||||
|
||||
```csharp
|
||||
public interface ISmsSender { Task<IReadOnlyList<SendResult>> SendAsync(IReadOnlyList<string> toE164, string text, CancellationToken ct); }
|
||||
public interface IEmailSender { Task<IReadOnlyList<SendResult>> SendAsync(EmailMessage message, CancellationToken ct); }
|
||||
public sealed record SendResult(string To, bool Ok, string? Error);
|
||||
```
|
||||
|
||||
| Implementation | Used when |
|
||||
|---|---|
|
||||
| `AuthHexEmailSender`, `AuthHexSmsSender` — two new methods on `IAuthHexClient` (`SendEmailAsync`, `SendSmsAsync`) reusing its HTTP client, base URL and `AdminHeaders()` | `Messaging:Provider = AuthHex` (default in Development/Production) |
|
||||
| `LogOnlyEmailSender`, `LogOnlySmsSender` — write the message to the log instead of sending | `Messaging:Provider = Log` (local dev, tests, or while AuthHex lacks the endpoint) |
|
||||
|
||||
No new secrets in ERPCore. Each call has a 20 s timeout and no retries of its own — the job retries.
|
||||
If AuthHex is unreachable the dispatch row is marked `Failed` with the error and retried (§3.4).
|
||||
|
||||
### 3.3 Building the report (`OwnerReportService`)
|
||||
|
||||
- `BuildAsync(DateOnly date)` → `OwnerReportContent { SmsText, EmailSubject, EmailHtml, EmailText }`.
|
||||
- Calls `DayEndReportService.GetDayAsync(date, branchId: null)` for the company total, and once per active branch when `IncludeBranchBreakdown` and branching is on.
|
||||
- **Branch scoping fix:** add an explicit "system / all branches" path to `DayEndReportService` (e.g. an overload taking `bool allBranches`) instead of relying on `ICurrentBranchContext`, which has no user inside the job. Must not widen access for HTTP callers.
|
||||
- Amounts use the same formatting as the UI (`#,##0.00`, `LKR` prefix on the headline); a negative net profit is written as `Loss 1,200.00`.
|
||||
- Email HTML is a simple inline-styled table template (`Infra/Messaging/Templates/OwnerReport.html`) — no external CSS/images.
|
||||
|
||||
### 3.4 Scheduler (`OwnerReportJob : BackgroundService`)
|
||||
|
||||
- Wakes every **60 s** (`PeriodicTimer`).
|
||||
- If `Enabled` and local time (`BusinessDayClock`) ≥ `SendAtLocalTime` and today has no `Sent`/`Pending` dispatch rows → build once, then for each active recipient × enabled channel insert a `Pending` dispatch row (unique index guards duplicates) and send.
|
||||
- Failures: retry up to **3** times, 5 min apart (`Attempts`, `Status = Failed` after the last).
|
||||
- Missed send (server down at 21:00): picked up on the next tick the same day (R8). Days before today are never sent automatically.
|
||||
- Runs in its own DI scope per tick; any exception is logged and the loop continues — it must never crash the host.
|
||||
- Multi-instance safe through the unique index (no distributed lock needed).
|
||||
|
||||
### 3.5 API (`SettingsOwnerReportController`, policy `settings.owner-report`)
|
||||
|
||||
| Method | Route | Body / result |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/v1/settings/owner-report` | config + recipients (ETag) |
|
||||
| `PUT` | `/api/v1/settings/owner-report` | config + full recipients list, `If-Match` required |
|
||||
| `POST` | `/api/v1/settings/owner-report/test` | `{ date?: "YYYY-MM-DD" }` → sends now to all active recipients (`IsTest = true`); returns per-recipient results |
|
||||
| `GET` | `/api/v1/settings/owner-report/dispatches?from&to&page&pageSize` | send log, newest first |
|
||||
| `GET` | `/api/v1/settings/owner-report/preview?date=` | `{ smsText, emailSubject, emailHtml }` — renders without sending |
|
||||
|
||||
Validation (422): recipient needs mobile or email; mobile must pass the Sri Lankan phone rule; email
|
||||
must be valid; ≤ 5 recipients; `SendAtLocalTime` between 00:00 and 23:59. `POST /test` is rate-limited
|
||||
(1 per minute) so the button can't be used to spam the owner.
|
||||
|
||||
### 3.6 Frontend — `app/dashboard/settings/owner-report/page.tsx`
|
||||
|
||||
- New card on **Settings → Configuration**: *"Day-End Owner Report — Send the day's revenue to the owner by SMS and email"* (icon `Send`).
|
||||
- Page sections (mobile-first, same `StatCard`/card styles as the rest of the app):
|
||||
1. **Delivery** — master switch, SMS on/off, Email on/off, send time (time input), "include branch breakdown".
|
||||
2. **Recipients** — list of name / mobile / email rows with add / remove (max 5). Mobile uses `limitPhoneInput` + `phoneError` from `lib/validations/phone.ts`; email validated client-side too. All errors also shown as toasts.
|
||||
3. **Preview** — SMS text in a phone-style bubble, email rendered in a sandboxed iframe (`srcdoc`), date picker to preview another day.
|
||||
4. **Send test now** — confirm dialog, then per-recipient result toast.
|
||||
5. **Send log** — paginated table (cards on phones): date, channel, recipient, status badge, error tooltip.
|
||||
- Sidebar: no new entry (reached from Settings → Configuration), permission code `settings.owner-report` added to the role permission tree.
|
||||
|
||||
---
|
||||
|
||||
## 4. Security & privacy (02-SECURITY checklist items)
|
||||
|
||||
- SMTP and SMS credentials stay in AuthHex's configuration only — ERPCore never sees them. The
|
||||
new AuthHex `/api/notify/*` endpoints accept only the admin API key, so they can't be used as an
|
||||
open relay; the key itself stays in ERPCore's configuration/secrets, never in git or API responses.
|
||||
- Owner contact data visible only to `settings.owner-report`; the send log masks numbers (`+94 77 *** 4567`) for other viewers if it is ever exposed elsewhere.
|
||||
- SMS/email bodies contain financial figures — sent only to configured recipients, never to user-supplied addresses on the test endpoint.
|
||||
- Audit log entries for config changes (who changed recipients / switched it on).
|
||||
- The email footer link requires login — no figures are exposed through the link itself.
|
||||
|
||||
---
|
||||
|
||||
## 5. Phases & tracking
|
||||
|
||||
| Phase | Scope | Done when |
|
||||
|---|---|---|
|
||||
| 1 | Data model + migration, config API, Settings page (delivery + recipients), `LogOnly` senders | Config saves/loads with validation; test send writes to the log |
|
||||
| 2 | `OwnerReportService` content + preview endpoint/UI; branch-scoping fix in `DayEndReportService` | Preview matches the Day-End Report figures for the same day |
|
||||
| 3 | AuthHex: `POST /api/notify/email` + `ThirdPartyService` custom-email method + sender name; ERPCore: `AuthHexEmailSender`; send log UI | Test email arrives from the right sender with correct figures |
|
||||
| 4 | AuthHex: `POST /api/notify/sms` + empty-OTP fix; ERPCore: `AuthHexSmsSender` | Test SMS arrives, ≤ 2 segments |
|
||||
| 5 | `OwnerReportJob` scheduler, retries, idempotency | Sends once at the set time; restart and 2-instance tests don't double-send |
|
||||
| 6 (opt.) | PDF attachment | Email carries the day's PDF |
|
||||
|
||||
Record each phase in `Backend/PROGRESS.md` / `Frontend/PROGRESS.md` per `01-DOC-GUIDE.md`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Open questions
|
||||
|
||||
| # | Question | Default if not answered |
|
||||
|---|---|---|
|
||||
| Q1 | ~~Which SMS gateway?~~ **Decided:** AuthHex's existing gateway (Introps), via the new AuthHex endpoint. | — |
|
||||
| Q2 | ~~Which SMTP account?~~ **Decided:** AuthHex's `SmtpSettings` (Hostinger), sender name **"Hexa ERP"**. Still open: the sender *address* — today `tester@hexdive.com`. | Keep the current address until a dedicated one (e.g. `erp@hexdive.com`) exists |
|
||||
| Q2b | Is the AuthHex repo (`ERP_Auth_Service`) ours to change, or owned by another team who must add `/api/notify/*`? | We add it in the same release |
|
||||
| Q3 | One company-wide report, or a separate report per branch to each branch's manager? | Company-wide + per-branch breakdown section |
|
||||
| Q4 | Send time — fixed 21:00, or after the last cashier closes the day (Approved Invoices / `SalesDayEnd`)? | Fixed configurable time |
|
||||
| Q5 | Should a day with no sales still send ("No sales today")? | Yes — confirms the system ran |
|
||||
| Q6 | Languages — English only, or Sinhala/Tamil SMS too (Unicode halves SMS length)? | English only |
|
||||
Reference in New Issue
Block a user