Move the GL account mapping out of appsettings into validated master data
The per-module Gl*Code appsettings keys pinned GL's account *codes* in ERPCore's config, while GL generates codes positionally and refuses a requested one. Nothing connected the two, so "1100 means Inventory" held only because someone once created accounts in a particular order - and on 2026-10-07 eleven of the twelve configured codes turned out never to have existed in GL. Every GRN, adjustment, day-end, invoice and FIFO posting had been failing with "Account <code> not found". The mapping is now master data in gl_account_config, keyed by accounting role rather than by module, and GlAccountConfigService validates every submitted code against GL's live chart of accounts before storing it. A code GL does not have, or cannot post to, is refused with the role named - so the mismatch is unrepresentable rather than merely detected. Correcting a mapping no longer needs a redeploy. Notes: - Fails closed on save (503 if GL is unreachable), deliberately opposite to the startup validator, which must still let the app serve. - Non-postable accounts are rejected too: GL rejects a journal line against one, so storing it only defers the failure. The auto-created 1000 Bank/Cash header is exactly that trap. - The settings endpoint also serves the selectable accounts, because GeneralLedgerController is gated on `ledgers` while this is on `settings` - and it keeps the picker and the validation list the same data. - Reading an unmapped role throws naming the role, instead of posting "" and letting GL return an opaque 404 from inside a transaction. - The appsettings keys are now seed-only: DataSeeder copies them in once so existing deployments keep their current mapping. No runtime fallback, or there would be two sources of truth again. - The 32 read sites across 11 services collapsed to 12 roles, since every module carried the same value for a given role. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
using ERPCore.Infra.Gl;
|
using ERPCore.Infra.Gl;
|
||||||
using ERPCore.Services.Gl;
|
using ERPCore.Services.Gl;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
using ERPCore.System.Errors;
|
|
||||||
using ERPCore.System.Time;
|
using ERPCore.System.Time;
|
||||||
|
using ERPCore.Tests.TestSupport;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
@@ -10,37 +10,34 @@ using Microsoft.Extensions.Logging.Abstractions;
|
|||||||
namespace ERPCore.Tests;
|
namespace ERPCore.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The validator's whole value is in *which* failures it treats as fatal: a missing account code
|
/// The validator is advisory: enforcement lives in <see cref="ERPCore.Services.GlAccountConfigService"/>,
|
||||||
/// must stop the app (the config is wrong), while an unreachable GL must not (GL shares the host
|
/// which refuses to store a bad mapping in the first place. So the behaviour that matters here is that
|
||||||
/// and may still be starting — hard-failing there turns that into a deploy crash-loop).
|
/// it <b>cannot abort startup</b>. An earlier revision threw on a missing code and core-dumped
|
||||||
|
/// erpcore.service on deploy (2026-10-07), turning a long-standing data mismatch into an outage.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class GlAccountCodeValidatorTests
|
public class GlAccountCodeValidatorTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task StartAsync_Succeeds_WhenEveryConfiguredCodeExistsInGl()
|
public async Task StartAsync_Succeeds_WhenEveryMappedCodeExistsInGl()
|
||||||
{
|
{
|
||||||
var validator = CreateValidator(
|
var validator = CreateValidator(
|
||||||
new() { ["Grn:GlInventoryAccountCode"] = "1100", ["Fifo:GlClearingAccountCode"] = "2000" },
|
new(),
|
||||||
new StubGl(["1100", "2000", "4000"]));
|
new StubGeneralLedgerService([
|
||||||
|
StubGeneralLedgerService.Postable("1100"),
|
||||||
|
StubGeneralLedgerService.Postable("2000")
|
||||||
|
]),
|
||||||
|
(GlAccountRole.Inventory, "1100"), (GlAccountRole.Clearing, "2000"));
|
||||||
|
|
||||||
await validator.StartAsync(CancellationToken.None);
|
await validator.StartAsync(CancellationToken.None);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The default must not block startup. Throwing here cost an outage on 2026-10-07 — a config
|
|
||||||
/// mismatch that had existed all along became a core-dump the moment the check shipped.
|
|
||||||
/// </summary>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task StartAsync_DoesNotThrow_WhenCodesAreMissing_ByDefault()
|
public async Task StartAsync_DoesNotThrow_WhenMappedCodesAreMissingFromGl()
|
||||||
{
|
{
|
||||||
var validator = CreateValidator(
|
var validator = CreateValidator(
|
||||||
new()
|
new(),
|
||||||
{
|
new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1000-01")]),
|
||||||
["Grn:GlInventoryAccountCode"] = "1100",
|
(GlAccountRole.Inventory, "1100"), (GlAccountRole.Cash, "1000-01"));
|
||||||
["SalesDayEnd:GlCashAccountCode"] = "1000-01",
|
|
||||||
["Adjustment:GlLossAccountCode"] = "5100"
|
|
||||||
},
|
|
||||||
new StubGl(["1000-01"]));
|
|
||||||
|
|
||||||
await validator.StartAsync(CancellationToken.None);
|
await validator.StartAsync(CancellationToken.None);
|
||||||
}
|
}
|
||||||
@@ -49,14 +46,11 @@ public class GlAccountCodeValidatorTests
|
|||||||
public async Task StartAsync_Throws_AndNamesEveryMissingCode_WhenConfiguredToFail()
|
public async Task StartAsync_Throws_AndNamesEveryMissingCode_WhenConfiguredToFail()
|
||||||
{
|
{
|
||||||
var validator = CreateValidator(
|
var validator = CreateValidator(
|
||||||
new()
|
new() { ["GeneralLedgerService:FailOnMissingAccountCodes"] = "true" },
|
||||||
{
|
new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]),
|
||||||
["GeneralLedgerService:FailOnMissingAccountCodes"] = "true",
|
(GlAccountRole.Inventory, "1100"),
|
||||||
["Grn:GlInventoryAccountCode"] = "1100",
|
(GlAccountRole.Cash, "1000-01"),
|
||||||
["SalesDayEnd:GlCashAccountCode"] = "1000-01",
|
(GlAccountRole.InventoryLoss, "5100"));
|
||||||
["Adjustment:GlLossAccountCode"] = "5100"
|
|
||||||
},
|
|
||||||
new StubGl(["1100"]));
|
|
||||||
|
|
||||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
() => validator.StartAsync(CancellationToken.None));
|
() => validator.StartAsync(CancellationToken.None));
|
||||||
@@ -64,97 +58,76 @@ public class GlAccountCodeValidatorTests
|
|||||||
Assert.Contains("1000-01", ex.Message);
|
Assert.Contains("1000-01", ex.Message);
|
||||||
Assert.Contains("5100", ex.Message);
|
Assert.Contains("5100", ex.Message);
|
||||||
Assert.Contains("2 of 3", ex.Message);
|
Assert.Contains("2 of 3", ex.Message);
|
||||||
Assert.DoesNotContain("1100,", ex.Message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The deploy-killer: if GL's /accounts shape ever changes, the reader yields nothing and a naive
|
/// If GL's /accounts shape ever changes, the reader yields nothing. Treating that as
|
||||||
/// validator would call every configured code missing and refuse to start.
|
/// "everything is missing" would refuse to start on a contract change.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task StartAsync_DoesNotThrow_WhenGlReturnsNoCodesAtAll()
|
public async Task StartAsync_DoesNotThrow_WhenGlReturnsNoAccountsAtAll()
|
||||||
{
|
{
|
||||||
var validator = CreateValidator(
|
var validator = CreateValidator(
|
||||||
new() { ["Grn:GlInventoryAccountCode"] = "1100", ["Fifo:GlClearingAccountCode"] = "2000" },
|
new() { ["GeneralLedgerService:FailOnMissingAccountCodes"] = "true" },
|
||||||
new StubGl([]));
|
new StubGeneralLedgerService([]),
|
||||||
|
(GlAccountRole.Inventory, "1100"));
|
||||||
await validator.StartAsync(CancellationToken.None);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task StartAsync_DoesNotThrow_WhenGlIsUnreachable()
|
|
||||||
{
|
|
||||||
var validator = CreateValidator(
|
|
||||||
new() { ["Grn:GlInventoryAccountCode"] = "1100" },
|
|
||||||
new StubGl(new DomainException(ErrorCodes.GlServiceUnavailable, "unreachable", 503)));
|
|
||||||
|
|
||||||
await validator.StartAsync(CancellationToken.None);
|
await validator.StartAsync(CancellationToken.None);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The earlier catch filter only matched DomainException/OperationCanceledException, so anything
|
/// The catch filter once matched only DomainException/OperationCanceledException, so anything
|
||||||
/// else — a parse failure, or a null `items` NRE in the reader — escaped and aborted the host.
|
/// else — a parse failure, a null-items NRE in the reader, a database hiccup reading the
|
||||||
/// A diagnostic must not be able to crash the thing it is diagnosing.
|
/// mapping — escaped a hosted service and aborted the host.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Theory]
|
[Theory]
|
||||||
[MemberData(nameof(UnexpectedFailures))]
|
[MemberData(nameof(UnexpectedFailures))]
|
||||||
public async Task StartAsync_DoesNotThrow_OnAnyUnexpectedFailure(Exception failure)
|
public async Task StartAsync_DoesNotThrow_OnAnyUnexpectedFailure(Exception failure)
|
||||||
{
|
{
|
||||||
var validator = CreateValidator(
|
var validator = CreateValidator(
|
||||||
new() { ["Grn:GlInventoryAccountCode"] = "1100" },
|
new() { ["GeneralLedgerService:FailOnMissingAccountCodes"] = "true" },
|
||||||
new StubGl(failure));
|
new StubGeneralLedgerService(failure),
|
||||||
|
(GlAccountRole.Inventory, "1100"));
|
||||||
|
|
||||||
await validator.StartAsync(CancellationToken.None);
|
await validator.StartAsync(CancellationToken.None);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static TheoryData<Exception> UnexpectedFailures() =>
|
public static TheoryData<Exception> UnexpectedFailures() =>
|
||||||
[
|
[
|
||||||
|
StubGeneralLedgerService.Unreachable(),
|
||||||
new NullReferenceException("items was null"),
|
new NullReferenceException("items was null"),
|
||||||
new global::System.Text.Json.JsonException("unexpected shape"),
|
new global::System.Text.Json.JsonException("unexpected shape"),
|
||||||
new InvalidOperationException("something else entirely"),
|
new InvalidOperationException("something else entirely"),
|
||||||
new HttpRequestException("connection reset"),
|
new HttpRequestException("connection reset"),
|
||||||
|
new OperationCanceledException(),
|
||||||
];
|
];
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task StartAsync_DoesNotThrow_WhenTheChartOfAccountsReadIsCancelled()
|
public async Task StartAsync_SkipsTheGlCall_WhenNothingIsMapped()
|
||||||
{
|
{
|
||||||
var validator = CreateValidator(
|
var gl = new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]);
|
||||||
new() { ["Grn:GlInventoryAccountCode"] = "1100" },
|
|
||||||
new StubGl(new OperationCanceledException()));
|
|
||||||
|
|
||||||
await validator.StartAsync(CancellationToken.None);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Only `Gl*Code` keys are account codes — a sibling setting must not be demanded of GL.</summary>
|
|
||||||
[Fact]
|
|
||||||
public async Task StartAsync_IgnoresSettingsThatAreNotAccountCodes()
|
|
||||||
{
|
|
||||||
var gl = new StubGl(["1100"]);
|
|
||||||
var validator = CreateValidator(
|
|
||||||
new()
|
|
||||||
{
|
|
||||||
["Grn:GlInventoryAccountCode"] = "1100",
|
|
||||||
["Grn:GlPostingEnabled"] = "true",
|
|
||||||
["Grn:SomeOtherCode"] = "not-an-account"
|
|
||||||
},
|
|
||||||
gl);
|
|
||||||
|
|
||||||
await validator.StartAsync(CancellationToken.None);
|
|
||||||
|
|
||||||
Assert.Equal(1, gl.Calls);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task StartAsync_SkipsTheGlCall_WhenNoCodesAreConfigured()
|
|
||||||
{
|
|
||||||
var gl = new StubGl(["1100"]);
|
|
||||||
var validator = CreateValidator(new(), gl);
|
var validator = CreateValidator(new(), gl);
|
||||||
|
|
||||||
await validator.StartAsync(CancellationToken.None);
|
await validator.StartAsync(CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(0, gl.Calls);
|
Assert.Equal(0, gl.Calls);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Duplicate codes across roles are one account to verify, not several.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task StartAsync_DeduplicatesCodesSharedBySeveralRoles()
|
||||||
|
{
|
||||||
|
var gl = new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]);
|
||||||
|
|
||||||
|
var validator = CreateValidator(
|
||||||
|
new() { ["GeneralLedgerService:FailOnMissingAccountCodes"] = "true" },
|
||||||
|
gl,
|
||||||
|
(GlAccountRole.Inventory, "1100"), (GlAccountRole.InventoryGain, "1100"));
|
||||||
|
|
||||||
|
await validator.StartAsync(CancellationToken.None);
|
||||||
|
Assert.Equal(1, gl.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Asia/Colombo is UTC+05:30, so 20:00 UTC on the 31st is already the 1st locally.</summary>
|
/// <summary>Asia/Colombo is UTC+05:30, so 20:00 UTC on the 31st is already the 1st locally.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BusinessClock_MapsAUtcInstantToTheLocalBusinessDay()
|
public void BusinessClock_MapsAUtcInstantToTheLocalBusinessDay()
|
||||||
@@ -165,39 +138,14 @@ public class GlAccountCodeValidatorTests
|
|||||||
Assert.Equal(new DateOnly(2026, 11, 1), BusinessClock.ToBusinessDate(new DateTime(2026, 10, 31, 18, 30, 0, DateTimeKind.Utc)));
|
Assert.Equal(new DateOnly(2026, 11, 1), BusinessClock.ToBusinessDate(new DateTime(2026, 10, 31, 18, 30, 0, DateTimeKind.Utc)));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static GlAccountCodeValidator CreateValidator(Dictionary<string, string?> settings, IGeneralLedgerService gl)
|
private static GlAccountCodeValidator CreateValidator(
|
||||||
|
Dictionary<string, string?> settings, StubGeneralLedgerService gl, params (GlAccountRole, string?)[] mapping)
|
||||||
{
|
{
|
||||||
var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
|
var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
|
||||||
var services = new ServiceCollection().AddScoped(_ => gl).BuildServiceProvider();
|
var services = new ServiceCollection()
|
||||||
|
.AddScoped<IGeneralLedgerService>(_ => gl)
|
||||||
|
.AddScoped<IGlAccountConfigService>(_ => new StubGlAccountConfigService(mapping))
|
||||||
|
.BuildServiceProvider();
|
||||||
return new GlAccountCodeValidator(services, configuration, NullLogger<GlAccountCodeValidator>.Instance);
|
return new GlAccountCodeValidator(services, configuration, NullLogger<GlAccountCodeValidator>.Instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class StubGl(HashSet<string>? codes = null, Exception? throws = null) : IGeneralLedgerService
|
|
||||||
{
|
|
||||||
private readonly HashSet<string> _codes = codes ?? [];
|
|
||||||
private readonly Exception? _throws = throws;
|
|
||||||
|
|
||||||
public StubGl(Exception throws) : this(null, throws) { }
|
|
||||||
|
|
||||||
public int Calls { get; private set; }
|
|
||||||
|
|
||||||
public Task<HashSet<string>> ListAccountCodesAsync(CancellationToken ct)
|
|
||||||
{
|
|
||||||
Calls++;
|
|
||||||
return _throws is not null ? Task.FromException<HashSet<string>>(_throws) : Task.FromResult(_codes);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<ERPCore.Infra.Gl.GeneralLedgerResponse> ForwardAsync(
|
|
||||||
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct)
|
|
||||||
=> throw new NotSupportedException();
|
|
||||||
|
|
||||||
public Task<GlJournalEntryResult> PostJournalEntryAsync(GlJournalEntryRequest request, CancellationToken ct)
|
|
||||||
=> throw new NotSupportedException();
|
|
||||||
|
|
||||||
public Task<GlPeriod> GetPeriodByDateAsync(DateOnly date, CancellationToken ct)
|
|
||||||
=> throw new NotSupportedException();
|
|
||||||
|
|
||||||
public Task<IReadOnlyList<GlBankAccount>> ListBankAccountsAsync(CancellationToken ct)
|
|
||||||
=> throw new NotSupportedException();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using ERPCore.Dtos.Config;
|
||||||
|
using ERPCore.Infra.Persistence;
|
||||||
|
using ERPCore.Repositories;
|
||||||
|
using ERPCore.Services;
|
||||||
|
using ERPCore.Services.Gl;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using ERPCore.System.Errors;
|
||||||
|
using ERPCore.Tests.TestSupport;
|
||||||
|
|
||||||
|
namespace ERPCore.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The point of this service is that a mapping GL cannot post to never reaches the database.
|
||||||
|
/// Validation at the point of entry is what the old appsettings approach could not do: those codes
|
||||||
|
/// were only ever checked at boot, and on 2026-10-07 eleven of twelve turned out never to have
|
||||||
|
/// existed in GL at all.
|
||||||
|
/// </summary>
|
||||||
|
public class GlAccountConfigServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_PersistsAMappingGlCanPostTo()
|
||||||
|
{
|
||||||
|
await using var db = await CreateSeededContextAsync();
|
||||||
|
var gl = new StubGeneralLedgerService([
|
||||||
|
StubGeneralLedgerService.Postable("1100", "Inventory"),
|
||||||
|
StubGeneralLedgerService.Postable("2000", "GRNI")
|
||||||
|
]);
|
||||||
|
var service = CreateService(db, gl);
|
||||||
|
var before = (await service.GetAsync()).RowVersion;
|
||||||
|
|
||||||
|
var result = await service.UpdateAsync(
|
||||||
|
new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100", ClearingAccountCode = "2000" }, before);
|
||||||
|
|
||||||
|
Assert.True(result.RowVersion > before);
|
||||||
|
var codes = await CreateService(db, gl).GetCodesAsync();
|
||||||
|
Assert.Equal("1100", codes[GlAccountRole.Inventory]);
|
||||||
|
Assert.Equal("2000", codes[GlAccountRole.Clearing]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_RejectsACodeGlDoesNotHave_AndNamesIt()
|
||||||
|
{
|
||||||
|
await using var db = await CreateSeededContextAsync();
|
||||||
|
var service = CreateService(db, new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]));
|
||||||
|
var rowVersion = (await service.GetAsync()).RowVersion;
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(
|
||||||
|
new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100", ClearingAccountCode = "9999" }, rowVersion));
|
||||||
|
|
||||||
|
Assert.Equal(ErrorCodes.GlAccountInvalid, ex.Code);
|
||||||
|
Assert.Equal(422, ex.StatusCode);
|
||||||
|
Assert.Contains("9999", ex.Message);
|
||||||
|
Assert.Contains("Clearing", ex.Message);
|
||||||
|
await AssertNothingStoredAsync(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// GL rejects a journal line against a non-postable account, so storing one only defers the
|
||||||
|
/// failure to posting time. The auto-created <c>1000</c> Bank/Cash header is exactly this trap.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_RejectsANonPostableAccount()
|
||||||
|
{
|
||||||
|
await using var db = await CreateSeededContextAsync();
|
||||||
|
var service = CreateService(db, new StubGeneralLedgerService([
|
||||||
|
StubGeneralLedgerService.NotPostable("1000", "Bank and Cash")
|
||||||
|
]));
|
||||||
|
var rowVersion = (await service.GetAsync()).RowVersion;
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(
|
||||||
|
new UpdateGlAccountConfigRequest { CashAccountCode = "1000" }, rowVersion));
|
||||||
|
|
||||||
|
Assert.Equal(ErrorCodes.GlAccountInvalid, ex.Code);
|
||||||
|
Assert.Contains("not postable", ex.Message);
|
||||||
|
await AssertNothingStoredAsync(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Fails closed, deliberately opposite to the startup validator: an unverified mapping is not a valid one.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_RefusesToSaveWhenGlIsUnreachable()
|
||||||
|
{
|
||||||
|
await using var db = await CreateSeededContextAsync();
|
||||||
|
var service = CreateService(db, new StubGeneralLedgerService(StubGeneralLedgerService.Unreachable()));
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(
|
||||||
|
new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100" }, 1));
|
||||||
|
|
||||||
|
Assert.Equal(ErrorCodes.GlServiceUnavailable, ex.Code);
|
||||||
|
await AssertNothingStoredAsync(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_RefusesToSaveAgainstAnEmptyChartOfAccounts()
|
||||||
|
{
|
||||||
|
await using var db = await CreateSeededContextAsync();
|
||||||
|
var service = CreateService(db, new StubGeneralLedgerService([]));
|
||||||
|
var rowVersion = (await service.GetAsync()).RowVersion;
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(
|
||||||
|
new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100" }, rowVersion));
|
||||||
|
|
||||||
|
Assert.Equal(ErrorCodes.GlAccountInvalid, ex.Code);
|
||||||
|
Assert.Equal(503, ex.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_RejectsAStaleIfMatch()
|
||||||
|
{
|
||||||
|
await using var db = await CreateSeededContextAsync();
|
||||||
|
var service = CreateService(db, new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]));
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(
|
||||||
|
new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100" }, expectedRowVersion: 9999));
|
||||||
|
|
||||||
|
Assert.Equal(ErrorCodes.ConcurrencyConflict, ex.Code);
|
||||||
|
Assert.Equal(412, ex.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An empty string clears a role rather than storing "" and posting it to GL.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_TreatsBlankAsCleared()
|
||||||
|
{
|
||||||
|
await using var db = await CreateSeededContextAsync();
|
||||||
|
var gl = new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]);
|
||||||
|
var service = CreateService(db, gl);
|
||||||
|
var rowVersion = (await service.GetAsync()).RowVersion;
|
||||||
|
|
||||||
|
await service.UpdateAsync(new UpdateGlAccountConfigRequest { InventoryAccountCode = " " }, rowVersion);
|
||||||
|
|
||||||
|
var codes = await CreateService(db, gl).GetCodesAsync();
|
||||||
|
Assert.Null(codes.GetOrNull(GlAccountRole.Inventory));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reading an unmapped role names the role, instead of posting "" and letting GL 404.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task GetCodesAsync_ThrowsNamingTheRole_WhenUnmapped()
|
||||||
|
{
|
||||||
|
await using var db = await CreateSeededContextAsync();
|
||||||
|
var service = CreateService(db, new StubGeneralLedgerService([]));
|
||||||
|
|
||||||
|
var codes = await service.GetCodesAsync();
|
||||||
|
var ex = Assert.Throws<DomainException>(() => codes[GlAccountRole.Cogs]);
|
||||||
|
|
||||||
|
Assert.Equal(ErrorCodes.GlAccountNotConfigured, ex.Code);
|
||||||
|
Assert.Contains("Cogs", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One request can post through several services; they should not each re-read the row.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task GetCodesAsync_ReadsOncePerScope()
|
||||||
|
{
|
||||||
|
await using var db = await CreateSeededContextAsync();
|
||||||
|
var service = CreateService(db, new StubGeneralLedgerService([]));
|
||||||
|
|
||||||
|
var first = await service.GetCodesAsync();
|
||||||
|
var second = await service.GetCodesAsync();
|
||||||
|
|
||||||
|
Assert.Same(first, second);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>GL being down must not stop the saved mapping from rendering on the settings screen.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAsync_StillReturnsTheMapping_WhenGlIsUnreachable()
|
||||||
|
{
|
||||||
|
await using var db = await CreateSeededContextAsync(c => c.InventoryAccountCode = "1100");
|
||||||
|
var service = CreateService(db, new StubGeneralLedgerService(StubGeneralLedgerService.Unreachable()));
|
||||||
|
|
||||||
|
var result = await service.GetAsync();
|
||||||
|
|
||||||
|
Assert.True(result.Value.GlUnavailable);
|
||||||
|
Assert.Empty(result.Value.Options);
|
||||||
|
var inventory = result.Value.Mappings.Single(m => m.Role == nameof(GlAccountRole.Inventory));
|
||||||
|
Assert.Equal("1100", inventory.AccountCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The 2026-10-07 state: a stored code GL does not have is shown flagged, not blank.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAsync_FlagsAStoredCodeThatGlDoesNotHave()
|
||||||
|
{
|
||||||
|
await using var db = await CreateSeededContextAsync(c =>
|
||||||
|
{
|
||||||
|
c.InventoryAccountCode = "1100";
|
||||||
|
c.CashAccountCode = "1000-01";
|
||||||
|
});
|
||||||
|
var service = CreateService(db, new StubGeneralLedgerService([
|
||||||
|
StubGeneralLedgerService.Postable("1000-01", "Cash in Hand")
|
||||||
|
]));
|
||||||
|
|
||||||
|
var result = await service.GetAsync();
|
||||||
|
|
||||||
|
Assert.False(result.Value.Mappings.Single(m => m.Role == nameof(GlAccountRole.Inventory)).ExistsInGl);
|
||||||
|
Assert.True(result.Value.Mappings.Single(m => m.Role == nameof(GlAccountRole.Cash)).ExistsInGl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The picker must only offer what validation will accept.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAsync_OffersOnlyPostableActiveAccounts()
|
||||||
|
{
|
||||||
|
await using var db = await CreateSeededContextAsync();
|
||||||
|
var service = CreateService(db, new StubGeneralLedgerService([
|
||||||
|
StubGeneralLedgerService.Postable("1100", "Inventory"),
|
||||||
|
StubGeneralLedgerService.NotPostable("1000", "Bank and Cash"),
|
||||||
|
new GlAccount("1200", "Retired", 1, true, false)
|
||||||
|
]));
|
||||||
|
|
||||||
|
var options = (await service.GetAsync()).Value.Options;
|
||||||
|
|
||||||
|
Assert.Equal(["1100"], options.Select(o => o.AccountCode));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task AssertNothingStoredAsync(ErpDbContext db)
|
||||||
|
{
|
||||||
|
var row = await new Repository<GlAccountConfig>(db).GetByIdAsync(GlAccountConfig.SingletonId);
|
||||||
|
Assert.Null(row!.InventoryAccountCode);
|
||||||
|
Assert.Null(row.ClearingAccountCode);
|
||||||
|
Assert.Null(row.CashAccountCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<ErpDbContext> CreateSeededContextAsync(Action<GlAccountConfig>? configure = null)
|
||||||
|
{
|
||||||
|
var db = NotificationConfigTestSupport.CreateContext(Guid.NewGuid().ToString("N"));
|
||||||
|
db.Users.Add(new User { UserId = User.SystemUserId, Username = "system", DisplayName = "System" });
|
||||||
|
|
||||||
|
var config = new GlAccountConfig { ConfigId = GlAccountConfig.SingletonId };
|
||||||
|
configure?.Invoke(config);
|
||||||
|
db.GlAccountConfig.Add(config);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GlAccountConfigService CreateService(ErpDbContext db, IGeneralLedgerService gl) =>
|
||||||
|
new(new Repository<GlAccountConfig>(db), gl, new TestCurrentUser(), new ERPCore.Infra.UoW.UnitOfWork(db));
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using ERPCore.Infra.Gl;
|
||||||
|
using ERPCore.Services.Gl;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using ERPCore.System.Errors;
|
||||||
|
|
||||||
|
namespace ERPCore.Tests.TestSupport;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stands in for the external GL service. Only <see cref="ListAccountsAsync"/> is implemented —
|
||||||
|
/// nothing under test here posts a journal, and leaving the rest throwing means a test that starts
|
||||||
|
/// doing so fails loudly rather than against a silently empty fake.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class StubGeneralLedgerService : IGeneralLedgerService
|
||||||
|
{
|
||||||
|
private readonly IReadOnlyList<GlAccount> _accounts;
|
||||||
|
private readonly Exception? _throws;
|
||||||
|
|
||||||
|
public StubGeneralLedgerService(IReadOnlyList<GlAccount> accounts) => _accounts = accounts;
|
||||||
|
public StubGeneralLedgerService(Exception throws) { _accounts = []; _throws = throws; }
|
||||||
|
|
||||||
|
public int Calls { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>A postable, active account — the ordinary case.</summary>
|
||||||
|
public static GlAccount Postable(string code, string name = "Account", short type = 1) => new(code, name, type, true, true);
|
||||||
|
|
||||||
|
/// <summary>A header/control account. GL rejects journal lines against these.</summary>
|
||||||
|
public static GlAccount NotPostable(string code, string name = "Header", short type = 1) => new(code, name, type, false, true);
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<GlAccount>> ListAccountsAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
Calls++;
|
||||||
|
return _throws is not null
|
||||||
|
? Task.FromException<IReadOnlyList<GlAccount>>(_throws)
|
||||||
|
: Task.FromResult(_accounts);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<GeneralLedgerResponse> ForwardAsync(
|
||||||
|
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<GlJournalEntryResult> PostJournalEntryAsync(GlJournalEntryRequest request, CancellationToken ct)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<GlPeriod> GetPeriodByDateAsync(DateOnly date, CancellationToken ct)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<GlBankAccount>> ListBankAccountsAsync(CancellationToken ct)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
|
||||||
|
public static DomainException Unreachable() =>
|
||||||
|
new(ErrorCodes.GlServiceUnavailable, "The General Ledger service is unreachable.", 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns a fixed mapping, for exercising the startup validator without a database.</summary>
|
||||||
|
internal sealed class StubGlAccountConfigService(params (GlAccountRole Role, string? Code)[] mappings) : IGlAccountConfigService
|
||||||
|
{
|
||||||
|
public Task<GlAccountCodes> GetCodesAsync(CancellationToken ct = default) =>
|
||||||
|
Task.FromResult(new GlAccountCodes(mappings.ToDictionary(m => m.Role, m => m.Code)));
|
||||||
|
|
||||||
|
public Task<ERPCore.Common.Http.ETagged<ERPCore.Dtos.Config.GlAccountConfigDto>> GetAsync(CancellationToken ct = default)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<ERPCore.Common.Http.ETagged<ERPCore.Dtos.Config.GlAccountConfigDto>> UpdateAsync(
|
||||||
|
ERPCore.Dtos.Config.UpdateGlAccountConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using ERPCore.Dtos.Config;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Role → GL account mapping endpoints.
|
||||||
|
/// <para>
|
||||||
|
/// <b>Authorization:</b> the <c>settings</c> nav policy, like <see cref="BranchConfigController"/>.
|
||||||
|
/// Note this endpoint also serves the selectable accounts, rather than the screen calling the GL
|
||||||
|
/// proxy directly: <see cref="GeneralLedgerController"/> is gated on <c>ledgers</c>, so a
|
||||||
|
/// settings-only user would get a 403 from it — and serving both from here guarantees the picker
|
||||||
|
/// and the server-side validation work from the same list.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
[Route("api/v1/gl-account-config")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "settings")]
|
||||||
|
public sealed class GlAccountConfigController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IGlAccountConfigService _config;
|
||||||
|
|
||||||
|
public GlAccountConfigController(IGlAccountConfigService config) => _config = config;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(GlAccountConfigDto), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<GlAccountConfigDto>> Get(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _config.GetAsync(ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Replaces the mapping. 422 when a code is unknown to GL or not postable; 503 when GL could
|
||||||
|
/// not be reached, since a mapping that could not be verified is not stored.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPut]
|
||||||
|
[ProducesResponseType(typeof(GlAccountConfigDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
|
||||||
|
public async Task<ActionResult<GlAccountConfigDto>> Update(
|
||||||
|
[FromBody] UpdateGlAccountConfigRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var expected = RequireIfMatch();
|
||||||
|
var result = await _config.UpdateAsync(request, expected, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
namespace ERPCore.Dtos.Config;
|
||||||
|
|
||||||
|
/// <summary>One selectable GL account, for the mapping pickers.</summary>
|
||||||
|
public sealed record GlAccountOptionDto(string AccountCode, string AccountName, short AccountTypeId, bool IsPostable, bool IsActive);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One role's current mapping. <see cref="ExistsInGl"/> is false when the saved code is not in GL's
|
||||||
|
/// chart of accounts — which is the state 11 of these were found in on 2026-10-07. The screen shows
|
||||||
|
/// those as flagged rather than blank, so the mapping that is actually stored stays visible.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record GlAccountMappingDto(
|
||||||
|
string Role, string? AccountCode, string? AccountName, bool ExistsInGl, bool IsPostable);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// GL account mapping resource. Carries the options alongside the mappings so the picker and the
|
||||||
|
/// server's validation list are the same data — a code the user can choose is a code that will save.
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="GlUnavailable"/> is true when GL could not be reached: mappings still render from the
|
||||||
|
/// database, <see cref="Options"/> is empty, and saving will be refused until GL answers.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed record GlAccountConfigDto(
|
||||||
|
IReadOnlyList<GlAccountMappingDto> Mappings,
|
||||||
|
IReadOnlyList<GlAccountOptionDto> Options,
|
||||||
|
bool GlUnavailable,
|
||||||
|
DateTime? UpdatedAt,
|
||||||
|
int? UpdatedBy);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Full replacement of the mapping. Every role is sent on every save; null or empty clears a role.
|
||||||
|
/// <c>UpdatedBy</c> is derived from the token, never posted.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UpdateGlAccountConfigRequest
|
||||||
|
{
|
||||||
|
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? InventoryAccountCode { get; set; }
|
||||||
|
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? VatRecoverableAccountCode { get; set; }
|
||||||
|
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? AccountsReceivableCode { get; set; }
|
||||||
|
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? ClearingAccountCode { get; set; }
|
||||||
|
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? TaxPayableAccountCode { get; set; }
|
||||||
|
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? AccountsPayableCode { get; set; }
|
||||||
|
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? SalesRevenueAccountCode { get; set; }
|
||||||
|
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? SalesDiscountAccountCode { get; set; }
|
||||||
|
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? InventoryGainAccountCode { get; set; }
|
||||||
|
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? CogsAccountCode { get; set; }
|
||||||
|
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? InventoryLossAccountCode { get; set; }
|
||||||
|
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? CashAccountCode { get; set; }
|
||||||
|
}
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
|
using ERPCore.Services.Gl;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
|
|
||||||
namespace ERPCore.Infra.Gl;
|
namespace ERPCore.Infra.Gl;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Startup guard for the GL chart of accounts. Every <c>Gl*Code</c> in configuration names an
|
/// Startup guard for the GL chart of accounts. Every account mapped in <c>gl_account_config</c>
|
||||||
/// account that must already exist in GL; until now a typo or an un-seeded account only surfaced
|
/// must exist in GL; without this, an un-seeded account only surfaced as a 404 when some user
|
||||||
/// as a 404 when some user happened to trigger that one flow, mid-transaction — one flow, one
|
/// happened to trigger that one flow, mid-transaction — one flow, one discovery, repeat. This
|
||||||
/// discovery, repeat. This asserts the whole set once, at boot.
|
/// asserts the whole set once, at boot.
|
||||||
|
/// <para>
|
||||||
|
/// Advisory only. Enforcement lives at the point of entry — <c>GlAccountConfigService.UpdateAsync</c>
|
||||||
|
/// refuses to store a code GL does not have or cannot post to — so this is a net for accounts that
|
||||||
|
/// disappeared from GL *after* being mapped.
|
||||||
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Reports, does not block.</b> An earlier revision threw on a missing code, on the reasoning
|
/// <b>Reports, does not block.</b> An earlier revision threw on a missing code, on the reasoning
|
||||||
/// that the config is wrong and waiting won't fix it. In practice that took erpcore.service down
|
/// that the config is wrong and waiting won't fix it. In practice that took erpcore.service down
|
||||||
@@ -26,39 +32,41 @@ public sealed class GlAccountCodeValidator(
|
|||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
ILogger<GlAccountCodeValidator> logger) : IHostedService
|
ILogger<GlAccountCodeValidator> logger) : IHostedService
|
||||||
{
|
{
|
||||||
/// <summary>Configuration sections that carry GL account codes — see appsettings.json.</summary>
|
|
||||||
private static readonly string[] Sections =
|
|
||||||
["Grn", "SalesDayEnd", "Adjustment", "SalesInvoice", "PurchaseInvoice", "Fifo"];
|
|
||||||
|
|
||||||
/// <summary>Startup must not stall on a hung GL — HttpClient's own default is 100s.</summary>
|
/// <summary>Startup must not stall on a hung GL — HttpClient's own default is 100s.</summary>
|
||||||
private static readonly TimeSpan FetchTimeout = TimeSpan.FromSeconds(10);
|
private static readonly TimeSpan FetchTimeout = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
public async Task StartAsync(CancellationToken ct)
|
public async Task StartAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
var expected = Sections
|
string[] expected;
|
||||||
.SelectMany(s => configuration.GetSection(s).GetChildren())
|
HashSet<string> present;
|
||||||
.Where(c => c.Key.StartsWith("Gl", StringComparison.Ordinal) && c.Key.EndsWith("Code", StringComparison.Ordinal))
|
|
||||||
.Select(c => c.Value)
|
// The database read sits inside the catch-all too: a DB hiccup here would otherwise escape
|
||||||
.Where(v => !string.IsNullOrWhiteSpace(v))
|
// a hosted service and reopen the startup-abort hole this class already caused once.
|
||||||
.Select(v => v!)
|
try
|
||||||
|
{
|
||||||
|
using var scope = services.CreateScope();
|
||||||
|
var mapping = scope.ServiceProvider.GetRequiredService<IGlAccountConfigService>();
|
||||||
|
var gl = scope.ServiceProvider.GetRequiredService<IGeneralLedgerService>();
|
||||||
|
|
||||||
|
var codes = await mapping.GetCodesAsync(ct);
|
||||||
|
expected = Enum.GetValues<GlAccountRole>()
|
||||||
|
.Select(codes.GetOrNull)
|
||||||
|
.Where(c => c is not null)
|
||||||
|
.Select(c => c!)
|
||||||
.Distinct(StringComparer.Ordinal)
|
.Distinct(StringComparer.Ordinal)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
if (expected.Length == 0)
|
if (expected.Length == 0)
|
||||||
{
|
{
|
||||||
logger.LogWarning("No GL account codes are configured — skipping chart-of-accounts verification.");
|
logger.LogWarning(
|
||||||
|
"No GL accounts are mapped — skipping chart-of-accounts verification. "
|
||||||
|
+ "Map them under Settings → GL Accounts; posting flows will fail until then.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
HashSet<string> present;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var scope = services.CreateScope();
|
|
||||||
var gl = scope.ServiceProvider.GetRequiredService<IGeneralLedgerService>();
|
|
||||||
|
|
||||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
timeout.CancelAfter(FetchTimeout);
|
timeout.CancelAfter(FetchTimeout);
|
||||||
present = await gl.ListAccountCodesAsync(timeout.Token);
|
present = (await gl.ListAccountsAsync(timeout.Token)).Select(a => a.AccountCode).ToHashSet(StringComparer.Ordinal);
|
||||||
}
|
}
|
||||||
// Catch-all on purpose. This is a diagnostic, and a diagnostic that can abort the host is
|
// Catch-all on purpose. This is a diagnostic, and a diagnostic that can abort the host is
|
||||||
// worse than no diagnostic — whatever GL answers with (unreachable, a timeout, a redirect,
|
// worse than no diagnostic — whatever GL answers with (unreachable, a timeout, a redirect,
|
||||||
@@ -67,8 +75,7 @@ public sealed class GlAccountCodeValidator(
|
|||||||
catch (Exception e) when (!ct.IsCancellationRequested)
|
catch (Exception e) when (!ct.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
logger.LogWarning(e,
|
logger.LogWarning(e,
|
||||||
"Could not read GL's chart of accounts at startup; {Count} configured account code(s) left unverified.",
|
"Could not verify the GL account mapping at startup; it is left unchecked.");
|
||||||
expected.Length);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +85,7 @@ public sealed class GlAccountCodeValidator(
|
|||||||
if (present.Count == 0)
|
if (present.Count == 0)
|
||||||
{
|
{
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
"GL returned no account codes; {Count} configured account code(s) left unverified. " +
|
"GL returned no account codes; {Count} mapped account code(s) left unverified. " +
|
||||||
"If GL's chart of accounts is not actually empty, the /accounts response shape has changed.",
|
"If GL's chart of accounts is not actually empty, the /accounts response shape has changed.",
|
||||||
expected.Length);
|
expected.Length);
|
||||||
return;
|
return;
|
||||||
@@ -88,10 +95,10 @@ public sealed class GlAccountCodeValidator(
|
|||||||
if (missing.Length > 0)
|
if (missing.Length > 0)
|
||||||
{
|
{
|
||||||
var message =
|
var message =
|
||||||
$"General Ledger is missing {missing.Length} of {expected.Length} configured account code(s): " +
|
$"General Ledger is missing {missing.Length} of {expected.Length} mapped account code(s): " +
|
||||||
$"{string.Join(", ", missing.Order(StringComparer.Ordinal))}. Every flow that posts to one of these " +
|
$"{string.Join(", ", missing.Order(StringComparer.Ordinal))}. Every flow that posts to one of these " +
|
||||||
"will fail against GL until they are seeded in GL's chart of accounts, or the Gl*Code settings are " +
|
"will fail against GL until the accounts are created in GL, or the mapping is corrected under " +
|
||||||
"corrected to match the codes GL actually uses.";
|
"Settings → GL Accounts.";
|
||||||
|
|
||||||
if (configuration.GetValue("GeneralLedgerService:FailOnMissingAccountCodes", false))
|
if (configuration.GetValue("GeneralLedgerService:FailOnMissingAccountCodes", false))
|
||||||
throw new InvalidOperationException(message);
|
throw new InvalidOperationException(message);
|
||||||
@@ -100,7 +107,7 @@ public sealed class GlAccountCodeValidator(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.LogInformation("GL account codes verified: {Count} present.", expected.Length);
|
logger.LogInformation("GL account mapping verified: {Count} account code(s) present.", expected.Length);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
|
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
|
||||||
|
|||||||
@@ -47,7 +47,8 @@ public static class DataSeeder
|
|||||||
("PRD-REWORK-LOSS", "Production Rework Loss", ReasonContext.Production),
|
("PRD-REWORK-LOSS", "Production Rework Loss", ReasonContext.Production),
|
||||||
];
|
];
|
||||||
|
|
||||||
public static async Task SeedAsync(ErpDbContext db, CancellationToken ct = default)
|
public static async Task SeedAsync(
|
||||||
|
ErpDbContext db, IConfiguration? configuration = null, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
// Must come first, and must save on its own: every branch-scoped entity has a
|
// Must come first, and must save on its own: every branch-scoped entity has a
|
||||||
// non-nullable BranchId with a Restrict FK, so nothing branch-scoped can be written
|
// non-nullable BranchId with a Restrict FK, so nothing branch-scoped can be written
|
||||||
@@ -60,6 +61,7 @@ public static class DataSeeder
|
|||||||
dirty |= await SeedBranchConfigAsync(db, ct);
|
dirty |= await SeedBranchConfigAsync(db, ct);
|
||||||
dirty |= await SeedSalesConfigAsync(db, ct);
|
dirty |= await SeedSalesConfigAsync(db, ct);
|
||||||
dirty |= await SeedGrnConfigAsync(db, ct);
|
dirty |= await SeedGrnConfigAsync(db, ct);
|
||||||
|
dirty |= await SeedGlAccountConfigAsync(db, configuration, ct);
|
||||||
dirty |= await SeedNotificationConfigAsync(db, ct);
|
dirty |= await SeedNotificationConfigAsync(db, ct);
|
||||||
|
|
||||||
if (dirty) await db.SaveChangesAsync(ct);
|
if (dirty) await db.SaveChangesAsync(ct);
|
||||||
@@ -199,6 +201,49 @@ public static class DataSeeder
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seeds the GL account mapping singleton, carrying over the legacy <c>Gl*Code</c> appsettings
|
||||||
|
/// values once so an existing deployment keeps the mapping it has been running with.
|
||||||
|
/// <para>
|
||||||
|
/// Those keys are <b>seed-only</b>: nothing reads them at runtime any more. Leaving a runtime
|
||||||
|
/// fallback in place would restore the two-sources-of-truth problem this table exists to end —
|
||||||
|
/// after seeding, <c>gl_account_config</c> is authoritative and is edited from
|
||||||
|
/// Settings → GL Accounts, not by redeploying.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Deliberately does not call GL: seeding runs inside the startup block that aborts the app on
|
||||||
|
/// failure (see Program.cs), so it must not depend on another service being up. The codes are
|
||||||
|
/// verified separately — advisory at boot by <c>GlAccountCodeValidator</c>, and enforced on
|
||||||
|
/// every save by <c>GlAccountConfigService.UpdateAsync</c>.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<bool> SeedGlAccountConfigAsync(
|
||||||
|
ErpDbContext db, IConfiguration? configuration, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (await db.GlAccountConfig.AnyAsync(c => c.ConfigId == GlAccountConfig.SingletonId, ct)) return false;
|
||||||
|
|
||||||
|
string? Legacy(string key) =>
|
||||||
|
string.IsNullOrWhiteSpace(configuration?[key]) ? null : configuration[key]!.Trim();
|
||||||
|
|
||||||
|
db.GlAccountConfig.Add(new GlAccountConfig
|
||||||
|
{
|
||||||
|
ConfigId = GlAccountConfig.SingletonId,
|
||||||
|
InventoryAccountCode = Legacy("Grn:GlInventoryAccountCode"),
|
||||||
|
VatRecoverableAccountCode = Legacy("Grn:GlVatRecoverableAccountCode"),
|
||||||
|
AccountsReceivableCode = Legacy("SalesInvoice:GlAccountsReceivableCode"),
|
||||||
|
ClearingAccountCode = Legacy("Grn:GlClearingAccountCode"),
|
||||||
|
TaxPayableAccountCode = Legacy("SalesDayEnd:GlTaxPayableAccountCode"),
|
||||||
|
AccountsPayableCode = Legacy("PurchaseInvoice:GlAccountsPayableCode"),
|
||||||
|
SalesRevenueAccountCode = Legacy("SalesDayEnd:GlSalesRevenueAccountCode"),
|
||||||
|
SalesDiscountAccountCode = Legacy("SalesDayEnd:GlSalesDiscountAccountCode"),
|
||||||
|
InventoryGainAccountCode = Legacy("Adjustment:GlGainAccountCode"),
|
||||||
|
CogsAccountCode = Legacy("SalesDayEnd:GlCogsAccountCode"),
|
||||||
|
InventoryLossAccountCode = Legacy("Adjustment:GlLossAccountCode"),
|
||||||
|
CashAccountCode = Legacy("SalesDayEnd:GlCashAccountCode")
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<bool> SeedNotificationConfigAsync(ErpDbContext db, CancellationToken ct)
|
private static async Task<bool> SeedNotificationConfigAsync(ErpDbContext db, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (await db.NotificationConfig.AnyAsync(c => c.NotificationConfigId == NotificationConfig.SingletonId, ct)) return false;
|
if (await db.NotificationConfig.AnyAsync(c => c.NotificationConfigId == NotificationConfig.SingletonId, ct)) return false;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ERPCore.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddGlAccountConfig : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "gl_account_config",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
ConfigId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
InventoryAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||||
|
VatRecoverableAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||||
|
AccountsReceivableCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||||
|
ClearingAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||||
|
TaxPayableAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||||
|
AccountsPayableCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||||
|
SalesRevenueAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||||
|
SalesDiscountAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||||
|
InventoryGainAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||||
|
CogsAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||||
|
InventoryLossAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||||
|
CashAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||||
|
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_gl_account_config", x => x.ConfigId);
|
||||||
|
table.CheckConstraint("ck_gl_account_config_singleton", "`ConfigId` = 1");
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_gl_account_config_users_UpdatedBy",
|
||||||
|
column: x => x.UpdatedBy,
|
||||||
|
principalTable: "users",
|
||||||
|
principalColumn: "UserId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_gl_account_config_UpdatedBy",
|
||||||
|
table: "gl_account_config",
|
||||||
|
column: "UpdatedBy");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "gl_account_config");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1421,6 +1421,79 @@ namespace ERPCore.Migrations
|
|||||||
b.ToTable("hr_employment_types", (string)null);
|
b.ToTable("hr_employment_types", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.GlAccountConfig", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("ConfigId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("AccountsPayableCode")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("varchar(20)");
|
||||||
|
|
||||||
|
b.Property<string>("AccountsReceivableCode")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("varchar(20)");
|
||||||
|
|
||||||
|
b.Property<string>("CashAccountCode")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("varchar(20)");
|
||||||
|
|
||||||
|
b.Property<string>("ClearingAccountCode")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("varchar(20)");
|
||||||
|
|
||||||
|
b.Property<string>("CogsAccountCode")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("varchar(20)");
|
||||||
|
|
||||||
|
b.Property<string>("InventoryAccountCode")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("varchar(20)");
|
||||||
|
|
||||||
|
b.Property<string>("InventoryGainAccountCode")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("varchar(20)");
|
||||||
|
|
||||||
|
b.Property<string>("InventoryLossAccountCode")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("varchar(20)");
|
||||||
|
|
||||||
|
b.Property<uint>("RowVersion")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("int unsigned");
|
||||||
|
|
||||||
|
b.Property<string>("SalesDiscountAccountCode")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("varchar(20)");
|
||||||
|
|
||||||
|
b.Property<string>("SalesRevenueAccountCode")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("varchar(20)");
|
||||||
|
|
||||||
|
b.Property<string>("TaxPayableAccountCode")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("varchar(20)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int?>("UpdatedBy")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("VatRecoverableAccountCode")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("varchar(20)");
|
||||||
|
|
||||||
|
b.HasKey("ConfigId");
|
||||||
|
|
||||||
|
b.HasIndex("UpdatedBy");
|
||||||
|
|
||||||
|
b.ToTable("gl_account_config", null, t =>
|
||||||
|
{
|
||||||
|
t.HasCheckConstraint("ck_gl_account_config_singleton", "`ConfigId` = 1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("GrnId")
|
b.Property<int>("GrnId")
|
||||||
@@ -7117,6 +7190,16 @@ namespace ERPCore.Migrations
|
|||||||
b.Navigation("SalaryComponent");
|
b.Navigation("SalaryComponent");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.GlAccountConfig", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UpdatedBy")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.Navigation("UpdatedByUser");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
|
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ builder.Services.AddScoped<IProductConfigService, ProductConfigService>();
|
|||||||
builder.Services.AddScoped<IBranchConfigService, BranchConfigService>();
|
builder.Services.AddScoped<IBranchConfigService, BranchConfigService>();
|
||||||
builder.Services.AddScoped<ISalesConfigService, SalesConfigService>();
|
builder.Services.AddScoped<ISalesConfigService, SalesConfigService>();
|
||||||
builder.Services.AddScoped<IGrnConfigService, GrnConfigService>();
|
builder.Services.AddScoped<IGrnConfigService, GrnConfigService>();
|
||||||
|
builder.Services.AddScoped<IGlAccountConfigService, GlAccountConfigService>();
|
||||||
builder.Services.AddScoped<IBranchAccessService, BranchAccessService>();
|
builder.Services.AddScoped<IBranchAccessService, BranchAccessService>();
|
||||||
builder.Services.AddSingleton<INotificationModuleCatalog, NotificationModuleCatalog>();
|
builder.Services.AddSingleton<INotificationModuleCatalog, NotificationModuleCatalog>();
|
||||||
builder.Services.AddScoped<INotificationConfigService, NotificationConfigService>();
|
builder.Services.AddScoped<INotificationConfigService, NotificationConfigService>();
|
||||||
@@ -241,7 +242,7 @@ using (var scope = app.Services.CreateScope())
|
|||||||
await db.Database.MigrateAsync();
|
await db.Database.MigrateAsync();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await DataSeeder.SeedAsync(db);
|
await DataSeeder.SeedAsync(db, builder.Configuration);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -37,15 +37,13 @@ public sealed class AdjustmentService : IAdjustmentService
|
|||||||
private readonly INotificationService _notifications;
|
private readonly INotificationService _notifications;
|
||||||
private readonly IUnitOfWork _uow;
|
private readonly IUnitOfWork _uow;
|
||||||
private readonly IGeneralLedgerService _gl;
|
private readonly IGeneralLedgerService _gl;
|
||||||
private readonly string _glInventoryAccountCode;
|
private readonly IGlAccountConfigService _glAccounts;
|
||||||
private readonly string _glGainAccountCode;
|
|
||||||
private readonly string _glLossAccountCode;
|
|
||||||
|
|
||||||
public AdjustmentService(
|
public AdjustmentService(
|
||||||
IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses, IRepository<Item> items,
|
IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses, IRepository<Item> items,
|
||||||
IRepository<ReasonCode> reasonCodes, IRepository<StockLedger> ledger, IStockMutator mutator,
|
IRepository<ReasonCode> reasonCodes, IRepository<StockLedger> ledger, IStockMutator mutator,
|
||||||
INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, INotificationService notifications, IUnitOfWork uow,
|
INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, INotificationService notifications, IUnitOfWork uow,
|
||||||
IGeneralLedgerService gl, IConfiguration configuration)
|
IGeneralLedgerService gl, IGlAccountConfigService glAccounts)
|
||||||
{
|
{
|
||||||
_adjustments = adjustments;
|
_adjustments = adjustments;
|
||||||
_warehouses = warehouses;
|
_warehouses = warehouses;
|
||||||
@@ -59,9 +57,7 @@ public sealed class AdjustmentService : IAdjustmentService
|
|||||||
_notifications = notifications;
|
_notifications = notifications;
|
||||||
_uow = uow;
|
_uow = uow;
|
||||||
_gl = gl;
|
_gl = gl;
|
||||||
_glInventoryAccountCode = configuration["Adjustment:GlInventoryAccountCode"] ?? string.Empty;
|
_glAccounts = glAccounts;
|
||||||
_glGainAccountCode = configuration["Adjustment:GlGainAccountCode"] ?? string.Empty;
|
|
||||||
_glLossAccountCode = configuration["Adjustment:GlLossAccountCode"] ?? string.Empty;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Builds and posts the GL journal for a <see cref="StockAdjustment"/>'s stock movement.
|
/// <summary>Builds and posts the GL journal for a <see cref="StockAdjustment"/>'s stock movement.
|
||||||
@@ -74,17 +70,18 @@ public sealed class AdjustmentService : IAdjustmentService
|
|||||||
var loss = refs.Where(r => r.Direction == Direction.Out).Sum(r => r.Value);
|
var loss = refs.Where(r => r.Direction == Direction.Out).Sum(r => r.Value);
|
||||||
if (gain <= 0 && loss <= 0) return null;
|
if (gain <= 0 && loss <= 0) return null;
|
||||||
|
|
||||||
|
var glCodes = await _glAccounts.GetCodesAsync(ct);
|
||||||
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), ct);
|
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), ct);
|
||||||
var lines = new List<GlJournalEntryLineRequest>();
|
var lines = new List<GlJournalEntryLineRequest>();
|
||||||
if (gain > 0)
|
if (gain > 0)
|
||||||
{
|
{
|
||||||
lines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, gain, 0m, $"Adjustment {docNo} — stock increase"));
|
lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Inventory], gain, 0m, $"Adjustment {docNo} — stock increase"));
|
||||||
lines.Add(new GlJournalEntryLineRequest(_glGainAccountCode, 0m, gain, $"Adjustment {docNo} — inventory gain"));
|
lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.InventoryGain], 0m, gain, $"Adjustment {docNo} — inventory gain"));
|
||||||
}
|
}
|
||||||
if (loss > 0)
|
if (loss > 0)
|
||||||
{
|
{
|
||||||
lines.Add(new GlJournalEntryLineRequest(_glLossAccountCode, loss, 0m, $"Adjustment {docNo} — inventory loss"));
|
lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.InventoryLoss], loss, 0m, $"Adjustment {docNo} — inventory loss"));
|
||||||
lines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, 0m, loss, $"Adjustment {docNo} — stock decrease"));
|
lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Inventory], 0m, loss, $"Adjustment {docNo} — stock decrease"));
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
var result = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||||
|
|||||||
@@ -41,16 +41,14 @@ public sealed class CountService : ICountService
|
|||||||
private readonly IRepository<StockLayer> _stockLayers;
|
private readonly IRepository<StockLayer> _stockLayers;
|
||||||
private readonly IRepository<Batch> _batches;
|
private readonly IRepository<Batch> _batches;
|
||||||
private readonly IRepository<Serial> _serials;
|
private readonly IRepository<Serial> _serials;
|
||||||
private readonly string _glInventoryAccountCode;
|
private readonly IGlAccountConfigService _glAccounts;
|
||||||
private readonly string _glGainAccountCode;
|
|
||||||
private readonly string _glLossAccountCode;
|
|
||||||
|
|
||||||
public CountService(
|
public CountService(
|
||||||
IRepository<StockCount> counts, IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses,
|
IRepository<StockCount> counts, IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses,
|
||||||
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IFifoCostingService fifo, IStockMutator mutator,
|
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IFifoCostingService fifo, IStockMutator mutator,
|
||||||
INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IUnitOfWork uow,
|
INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IUnitOfWork uow,
|
||||||
IGeneralLedgerService gl, IRepository<StockLayer> stockLayers, IRepository<Batch> batches,
|
IGeneralLedgerService gl, IRepository<StockLayer> stockLayers, IRepository<Batch> batches,
|
||||||
IRepository<Serial> serials, IConfiguration configuration)
|
IRepository<Serial> serials, IGlAccountConfigService glAccounts)
|
||||||
{
|
{
|
||||||
_counts = counts;
|
_counts = counts;
|
||||||
_adjustments = adjustments;
|
_adjustments = adjustments;
|
||||||
@@ -67,9 +65,7 @@ public sealed class CountService : ICountService
|
|||||||
_stockLayers = stockLayers;
|
_stockLayers = stockLayers;
|
||||||
_batches = batches;
|
_batches = batches;
|
||||||
_serials = serials;
|
_serials = serials;
|
||||||
_glInventoryAccountCode = configuration["Adjustment:GlInventoryAccountCode"] ?? string.Empty;
|
_glAccounts = glAccounts;
|
||||||
_glGainAccountCode = configuration["Adjustment:GlGainAccountCode"] ?? string.Empty;
|
|
||||||
_glLossAccountCode = configuration["Adjustment:GlLossAccountCode"] ?? string.Empty;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Same logic as <c>AdjustmentService</c>'s private method of the same name — see there for why
|
/// <summary>Same logic as <c>AdjustmentService</c>'s private method of the same name — see there for why
|
||||||
@@ -81,17 +77,18 @@ public sealed class CountService : ICountService
|
|||||||
var loss = refs.Where(r => r.Direction == Direction.Out).Sum(r => r.Value);
|
var loss = refs.Where(r => r.Direction == Direction.Out).Sum(r => r.Value);
|
||||||
if (gain <= 0 && loss <= 0) return null;
|
if (gain <= 0 && loss <= 0) return null;
|
||||||
|
|
||||||
|
var glCodes = await _glAccounts.GetCodesAsync(ct);
|
||||||
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), ct);
|
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), ct);
|
||||||
var lines = new List<GlJournalEntryLineRequest>();
|
var lines = new List<GlJournalEntryLineRequest>();
|
||||||
if (gain > 0)
|
if (gain > 0)
|
||||||
{
|
{
|
||||||
lines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, gain, 0m, $"Adjustment {docNo} — stock increase"));
|
lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Inventory], gain, 0m, $"Adjustment {docNo} — stock increase"));
|
||||||
lines.Add(new GlJournalEntryLineRequest(_glGainAccountCode, 0m, gain, $"Adjustment {docNo} — inventory gain"));
|
lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.InventoryGain], 0m, gain, $"Adjustment {docNo} — inventory gain"));
|
||||||
}
|
}
|
||||||
if (loss > 0)
|
if (loss > 0)
|
||||||
{
|
{
|
||||||
lines.Add(new GlJournalEntryLineRequest(_glLossAccountCode, loss, 0m, $"Adjustment {docNo} — inventory loss"));
|
lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.InventoryLoss], loss, 0m, $"Adjustment {docNo} — inventory loss"));
|
||||||
lines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, 0m, loss, $"Adjustment {docNo} — stock decrease"));
|
lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Inventory], 0m, loss, $"Adjustment {docNo} — stock decrease"));
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
var result = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ public sealed class GeneralLedgerService : IGeneralLedgerService
|
|||||||
a.CashAccountTypeName, a.AccountNumber, a.GlAccountId, a.GlAccountCode, a.CurrencyCode)).ToList();
|
a.CashAccountTypeName, a.AccountNumber, a.GlAccountId, a.GlAccountCode, a.CurrencyCode)).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<HashSet<string>> ListAccountCodesAsync(CancellationToken ct)
|
public async Task<IReadOnlyList<GlAccount>> ListAccountsAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
// No page/pageSize: GL pages this endpoint only when both are supplied, so this is the
|
// No page/pageSize: GL pages this endpoint only when both are supplied, so this is the
|
||||||
// full, flat chart of accounts (child codes like "1000-01" are rows of their own —
|
// full, flat chart of accounts (child codes like "1000-01" are rows of their own —
|
||||||
@@ -59,8 +59,8 @@ public sealed class GeneralLedgerService : IGeneralLedgerService
|
|||||||
// `"items": null` deserializes over the property initialiser, so the null check is load-bearing.
|
// `"items": null` deserializes over the property initialiser, so the null check is load-bearing.
|
||||||
return (data.Items ?? [])
|
return (data.Items ?? [])
|
||||||
.Where(a => a is not null && !string.IsNullOrWhiteSpace(a.AccountCode))
|
.Where(a => a is not null && !string.IsNullOrWhiteSpace(a.AccountCode))
|
||||||
.Select(a => a.AccountCode)
|
.Select(a => new GlAccount(a.AccountCode, a.AccountName, a.AccountTypeId, a.IsPostable, a.IsActive))
|
||||||
.ToHashSet(StringComparer.Ordinal);
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -118,6 +118,10 @@ public sealed class GeneralLedgerService : IGeneralLedgerService
|
|||||||
private sealed class GlAccountData
|
private sealed class GlAccountData
|
||||||
{
|
{
|
||||||
public string AccountCode { get; set; } = string.Empty;
|
public string AccountCode { get; set; } = string.Empty;
|
||||||
|
public string AccountName { get; set; } = string.Empty;
|
||||||
|
public short AccountTypeId { get; set; }
|
||||||
|
public bool IsPostable { get; set; }
|
||||||
|
public bool IsActive { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class GlBankAccountData
|
private sealed class GlBankAccountData
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
using ERPCore.Common.Http;
|
||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using ERPCore.Dtos.Config;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
|
using ERPCore.Infra.UoW;
|
||||||
|
using ERPCore.Repositories.Interfaces;
|
||||||
|
using ERPCore.Services.Gl;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using ERPCore.System.Errors;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ERPCore.Services;
|
||||||
|
|
||||||
|
/// <inheritdoc cref="IGlAccountConfigService"/>
|
||||||
|
public sealed class GlAccountConfigService : IGlAccountConfigService
|
||||||
|
{
|
||||||
|
private readonly IRepository<GlAccountConfig> _config;
|
||||||
|
private readonly IGeneralLedgerService _gl;
|
||||||
|
private readonly ICurrentUser _currentUser;
|
||||||
|
private readonly IUnitOfWork _uow;
|
||||||
|
|
||||||
|
/// <summary>Scope-lifetime cache — see <see cref="IGlAccountConfigService.GetCodesAsync"/>.</summary>
|
||||||
|
private GlAccountCodes? _cached;
|
||||||
|
|
||||||
|
public GlAccountConfigService(
|
||||||
|
IRepository<GlAccountConfig> config, IGeneralLedgerService gl, ICurrentUser currentUser, IUnitOfWork uow)
|
||||||
|
{
|
||||||
|
_config = config;
|
||||||
|
_gl = gl;
|
||||||
|
_currentUser = currentUser;
|
||||||
|
_uow = uow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ETagged<GlAccountConfigDto>> GetAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var config = await _config.Query().AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(c => c.ConfigId == GlAccountConfig.SingletonId, ct)
|
||||||
|
?? throw new NotFoundException("GL account configuration has not been seeded.");
|
||||||
|
|
||||||
|
// Read-only screen data: GL being down must not stop the saved mapping from rendering.
|
||||||
|
IReadOnlyList<GlAccount> accounts = [];
|
||||||
|
var glUnavailable = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
accounts = await _gl.ListAccountsAsync(ct);
|
||||||
|
}
|
||||||
|
catch (DomainException)
|
||||||
|
{
|
||||||
|
glUnavailable = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ETagged<GlAccountConfigDto>(Map(config, accounts, glUnavailable), config.RowVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ETagged<GlAccountConfigDto>> UpdateAsync(
|
||||||
|
UpdateGlAccountConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var config = await _config.GetByIdAsync(GlAccountConfig.SingletonId, ct)
|
||||||
|
?? throw new NotFoundException("GL account configuration has not been seeded.");
|
||||||
|
|
||||||
|
if (config.RowVersion != expectedRowVersion)
|
||||||
|
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
|
||||||
|
|
||||||
|
var submitted = Submitted(request);
|
||||||
|
|
||||||
|
// Fails closed, unlike GlAccountCodeValidator at startup: there we cannot verify and must
|
||||||
|
// still serve, here we cannot verify and must not store. Letting an unchecked mapping
|
||||||
|
// through is how the appsettings version went unnoticed for months.
|
||||||
|
var accounts = await _gl.ListAccountsAsync(ct);
|
||||||
|
if (accounts.Count == 0)
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCodes.GlAccountInvalid,
|
||||||
|
"GL returned an empty chart of accounts, so the mapping cannot be verified. Try again once GL has accounts.",
|
||||||
|
503);
|
||||||
|
|
||||||
|
var byCode = accounts.ToDictionary(a => a.AccountCode, StringComparer.Ordinal);
|
||||||
|
var problems = new List<string>();
|
||||||
|
foreach (var (role, code) in submitted)
|
||||||
|
{
|
||||||
|
if (code is null) continue;
|
||||||
|
|
||||||
|
if (!byCode.TryGetValue(code, out var account))
|
||||||
|
problems.Add($"{role}: account '{code}' does not exist in GL");
|
||||||
|
else if (!account.IsPostable)
|
||||||
|
// GL rejects a journal line against a non-postable account, so storing one just
|
||||||
|
// moves the failure to posting time. Control/header accounts are the usual trap.
|
||||||
|
problems.Add($"{role}: account '{code}' ({account.AccountName}) is not postable");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (problems.Count > 0)
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCodes.GlAccountInvalid,
|
||||||
|
"The GL account mapping was rejected — " + string.Join("; ", problems) + ".",
|
||||||
|
422);
|
||||||
|
|
||||||
|
config.InventoryAccountCode = submitted[GlAccountRole.Inventory];
|
||||||
|
config.VatRecoverableAccountCode = submitted[GlAccountRole.VatRecoverable];
|
||||||
|
config.AccountsReceivableCode = submitted[GlAccountRole.AccountsReceivable];
|
||||||
|
config.ClearingAccountCode = submitted[GlAccountRole.Clearing];
|
||||||
|
config.TaxPayableAccountCode = submitted[GlAccountRole.TaxPayable];
|
||||||
|
config.AccountsPayableCode = submitted[GlAccountRole.AccountsPayable];
|
||||||
|
config.SalesRevenueAccountCode = submitted[GlAccountRole.SalesRevenue];
|
||||||
|
config.SalesDiscountAccountCode = submitted[GlAccountRole.SalesDiscount];
|
||||||
|
config.InventoryGainAccountCode = submitted[GlAccountRole.InventoryGain];
|
||||||
|
config.CogsAccountCode = submitted[GlAccountRole.Cogs];
|
||||||
|
config.InventoryLossAccountCode = submitted[GlAccountRole.InventoryLoss];
|
||||||
|
config.CashAccountCode = submitted[GlAccountRole.Cash];
|
||||||
|
config.UpdatedAt = DateTime.UtcNow;
|
||||||
|
config.UpdatedBy = _currentUser.AuditUserId;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _uow.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
catch (DbUpdateConcurrencyException)
|
||||||
|
{
|
||||||
|
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
|
||||||
|
}
|
||||||
|
|
||||||
|
_cached = null;
|
||||||
|
return new ETagged<GlAccountConfigDto>(Map(config, accounts, glUnavailable: false), config.RowVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<GlAccountCodes> GetCodesAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (_cached is not null) return _cached;
|
||||||
|
|
||||||
|
var config = await _config.Query().AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(c => c.ConfigId == GlAccountConfig.SingletonId, ct)
|
||||||
|
?? throw new NotFoundException("GL account configuration has not been seeded.");
|
||||||
|
|
||||||
|
return _cached = new GlAccountCodes(Current(config));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Role → stored code, normalising blanks to null.</summary>
|
||||||
|
private static Dictionary<GlAccountRole, string?> Current(GlAccountConfig c) => new()
|
||||||
|
{
|
||||||
|
[GlAccountRole.Inventory] = Trim(c.InventoryAccountCode),
|
||||||
|
[GlAccountRole.VatRecoverable] = Trim(c.VatRecoverableAccountCode),
|
||||||
|
[GlAccountRole.AccountsReceivable] = Trim(c.AccountsReceivableCode),
|
||||||
|
[GlAccountRole.Clearing] = Trim(c.ClearingAccountCode),
|
||||||
|
[GlAccountRole.TaxPayable] = Trim(c.TaxPayableAccountCode),
|
||||||
|
[GlAccountRole.AccountsPayable] = Trim(c.AccountsPayableCode),
|
||||||
|
[GlAccountRole.SalesRevenue] = Trim(c.SalesRevenueAccountCode),
|
||||||
|
[GlAccountRole.SalesDiscount] = Trim(c.SalesDiscountAccountCode),
|
||||||
|
[GlAccountRole.InventoryGain] = Trim(c.InventoryGainAccountCode),
|
||||||
|
[GlAccountRole.Cogs] = Trim(c.CogsAccountCode),
|
||||||
|
[GlAccountRole.InventoryLoss] = Trim(c.InventoryLossAccountCode),
|
||||||
|
[GlAccountRole.Cash] = Trim(c.CashAccountCode)
|
||||||
|
};
|
||||||
|
|
||||||
|
private static Dictionary<GlAccountRole, string?> Submitted(UpdateGlAccountConfigRequest r) => new()
|
||||||
|
{
|
||||||
|
[GlAccountRole.Inventory] = Trim(r.InventoryAccountCode),
|
||||||
|
[GlAccountRole.VatRecoverable] = Trim(r.VatRecoverableAccountCode),
|
||||||
|
[GlAccountRole.AccountsReceivable] = Trim(r.AccountsReceivableCode),
|
||||||
|
[GlAccountRole.Clearing] = Trim(r.ClearingAccountCode),
|
||||||
|
[GlAccountRole.TaxPayable] = Trim(r.TaxPayableAccountCode),
|
||||||
|
[GlAccountRole.AccountsPayable] = Trim(r.AccountsPayableCode),
|
||||||
|
[GlAccountRole.SalesRevenue] = Trim(r.SalesRevenueAccountCode),
|
||||||
|
[GlAccountRole.SalesDiscount] = Trim(r.SalesDiscountAccountCode),
|
||||||
|
[GlAccountRole.InventoryGain] = Trim(r.InventoryGainAccountCode),
|
||||||
|
[GlAccountRole.Cogs] = Trim(r.CogsAccountCode),
|
||||||
|
[GlAccountRole.InventoryLoss] = Trim(r.InventoryLossAccountCode),
|
||||||
|
[GlAccountRole.Cash] = Trim(r.CashAccountCode)
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string? Trim(string? code) => string.IsNullOrWhiteSpace(code) ? null : code.Trim();
|
||||||
|
|
||||||
|
private static GlAccountConfigDto Map(
|
||||||
|
GlAccountConfig config, IReadOnlyList<GlAccount> accounts, bool glUnavailable)
|
||||||
|
{
|
||||||
|
var byCode = accounts.ToDictionary(a => a.AccountCode, StringComparer.Ordinal);
|
||||||
|
|
||||||
|
var mappings = Current(config).Select(kv =>
|
||||||
|
{
|
||||||
|
var found = kv.Value is not null && byCode.TryGetValue(kv.Value, out var a) ? a : null;
|
||||||
|
return new GlAccountMappingDto(
|
||||||
|
kv.Key.ToString(),
|
||||||
|
kv.Value,
|
||||||
|
found?.AccountName,
|
||||||
|
// Unknown while GL is down, rather than falsely "missing".
|
||||||
|
ExistsInGl: glUnavailable || found is not null,
|
||||||
|
IsPostable: glUnavailable || found?.IsPostable == true);
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
var options = accounts
|
||||||
|
.Where(a => a.IsPostable && a.IsActive)
|
||||||
|
.OrderBy(a => a.AccountCode, StringComparer.Ordinal)
|
||||||
|
.Select(a => new GlAccountOptionDto(a.AccountCode, a.AccountName, a.AccountTypeId, a.IsPostable, a.IsActive))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new GlAccountConfigDto(mappings, options, glUnavailable, config.UpdatedAt, config.UpdatedBy);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,9 +45,7 @@ public sealed class GrnService : IGrnService
|
|||||||
private readonly IUnitOfWork _uow;
|
private readonly IUnitOfWork _uow;
|
||||||
private readonly IGeneralLedgerService _gl;
|
private readonly IGeneralLedgerService _gl;
|
||||||
private readonly INotificationService _notifications;
|
private readonly INotificationService _notifications;
|
||||||
private readonly string _glInventoryAccountCode;
|
private readonly IGlAccountConfigService _glAccounts;
|
||||||
private readonly string _glVatRecoverableAccountCode;
|
|
||||||
private readonly string _glClearingAccountCode;
|
|
||||||
|
|
||||||
public GrnService(
|
public GrnService(
|
||||||
IRepository<Grn> grns, IRepository<PurchaseOrder> pos, IRepository<PoLine> poLines,
|
IRepository<Grn> grns, IRepository<PurchaseOrder> pos, IRepository<PoLine> poLines,
|
||||||
@@ -55,7 +53,7 @@ public sealed class GrnService : IGrnService
|
|||||||
IRepository<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches,
|
IRepository<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches,
|
||||||
IRepository<StockLayer> layers, IRepository<StockLedger> ledger, IRepository<TaxRate> taxRates,
|
IRepository<StockLayer> layers, IRepository<StockLedger> ledger, IRepository<TaxRate> taxRates,
|
||||||
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IGrnConfigService grnConfig,
|
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IGrnConfigService grnConfig,
|
||||||
IUnitOfWork uow, IGeneralLedgerService gl, INotificationService notifications, IConfiguration configuration)
|
IUnitOfWork uow, IGeneralLedgerService gl, INotificationService notifications, IGlAccountConfigService glAccounts)
|
||||||
{
|
{
|
||||||
_grns = grns;
|
_grns = grns;
|
||||||
_pos = pos;
|
_pos = pos;
|
||||||
@@ -76,9 +74,7 @@ public sealed class GrnService : IGrnService
|
|||||||
_uow = uow;
|
_uow = uow;
|
||||||
_gl = gl;
|
_gl = gl;
|
||||||
_notifications = notifications;
|
_notifications = notifications;
|
||||||
_glInventoryAccountCode = configuration["Grn:GlInventoryAccountCode"] ?? string.Empty;
|
_glAccounts = glAccounts;
|
||||||
_glVatRecoverableAccountCode = configuration["Grn:GlVatRecoverableAccountCode"] ?? string.Empty;
|
|
||||||
_glClearingAccountCode = configuration["Grn:GlClearingAccountCode"] ?? string.Empty;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PagedResponse<GrnSummaryDto>> ListAsync(
|
public async Task<PagedResponse<GrnSummaryDto>> ListAsync(
|
||||||
@@ -311,17 +307,18 @@ public sealed class GrnService : IGrnService
|
|||||||
// with it, so inventory and the ledger never diverge (user-approved "fails
|
// with it, so inventory and the ledger never diverge (user-approved "fails
|
||||||
// atomically" behavior; residual risk if GL posts but the local commit still
|
// atomically" behavior; residual risk if GL posts but the local commit still
|
||||||
// fails afterward is accepted for this phase, see the integration plan).
|
// fails afterward is accepted for this phase, see the integration plan).
|
||||||
|
var glCodes = await _glAccounts.GetCodesAsync(token);
|
||||||
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), token);
|
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), token);
|
||||||
var glLines = new List<GlJournalEntryLineRequest>();
|
var glLines = new List<GlJournalEntryLineRequest>();
|
||||||
decimal totalPayable = 0m;
|
decimal totalPayable = 0m;
|
||||||
foreach (var line in grn.Lines.OrderBy(l => l.GrnLineId))
|
foreach (var line in grn.Lines.OrderBy(l => l.GrnLineId))
|
||||||
{
|
{
|
||||||
glLines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, line.ReceivedValue, 0m, $"GRN {grn.DocNo} line {line.GrnLineId}"));
|
glLines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Inventory], line.ReceivedValue, 0m, $"GRN {grn.DocNo} line {line.GrnLineId}"));
|
||||||
if (line.VatAmount > 0)
|
if (line.VatAmount > 0)
|
||||||
glLines.Add(new GlJournalEntryLineRequest(_glVatRecoverableAccountCode, line.VatAmount, 0m, $"GRN {grn.DocNo} line {line.GrnLineId} VAT"));
|
glLines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.VatRecoverable], line.VatAmount, 0m, $"GRN {grn.DocNo} line {line.GrnLineId} VAT"));
|
||||||
totalPayable += line.LineTotal;
|
totalPayable += line.LineTotal;
|
||||||
}
|
}
|
||||||
glLines.Add(new GlJournalEntryLineRequest(_glClearingAccountCode, 0m, totalPayable, $"GRN {grn.DocNo} received"));
|
glLines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Clearing], 0m, totalPayable, $"GRN {grn.DocNo} received"));
|
||||||
|
|
||||||
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using ERPCore.Common.Http;
|
||||||
|
using ERPCore.Dtos.Config;
|
||||||
|
using ERPCore.Services.Gl;
|
||||||
|
|
||||||
|
namespace ERPCore.Services.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The role → GL account code mapping: master data, held in <c>gl_account_config</c> and validated
|
||||||
|
/// against GL's live chart of accounts on save. Replaces the per-module <c>Gl*Code</c> appsettings
|
||||||
|
/// keys, which could only be checked at boot and only after a redeploy could be corrected.
|
||||||
|
/// </summary>
|
||||||
|
public interface IGlAccountConfigService
|
||||||
|
{
|
||||||
|
/// <summary>Current mapping plus the selectable accounts, for the settings screen.</summary>
|
||||||
|
Task<ETagged<GlAccountConfigDto>> GetAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Replaces the mapping. Fails closed: rejects any code GL does not have or cannot post to, and
|
||||||
|
/// rejects the save outright if GL cannot be reached — an unverified mapping is not a valid one.
|
||||||
|
/// </summary>
|
||||||
|
Task<ETagged<GlAccountConfigDto>> UpdateAsync(
|
||||||
|
UpdateGlAccountConfigRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The mapping as posting flows consume it. Cached for the lifetime of the scope — a single
|
||||||
|
/// request can post through several services (GrnService → FifoCostingService) and they should
|
||||||
|
/// not each re-read the row.
|
||||||
|
/// </summary>
|
||||||
|
Task<GlAccountCodes> GetCodesAsync(CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -28,18 +28,18 @@ public sealed class PurchaseInvoicePaymentService : IPurchaseInvoicePaymentServi
|
|||||||
private readonly IGeneralLedgerService _gl;
|
private readonly IGeneralLedgerService _gl;
|
||||||
private readonly ICurrentUser _currentUser;
|
private readonly ICurrentUser _currentUser;
|
||||||
private readonly IUnitOfWork _uow;
|
private readonly IUnitOfWork _uow;
|
||||||
private readonly string _glAccountsPayableCode;
|
private readonly IGlAccountConfigService _glAccounts;
|
||||||
|
|
||||||
public PurchaseInvoicePaymentService(
|
public PurchaseInvoicePaymentService(
|
||||||
IRepository<PurchaseInvoice> invoices, IRepository<PurchaseInvoicePayment> payments, IGeneralLedgerService gl,
|
IRepository<PurchaseInvoice> invoices, IRepository<PurchaseInvoicePayment> payments, IGeneralLedgerService gl,
|
||||||
ICurrentUser currentUser, IUnitOfWork uow, IConfiguration configuration)
|
ICurrentUser currentUser, IUnitOfWork uow, IGlAccountConfigService glAccounts)
|
||||||
{
|
{
|
||||||
_invoices = invoices;
|
_invoices = invoices;
|
||||||
_payments = payments;
|
_payments = payments;
|
||||||
_gl = gl;
|
_gl = gl;
|
||||||
_currentUser = currentUser;
|
_currentUser = currentUser;
|
||||||
_uow = uow;
|
_uow = uow;
|
||||||
_glAccountsPayableCode = configuration["PurchaseInvoice:GlAccountsPayableCode"] ?? string.Empty;
|
_glAccounts = glAccounts;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PurchaseInvoicePaymentDto> PayAsync(int purchaseInvoiceId, CreatePurchaseInvoicePaymentRequest request, CancellationToken ct = default)
|
public async Task<PurchaseInvoicePaymentDto> PayAsync(int purchaseInvoiceId, CreatePurchaseInvoicePaymentRequest request, CancellationToken ct = default)
|
||||||
@@ -62,6 +62,7 @@ public sealed class PurchaseInvoicePaymentService : IPurchaseInvoicePaymentServi
|
|||||||
|
|
||||||
var payment = await _uow.ExecuteInTransactionAsync(async token =>
|
var payment = await _uow.ExecuteInTransactionAsync(async token =>
|
||||||
{
|
{
|
||||||
|
var glCodes = await _glAccounts.GetCodesAsync(token);
|
||||||
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), token);
|
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), token);
|
||||||
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||||
{
|
{
|
||||||
@@ -72,7 +73,7 @@ public sealed class PurchaseInvoicePaymentService : IPurchaseInvoicePaymentServi
|
|||||||
Narration = $"Payment against invoice {invoice.DocNo}",
|
Narration = $"Payment against invoice {invoice.DocNo}",
|
||||||
Lines =
|
Lines =
|
||||||
[
|
[
|
||||||
new GlJournalEntryLineRequest(_glAccountsPayableCode, request.Amount, 0m, $"Payment against invoice {invoice.DocNo}"),
|
new GlJournalEntryLineRequest(glCodes[GlAccountRole.AccountsPayable], request.Amount, 0m, $"Payment against invoice {invoice.DocNo}"),
|
||||||
new GlJournalEntryLineRequest(account.GlAccountCode, 0m, request.Amount, $"Payment against invoice {invoice.DocNo}")
|
new GlJournalEntryLineRequest(account.GlAccountCode, 0m, request.Amount, $"Payment against invoice {invoice.DocNo}")
|
||||||
]
|
]
|
||||||
}, token);
|
}, token);
|
||||||
|
|||||||
@@ -35,13 +35,12 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService
|
|||||||
private readonly INumberSequenceService _numbers;
|
private readonly INumberSequenceService _numbers;
|
||||||
private readonly IUnitOfWork _uow;
|
private readonly IUnitOfWork _uow;
|
||||||
private readonly IGeneralLedgerService _gl;
|
private readonly IGeneralLedgerService _gl;
|
||||||
private readonly string _glClearingAccountCode;
|
private readonly IGlAccountConfigService _glAccounts;
|
||||||
private readonly string _glAccountsPayableCode;
|
|
||||||
|
|
||||||
public PurchaseInvoiceService(
|
public PurchaseInvoiceService(
|
||||||
IRepository<PurchaseInvoice> invoices, IRepository<Vendor> vendors, IRepository<Grn> grns,
|
IRepository<PurchaseInvoice> invoices, IRepository<Vendor> vendors, IRepository<Grn> grns,
|
||||||
ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow,
|
ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow,
|
||||||
IGeneralLedgerService gl, IConfiguration configuration)
|
IGeneralLedgerService gl, IGlAccountConfigService glAccounts)
|
||||||
{
|
{
|
||||||
_invoices = invoices;
|
_invoices = invoices;
|
||||||
_vendors = vendors;
|
_vendors = vendors;
|
||||||
@@ -50,8 +49,7 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService
|
|||||||
_numbers = numbers;
|
_numbers = numbers;
|
||||||
_uow = uow;
|
_uow = uow;
|
||||||
_gl = gl;
|
_gl = gl;
|
||||||
_glClearingAccountCode = configuration["PurchaseInvoice:GlClearingAccountCode"] ?? string.Empty;
|
_glAccounts = glAccounts;
|
||||||
_glAccountsPayableCode = configuration["PurchaseInvoice:GlAccountsPayableCode"] ?? string.Empty;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PagedResponse<PurchaseInvoiceSummaryDto>> ListAsync(PageQuery query, PurchaseInvoiceStatus? status, int? vendorId, CancellationToken ct = default)
|
public async Task<PagedResponse<PurchaseInvoiceSummaryDto>> ListAsync(PageQuery query, PurchaseInvoiceStatus? status, int? vendorId, CancellationToken ct = default)
|
||||||
@@ -186,6 +184,7 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService
|
|||||||
// Reclassify the GRN clearing balance to Accounts Payable — the mirror image of
|
// Reclassify the GRN clearing balance to Accounts Payable — the mirror image of
|
||||||
// SalesInvoicePaymentService's AR credit — before committing, so a GL rejection
|
// SalesInvoicePaymentService's AR credit — before committing, so a GL rejection
|
||||||
// rolls the whole post back (same atomicity approach as GrnService.ConfirmAsync).
|
// rolls the whole post back (same atomicity approach as GrnService.ConfirmAsync).
|
||||||
|
var glCodes = await _glAccounts.GetCodesAsync(token);
|
||||||
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), token);
|
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), token);
|
||||||
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||||
{
|
{
|
||||||
@@ -196,8 +195,8 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService
|
|||||||
Narration = $"Vendor invoice recognized - {invoice.DocNo}",
|
Narration = $"Vendor invoice recognized - {invoice.DocNo}",
|
||||||
Lines =
|
Lines =
|
||||||
[
|
[
|
||||||
new GlJournalEntryLineRequest(_glClearingAccountCode, invoice.GrandTotal, 0m, $"Purchase invoice {invoice.DocNo}"),
|
new GlJournalEntryLineRequest(glCodes[GlAccountRole.Clearing], invoice.GrandTotal, 0m, $"Purchase invoice {invoice.DocNo}"),
|
||||||
new GlJournalEntryLineRequest(_glAccountsPayableCode, 0m, invoice.GrandTotal, $"Purchase invoice {invoice.DocNo}")
|
new GlJournalEntryLineRequest(glCodes[GlAccountRole.AccountsPayable], 0m, invoice.GrandTotal, $"Purchase invoice {invoice.DocNo}")
|
||||||
]
|
]
|
||||||
}, token);
|
}, token);
|
||||||
|
|
||||||
|
|||||||
@@ -43,15 +43,14 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
|
|||||||
private readonly INotificationService _notifications;
|
private readonly INotificationService _notifications;
|
||||||
private readonly IUnitOfWork _uow;
|
private readonly IUnitOfWork _uow;
|
||||||
private readonly IGeneralLedgerService _gl;
|
private readonly IGeneralLedgerService _gl;
|
||||||
private readonly string _glInventoryAccountCode;
|
private readonly IGlAccountConfigService _glAccounts;
|
||||||
private readonly string _glClearingAccountCode;
|
|
||||||
|
|
||||||
public PurchaseReturnService(
|
public PurchaseReturnService(
|
||||||
IRepository<PurchaseReturn> returns, IRepository<Vendor> vendors, IRepository<Warehouse> warehouses,
|
IRepository<PurchaseReturn> returns, IRepository<Vendor> vendors, IRepository<Warehouse> warehouses,
|
||||||
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IRepository<GrnLine> grnLines,
|
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IRepository<GrnLine> grnLines,
|
||||||
IRepository<StockLayer> layers, IRepository<StockLedger> ledger, IStockMutator mutator, IFifoCostingService fifo,
|
IRepository<StockLayer> layers, IRepository<StockLedger> ledger, IStockMutator mutator, IFifoCostingService fifo,
|
||||||
INumberSequenceService numbers, ICurrentUser currentUser, INotificationService notifications,
|
INumberSequenceService numbers, ICurrentUser currentUser, INotificationService notifications,
|
||||||
IUnitOfWork uow, IGeneralLedgerService gl, IConfiguration configuration)
|
IUnitOfWork uow, IGeneralLedgerService gl, IGlAccountConfigService glAccounts)
|
||||||
{
|
{
|
||||||
_returns = returns;
|
_returns = returns;
|
||||||
_vendors = vendors;
|
_vendors = vendors;
|
||||||
@@ -68,8 +67,7 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
|
|||||||
_notifications = notifications;
|
_notifications = notifications;
|
||||||
_uow = uow;
|
_uow = uow;
|
||||||
_gl = gl;
|
_gl = gl;
|
||||||
_glInventoryAccountCode = configuration["Grn:GlInventoryAccountCode"] ?? string.Empty;
|
_glAccounts = glAccounts;
|
||||||
_glClearingAccountCode = configuration["Grn:GlClearingAccountCode"] ?? string.Empty;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PagedResponse<PurchaseReturnSummaryDto>> ListAsync(
|
public async Task<PagedResponse<PurchaseReturnSummaryDto>> ListAsync(
|
||||||
@@ -221,6 +219,7 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
|
|||||||
var totalValue = refs.Sum(r => r.Value);
|
var totalValue = refs.Sum(r => r.Value);
|
||||||
if (totalValue > 0)
|
if (totalValue > 0)
|
||||||
{
|
{
|
||||||
|
var glCodes = await _glAccounts.GetCodesAsync(token);
|
||||||
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), token);
|
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), token);
|
||||||
var glResult = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
var glResult = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||||
{
|
{
|
||||||
@@ -231,8 +230,8 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
|
|||||||
Narration = $"Purchase return {docNo} — stock returned to vendor",
|
Narration = $"Purchase return {docNo} — stock returned to vendor",
|
||||||
Lines = new List<GlJournalEntryLineRequest>
|
Lines = new List<GlJournalEntryLineRequest>
|
||||||
{
|
{
|
||||||
new(_glClearingAccountCode, totalValue, 0m, $"Purchase return {docNo}"),
|
new(glCodes[GlAccountRole.Clearing], totalValue, 0m, $"Purchase return {docNo}"),
|
||||||
new(_glInventoryAccountCode, 0m, totalValue, $"Purchase return {docNo} — inventory reduction")
|
new(glCodes[GlAccountRole.Inventory], 0m, totalValue, $"Purchase return {docNo} — inventory reduction")
|
||||||
}
|
}
|
||||||
}, token);
|
}, token);
|
||||||
ret.GlJournalNo = glResult.JournalNo;
|
ret.GlJournalNo = glResult.JournalNo;
|
||||||
|
|||||||
@@ -43,18 +43,12 @@ public sealed class SalesDayEndService : ISalesDayEndService
|
|||||||
private readonly ICurrentUser _currentUser;
|
private readonly ICurrentUser _currentUser;
|
||||||
private readonly IUnitOfWork _uow;
|
private readonly IUnitOfWork _uow;
|
||||||
private readonly IGeneralLedgerService _gl;
|
private readonly IGeneralLedgerService _gl;
|
||||||
private readonly string _glCashAccountCode;
|
private readonly IGlAccountConfigService _glAccounts;
|
||||||
private readonly string _glSalesRevenueAccountCode;
|
|
||||||
private readonly string _glSalesDiscountAccountCode;
|
|
||||||
private readonly string _glTaxPayableAccountCode;
|
|
||||||
private readonly string _glCogsAccountCode;
|
|
||||||
private readonly string _glInventoryAccountCode;
|
|
||||||
private readonly string _glAccountsReceivableCode;
|
|
||||||
|
|
||||||
public SalesDayEndService(
|
public SalesDayEndService(
|
||||||
IRepository<SalesDayEnd> dayEnds, IRepository<SalesSlip> slips, IRepository<BundleSale> bundles,
|
IRepository<SalesDayEnd> dayEnds, IRepository<SalesSlip> slips, IRepository<BundleSale> bundles,
|
||||||
IRepository<SalesInvoice> invoices, IRepository<StockLedger> ledger, IRepository<Item> items, INumberSequenceService numbers,
|
IRepository<SalesInvoice> invoices, IRepository<StockLedger> ledger, IRepository<Item> items, INumberSequenceService numbers,
|
||||||
ICurrentUser currentUser, IUnitOfWork uow, IGeneralLedgerService gl, IConfiguration configuration)
|
ICurrentUser currentUser, IUnitOfWork uow, IGeneralLedgerService gl, IGlAccountConfigService glAccounts)
|
||||||
{
|
{
|
||||||
_dayEnds = dayEnds;
|
_dayEnds = dayEnds;
|
||||||
_slips = slips;
|
_slips = slips;
|
||||||
@@ -66,13 +60,7 @@ public sealed class SalesDayEndService : ISalesDayEndService
|
|||||||
_currentUser = currentUser;
|
_currentUser = currentUser;
|
||||||
_uow = uow;
|
_uow = uow;
|
||||||
_gl = gl;
|
_gl = gl;
|
||||||
_glCashAccountCode = configuration["SalesDayEnd:GlCashAccountCode"] ?? string.Empty;
|
_glAccounts = glAccounts;
|
||||||
_glSalesRevenueAccountCode = configuration["SalesDayEnd:GlSalesRevenueAccountCode"] ?? string.Empty;
|
|
||||||
_glSalesDiscountAccountCode = configuration["SalesDayEnd:GlSalesDiscountAccountCode"] ?? string.Empty;
|
|
||||||
_glTaxPayableAccountCode = configuration["SalesDayEnd:GlTaxPayableAccountCode"] ?? string.Empty;
|
|
||||||
_glCogsAccountCode = configuration["SalesDayEnd:GlCogsAccountCode"] ?? string.Empty;
|
|
||||||
_glInventoryAccountCode = configuration["SalesDayEnd:GlInventoryAccountCode"] ?? string.Empty;
|
|
||||||
_glAccountsReceivableCode = configuration["SalesDayEnd:GlAccountsReceivableCode"] ?? string.Empty;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<SalesDayEndPreviewDto> PreviewAsync(int cashierUserId, DateOnly businessDate, CancellationToken ct = default)
|
public async Task<SalesDayEndPreviewDto> PreviewAsync(int cashierUserId, DateOnly businessDate, CancellationToken ct = default)
|
||||||
@@ -206,24 +194,25 @@ public sealed class SalesDayEndService : ISalesDayEndService
|
|||||||
|
|
||||||
if (grandTotal > 0 || cogs > 0)
|
if (grandTotal > 0 || cogs > 0)
|
||||||
{
|
{
|
||||||
|
var glCodes = await _glAccounts.GetCodesAsync(token);
|
||||||
var period = await _gl.GetPeriodByDateAsync(businessDate, token);
|
var period = await _gl.GetPeriodByDateAsync(businessDate, token);
|
||||||
var glLines = new List<GlJournalEntryLineRequest>();
|
var glLines = new List<GlJournalEntryLineRequest>();
|
||||||
if (grandTotal > 0)
|
if (grandTotal > 0)
|
||||||
{
|
{
|
||||||
if (paidTotal > 0)
|
if (paidTotal > 0)
|
||||||
glLines.Add(new GlJournalEntryLineRequest(_glCashAccountCode, paidTotal, 0m, $"Day-end {businessDate:yyyy-MM-dd} cashier {request.CashierUserId} — cash collected"));
|
glLines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Cash], paidTotal, 0m, $"Day-end {businessDate:yyyy-MM-dd} cashier {request.CashierUserId} — cash collected"));
|
||||||
if (balanceTotal > 0)
|
if (balanceTotal > 0)
|
||||||
glLines.Add(new GlJournalEntryLineRequest(_glAccountsReceivableCode, balanceTotal, 0m, $"Day-end {businessDate:yyyy-MM-dd} cashier {request.CashierUserId} — sold on account"));
|
glLines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.AccountsReceivable], balanceTotal, 0m, $"Day-end {businessDate:yyyy-MM-dd} cashier {request.CashierUserId} — sold on account"));
|
||||||
if (discountTotal > 0)
|
if (discountTotal > 0)
|
||||||
glLines.Add(new GlJournalEntryLineRequest(_glSalesDiscountAccountCode, discountTotal, 0m, "Sales discount"));
|
glLines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.SalesDiscount], discountTotal, 0m, "Sales discount"));
|
||||||
glLines.Add(new GlJournalEntryLineRequest(_glSalesRevenueAccountCode, 0m, subtotal, "Sales revenue"));
|
glLines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.SalesRevenue], 0m, subtotal, "Sales revenue"));
|
||||||
if (taxTotal > 0)
|
if (taxTotal > 0)
|
||||||
glLines.Add(new GlJournalEntryLineRequest(_glTaxPayableAccountCode, 0m, taxTotal, "Output tax payable"));
|
glLines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.TaxPayable], 0m, taxTotal, "Output tax payable"));
|
||||||
}
|
}
|
||||||
if (cogs > 0)
|
if (cogs > 0)
|
||||||
{
|
{
|
||||||
glLines.Add(new GlJournalEntryLineRequest(_glCogsAccountCode, cogs, 0m, "Cost of goods sold"));
|
glLines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Cogs], cogs, 0m, "Cost of goods sold"));
|
||||||
glLines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, 0m, cogs, "Inventory reduction"));
|
glLines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Inventory], 0m, cogs, "Inventory reduction"));
|
||||||
}
|
}
|
||||||
|
|
||||||
var glResult = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
var glResult = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||||
|
|||||||
@@ -29,11 +29,11 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
|
|||||||
private readonly IGeneralLedgerService _gl;
|
private readonly IGeneralLedgerService _gl;
|
||||||
private readonly ICurrentUser _currentUser;
|
private readonly ICurrentUser _currentUser;
|
||||||
private readonly IUnitOfWork _uow;
|
private readonly IUnitOfWork _uow;
|
||||||
private readonly string _glAccountsReceivableCode;
|
private readonly IGlAccountConfigService _glAccounts;
|
||||||
|
|
||||||
public SalesInvoicePaymentService(
|
public SalesInvoicePaymentService(
|
||||||
IRepository<SalesInvoice> invoices, IRepository<SalesInvoicePayment> payments, IRepository<SalesConfig> salesConfig,
|
IRepository<SalesInvoice> invoices, IRepository<SalesInvoicePayment> payments, IRepository<SalesConfig> salesConfig,
|
||||||
IGeneralLedgerService gl, ICurrentUser currentUser, IUnitOfWork uow, IConfiguration configuration)
|
IGeneralLedgerService gl, ICurrentUser currentUser, IUnitOfWork uow, IGlAccountConfigService glAccounts)
|
||||||
{
|
{
|
||||||
_invoices = invoices;
|
_invoices = invoices;
|
||||||
_payments = payments;
|
_payments = payments;
|
||||||
@@ -41,7 +41,7 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
|
|||||||
_gl = gl;
|
_gl = gl;
|
||||||
_currentUser = currentUser;
|
_currentUser = currentUser;
|
||||||
_uow = uow;
|
_uow = uow;
|
||||||
_glAccountsReceivableCode = configuration["SalesInvoice:GlAccountsReceivableCode"] ?? string.Empty;
|
_glAccounts = glAccounts;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<SalesInvoicePaymentDto> PayAsync(int salesInvoiceId, CreateSalesInvoicePaymentRequest request, CancellationToken ct = default)
|
public async Task<SalesInvoicePaymentDto> PayAsync(int salesInvoiceId, CreateSalesInvoicePaymentRequest request, CancellationToken ct = default)
|
||||||
@@ -68,6 +68,7 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
|
|||||||
// Same atomicity approach as GrnPaymentService.PayAsync: post to GL first, inside
|
// Same atomicity approach as GrnPaymentService.PayAsync: post to GL first, inside
|
||||||
// this transaction, before anything is committed — a GL rejection/timeout rolls
|
// this transaction, before anything is committed — a GL rejection/timeout rolls
|
||||||
// the whole payment back with no partial local state.
|
// the whole payment back with no partial local state.
|
||||||
|
var glCodes = await _glAccounts.GetCodesAsync(token);
|
||||||
var period = await _gl.GetPeriodByDateAsync(paymentDay, token);
|
var period = await _gl.GetPeriodByDateAsync(paymentDay, token);
|
||||||
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||||
{
|
{
|
||||||
@@ -79,7 +80,7 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
|
|||||||
Lines =
|
Lines =
|
||||||
[
|
[
|
||||||
new GlJournalEntryLineRequest(account.GlAccountCode, request.Amount, 0m, $"Payment against invoice {invoice.InvoiceNo}"),
|
new GlJournalEntryLineRequest(account.GlAccountCode, request.Amount, 0m, $"Payment against invoice {invoice.InvoiceNo}"),
|
||||||
new GlJournalEntryLineRequest(_glAccountsReceivableCode, 0m, request.Amount, $"Payment against invoice {invoice.InvoiceNo}")
|
new GlJournalEntryLineRequest(glCodes[GlAccountRole.AccountsReceivable], 0m, request.Amount, $"Payment against invoice {invoice.InvoiceNo}")
|
||||||
]
|
]
|
||||||
}, token);
|
}, token);
|
||||||
|
|
||||||
@@ -178,10 +179,11 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
|
|||||||
{
|
{
|
||||||
// One journal for the whole payment (Debit each cash/bank line / Credit AR in total), posted
|
// One journal for the whole payment (Debit each cash/bank line / Credit AR in total), posted
|
||||||
// before anything is committed — so a GL rejection leaves neither GL nor local state half-done.
|
// before anything is committed — so a GL rejection leaves neither GL nor local state half-done.
|
||||||
|
var glCodes = await _glAccounts.GetCodesAsync(token);
|
||||||
var period = await _gl.GetPeriodByDateAsync(paymentDay, token);
|
var period = await _gl.GetPeriodByDateAsync(paymentDay, token);
|
||||||
var glLines = lines
|
var glLines = lines
|
||||||
.Select(x => new GlJournalEntryLineRequest(x.Account.GlAccountCode, x.Line.Amount, 0m, narration))
|
.Select(x => new GlJournalEntryLineRequest(x.Account.GlAccountCode, x.Line.Amount, 0m, narration))
|
||||||
.Append(new GlJournalEntryLineRequest(_glAccountsReceivableCode, 0m, total, narration))
|
.Append(new GlJournalEntryLineRequest(glCodes[GlAccountRole.AccountsReceivable], 0m, total, narration))
|
||||||
.ToList();
|
.ToList();
|
||||||
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -26,12 +26,7 @@ public sealed class SalesPostingService : ISalesPostingService
|
|||||||
private readonly ICurrentUser _currentUser;
|
private readonly ICurrentUser _currentUser;
|
||||||
private readonly IUnitOfWork _uow;
|
private readonly IUnitOfWork _uow;
|
||||||
private readonly IGeneralLedgerService _gl;
|
private readonly IGeneralLedgerService _gl;
|
||||||
private readonly string _glAccountsReceivableCode;
|
private readonly IGlAccountConfigService _glAccounts;
|
||||||
private readonly string _glSalesRevenueAccountCode;
|
|
||||||
private readonly string _glSalesDiscountAccountCode;
|
|
||||||
private readonly string _glTaxPayableAccountCode;
|
|
||||||
private readonly string _glCogsAccountCode;
|
|
||||||
private readonly string _glInventoryAccountCode;
|
|
||||||
|
|
||||||
public SalesPostingService(
|
public SalesPostingService(
|
||||||
IRepository<SalesInvoice> invoices,
|
IRepository<SalesInvoice> invoices,
|
||||||
@@ -44,7 +39,7 @@ public sealed class SalesPostingService : ISalesPostingService
|
|||||||
ICurrentUser currentUser,
|
ICurrentUser currentUser,
|
||||||
IUnitOfWork uow,
|
IUnitOfWork uow,
|
||||||
IGeneralLedgerService gl,
|
IGeneralLedgerService gl,
|
||||||
IConfiguration configuration)
|
IGlAccountConfigService glAccounts)
|
||||||
{
|
{
|
||||||
_invoices = invoices;
|
_invoices = invoices;
|
||||||
_slips = slips;
|
_slips = slips;
|
||||||
@@ -56,14 +51,9 @@ public sealed class SalesPostingService : ISalesPostingService
|
|||||||
_currentUser = currentUser;
|
_currentUser = currentUser;
|
||||||
_uow = uow;
|
_uow = uow;
|
||||||
_gl = gl;
|
_gl = gl;
|
||||||
_glAccountsReceivableCode = configuration["SalesInvoice:GlAccountsReceivableCode"] ?? string.Empty;
|
_glAccounts = glAccounts;
|
||||||
// Same physical accounts Sales Day End credits for slip/bundle revenue — an invoice
|
// Same physical accounts Sales Day End credits for slip/bundle revenue — an invoice
|
||||||
// just recognizes them immediately on Post instead of waiting for the day's close.
|
// just recognizes them immediately on Post instead of waiting for the day's close.
|
||||||
_glSalesRevenueAccountCode = configuration["SalesDayEnd:GlSalesRevenueAccountCode"] ?? string.Empty;
|
|
||||||
_glSalesDiscountAccountCode = configuration["SalesDayEnd:GlSalesDiscountAccountCode"] ?? string.Empty;
|
|
||||||
_glTaxPayableAccountCode = configuration["SalesDayEnd:GlTaxPayableAccountCode"] ?? string.Empty;
|
|
||||||
_glCogsAccountCode = configuration["SalesDayEnd:GlCogsAccountCode"] ?? string.Empty;
|
|
||||||
_glInventoryAccountCode = configuration["SalesDayEnd:GlInventoryAccountCode"] ?? string.Empty;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<SalesInvoicePostingCheckDto> CheckInvoiceAsync(int salesInvoiceId, CancellationToken ct = default)
|
public async Task<SalesInvoicePostingCheckDto> CheckInvoiceAsync(int salesInvoiceId, CancellationToken ct = default)
|
||||||
@@ -185,21 +175,22 @@ public sealed class SalesPostingService : ISalesPostingService
|
|||||||
if (invoice.NetPayable <= 0 && cogs <= 0) return;
|
if (invoice.NetPayable <= 0 && cogs <= 0) return;
|
||||||
|
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
|
var glCodes = await _glAccounts.GetCodesAsync(ct);
|
||||||
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), ct);
|
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), ct);
|
||||||
var lines = new List<GlJournalEntryLineRequest>();
|
var lines = new List<GlJournalEntryLineRequest>();
|
||||||
if (invoice.NetPayable > 0)
|
if (invoice.NetPayable > 0)
|
||||||
{
|
{
|
||||||
lines.Add(new GlJournalEntryLineRequest(_glAccountsReceivableCode, invoice.NetPayable, 0m, $"Invoice {invoice.InvoiceNo}"));
|
lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.AccountsReceivable], invoice.NetPayable, 0m, $"Invoice {invoice.InvoiceNo}"));
|
||||||
if (invoice.DiscountTotal > 0)
|
if (invoice.DiscountTotal > 0)
|
||||||
lines.Add(new GlJournalEntryLineRequest(_glSalesDiscountAccountCode, invoice.DiscountTotal, 0m, "Sales discount"));
|
lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.SalesDiscount], invoice.DiscountTotal, 0m, "Sales discount"));
|
||||||
lines.Add(new GlJournalEntryLineRequest(_glSalesRevenueAccountCode, 0m, invoice.Subtotal, "Sales revenue"));
|
lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.SalesRevenue], 0m, invoice.Subtotal, "Sales revenue"));
|
||||||
if (invoice.TaxTotal > 0)
|
if (invoice.TaxTotal > 0)
|
||||||
lines.Add(new GlJournalEntryLineRequest(_glTaxPayableAccountCode, 0m, invoice.TaxTotal, "Output tax payable"));
|
lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.TaxPayable], 0m, invoice.TaxTotal, "Output tax payable"));
|
||||||
}
|
}
|
||||||
if (cogs > 0)
|
if (cogs > 0)
|
||||||
{
|
{
|
||||||
lines.Add(new GlJournalEntryLineRequest(_glCogsAccountCode, cogs, 0m, "Cost of goods sold"));
|
lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Cogs], cogs, 0m, "Cost of goods sold"));
|
||||||
lines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, 0m, cogs, "Inventory reduction"));
|
lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Inventory], 0m, cogs, "Inventory reduction"));
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
var result = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||||
|
|||||||
@@ -39,15 +39,14 @@ public sealed class SalesReturnService : ISalesReturnService
|
|||||||
private readonly ICurrentUser _currentUser;
|
private readonly ICurrentUser _currentUser;
|
||||||
private readonly IUnitOfWork _uow;
|
private readonly IUnitOfWork _uow;
|
||||||
private readonly IGeneralLedgerService _gl;
|
private readonly IGeneralLedgerService _gl;
|
||||||
private readonly string _glInventoryAccountCode;
|
private readonly IGlAccountConfigService _glAccounts;
|
||||||
private readonly string _glCogsAccountCode;
|
|
||||||
|
|
||||||
public SalesReturnService(
|
public SalesReturnService(
|
||||||
IRepository<SalesReturn> returns, IRepository<Customer> customers, IRepository<Warehouse> warehouses,
|
IRepository<SalesReturn> returns, IRepository<Customer> customers, IRepository<Warehouse> warehouses,
|
||||||
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IRepository<SalesInvoiceLine> salesInvoiceLines,
|
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IRepository<SalesInvoiceLine> salesInvoiceLines,
|
||||||
IRepository<SalesReturnLine> returnLines, IRepository<StockLedger> ledger, IStockMutator mutator,
|
IRepository<SalesReturnLine> returnLines, IRepository<StockLedger> ledger, IStockMutator mutator,
|
||||||
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow,
|
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow,
|
||||||
IGeneralLedgerService gl, IConfiguration configuration)
|
IGeneralLedgerService gl, IGlAccountConfigService glAccounts)
|
||||||
{
|
{
|
||||||
_returns = returns;
|
_returns = returns;
|
||||||
_customers = customers;
|
_customers = customers;
|
||||||
@@ -62,8 +61,7 @@ public sealed class SalesReturnService : ISalesReturnService
|
|||||||
_currentUser = currentUser;
|
_currentUser = currentUser;
|
||||||
_uow = uow;
|
_uow = uow;
|
||||||
_gl = gl;
|
_gl = gl;
|
||||||
_glInventoryAccountCode = configuration["SalesDayEnd:GlInventoryAccountCode"] ?? string.Empty;
|
_glAccounts = glAccounts;
|
||||||
_glCogsAccountCode = configuration["SalesDayEnd:GlCogsAccountCode"] ?? string.Empty;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PagedResponse<SalesReturnSummaryDto>> ListAsync(
|
public async Task<PagedResponse<SalesReturnSummaryDto>> ListAsync(
|
||||||
@@ -180,6 +178,7 @@ public sealed class SalesReturnService : ISalesReturnService
|
|||||||
var totalValue = refs.Sum(r => r.Value);
|
var totalValue = refs.Sum(r => r.Value);
|
||||||
if (totalValue > 0)
|
if (totalValue > 0)
|
||||||
{
|
{
|
||||||
|
var glCodes = await _glAccounts.GetCodesAsync(token);
|
||||||
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), token);
|
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), token);
|
||||||
var glResult = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
var glResult = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||||
{
|
{
|
||||||
@@ -190,8 +189,8 @@ public sealed class SalesReturnService : ISalesReturnService
|
|||||||
Narration = $"Sales return {docNo} — stock restocked",
|
Narration = $"Sales return {docNo} — stock restocked",
|
||||||
Lines = new List<GlJournalEntryLineRequest>
|
Lines = new List<GlJournalEntryLineRequest>
|
||||||
{
|
{
|
||||||
new(_glInventoryAccountCode, totalValue, 0m, $"Sales return {docNo}"),
|
new(glCodes[GlAccountRole.Inventory], totalValue, 0m, $"Sales return {docNo}"),
|
||||||
new(_glCogsAccountCode, 0m, totalValue, $"Sales return {docNo} — COGS reversal")
|
new(glCodes[GlAccountRole.Cogs], 0m, totalValue, $"Sales return {docNo} — COGS reversal")
|
||||||
}
|
}
|
||||||
}, token);
|
}, token);
|
||||||
ret.GlJournalNo = glResult.JournalNo;
|
ret.GlJournalNo = glResult.JournalNo;
|
||||||
|
|||||||
@@ -26,21 +26,19 @@ public sealed class FifoCostingService : IFifoCostingService
|
|||||||
private readonly IRepository<JournalEntryStub> _journal;
|
private readonly IRepository<JournalEntryStub> _journal;
|
||||||
private readonly ErpDbContext _db;
|
private readonly ErpDbContext _db;
|
||||||
private readonly IGeneralLedgerService _gl;
|
private readonly IGeneralLedgerService _gl;
|
||||||
private readonly string _glInventoryAccountCode;
|
private readonly IGlAccountConfigService _glAccounts;
|
||||||
private readonly string _glClearingAccountCode;
|
|
||||||
|
|
||||||
public FifoCostingService(
|
public FifoCostingService(
|
||||||
IRepository<StockLayer> layers, IRepository<StockLedger> ledger,
|
IRepository<StockLayer> layers, IRepository<StockLedger> ledger,
|
||||||
IRepository<JournalEntryStub> journal, ErpDbContext db,
|
IRepository<JournalEntryStub> journal, ErpDbContext db,
|
||||||
IGeneralLedgerService gl, IConfiguration configuration)
|
IGeneralLedgerService gl, IGlAccountConfigService glAccounts)
|
||||||
{
|
{
|
||||||
_layers = layers;
|
_layers = layers;
|
||||||
_ledger = ledger;
|
_ledger = ledger;
|
||||||
_journal = journal;
|
_journal = journal;
|
||||||
_db = db;
|
_db = db;
|
||||||
_gl = gl;
|
_gl = gl;
|
||||||
_glInventoryAccountCode = configuration["Fifo:GlInventoryAccountCode"] ?? string.Empty;
|
_glAccounts = glAccounts;
|
||||||
_glClearingAccountCode = configuration["Fifo:GlClearingAccountCode"] ?? string.Empty;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<StockLayer> CreateInboundLayerAsync(
|
public async Task<StockLayer> CreateInboundLayerAsync(
|
||||||
@@ -273,10 +271,12 @@ public sealed class FifoCostingService : IFifoCostingService
|
|||||||
};
|
};
|
||||||
await _ledger.AddAsync(entry, ct);
|
await _ledger.AddAsync(entry, ct);
|
||||||
|
|
||||||
|
var glCodes = await _glAccounts.GetCodesAsync(ct);
|
||||||
|
|
||||||
// Inbound debits Inventory / credits Clearing; outbound reverses.
|
// Inbound debits Inventory / credits Clearing; outbound reverses.
|
||||||
var (debit, credit) = direction == Direction.In
|
var (debit, credit) = direction == Direction.In
|
||||||
? (_glInventoryAccountCode, _glClearingAccountCode)
|
? (glCodes[GlAccountRole.Inventory], glCodes[GlAccountRole.Clearing])
|
||||||
: (_glClearingAccountCode, _glInventoryAccountCode);
|
: (glCodes[GlAccountRole.Clearing], glCodes[GlAccountRole.Inventory]);
|
||||||
|
|
||||||
// Local read-only stub kept for the journal-entries listing endpoint (FR-STK-13).
|
// Local read-only stub kept for the journal-entries listing endpoint (FR-STK-13).
|
||||||
await _journal.AddAsync(new JournalEntryStub
|
await _journal.AddAsync(new JournalEntryStub
|
||||||
|
|||||||
Reference in New Issue
Block a user