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];
|
||||
}
|
||||
Reference in New Issue
Block a user