Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d413ad4064 | |||
| 9b8c2493ad | |||
| 99b669395e | |||
| 4cc6a546c7 | |||
| 8835ba9500 | |||
| 251b23f4b5 | |||
| 283a56723f | |||
| 9a38cc1131 | |||
| 1c5b05e315 | |||
| efa4ad3770 | |||
| 6528fc8d9d | |||
| ae6a87022d | |||
| ee9fa40edf | |||
| b1b0fe4bac |
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ERPCore\ERPCore.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,152 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Notifications;
|
||||
using ERPCore.Repositories;
|
||||
using ERPCore.Services;
|
||||
using ERPCore.System.Errors;
|
||||
using ERPCore.Tests.TestSupport;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Tests;
|
||||
|
||||
public class NotificationConfigServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetAsync_MergesSavedValuesWithDiscoveredModules()
|
||||
{
|
||||
var dbName = Guid.NewGuid().ToString("N");
|
||||
await using var db = NotificationConfigTestSupport.CreateContext(dbName);
|
||||
await NotificationConfigTestSupport.SeedAsync(db, new[]
|
||||
{
|
||||
new NotificationConfigModule
|
||||
{
|
||||
ModuleKey = "sale.create",
|
||||
EmailEnabled = true,
|
||||
SmsEnabled = false,
|
||||
InAppEnabled = true
|
||||
}
|
||||
});
|
||||
|
||||
var service = CreateService(db);
|
||||
|
||||
var result = await service.GetAsync();
|
||||
|
||||
Assert.Equal(7u, result.RowVersion);
|
||||
Assert.Contains(result.Value.Modules, m => m.ModuleKey == "sale.create" && m.EmailEnabled && !m.SmsEnabled && m.InAppEnabled);
|
||||
Assert.Contains(result.Value.Modules, m => m.ModuleKey == "grn.confirmed" && !m.EmailEnabled && !m.SmsEnabled && !m.InAppEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_PersistsEmptyAndMultiChannelSelections()
|
||||
{
|
||||
var dbName = Guid.NewGuid().ToString("N");
|
||||
await using var db = NotificationConfigTestSupport.CreateContext(dbName);
|
||||
await NotificationConfigTestSupport.SeedAsync(db);
|
||||
|
||||
var service = CreateService(db);
|
||||
var catalog = new NotificationModuleCatalog();
|
||||
var request = new ERPCore.Dtos.Notifications.UpdateNotificationConfigRequest
|
||||
{
|
||||
Modules = catalog.GetAll().Select((module, index) => new ERPCore.Dtos.Notifications.UpdateNotificationModuleRequest
|
||||
{
|
||||
ModuleKey = module.Key,
|
||||
EmailEnabled = index == 0,
|
||||
SmsEnabled = index == 1,
|
||||
InAppEnabled = index == 1
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
var updated = await service.UpdateAsync(request, 7);
|
||||
|
||||
Assert.Equal(7u, updated.RowVersion);
|
||||
Assert.Contains(updated.Value.Modules, m => m.ModuleKey == catalog.GetAll()[0].Key && m.EmailEnabled && !m.SmsEnabled && !m.InAppEnabled);
|
||||
Assert.Contains(updated.Value.Modules, m => m.ModuleKey == catalog.GetAll()[1].Key && !m.EmailEnabled && m.SmsEnabled && m.InAppEnabled);
|
||||
|
||||
await using var verifyDb = NotificationConfigTestSupport.CreateContext(dbName);
|
||||
var persisted = await verifyDb.NotificationConfigModules.AsNoTracking().ToListAsync();
|
||||
Assert.Contains(persisted, m => m.ModuleKey == catalog.GetAll()[0].Key && m.EmailEnabled && !m.SmsEnabled && !m.InAppEnabled);
|
||||
Assert.Contains(persisted, m => m.ModuleKey == catalog.GetAll()[1].Key && !m.EmailEnabled && m.SmsEnabled && m.InAppEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveEnabledChannelsAsync_ReturnsEmptyWhenNothingEnabled()
|
||||
{
|
||||
var dbName = Guid.NewGuid().ToString("N");
|
||||
await using var db = NotificationConfigTestSupport.CreateContext(dbName);
|
||||
await NotificationConfigTestSupport.SeedAsync(db, new[]
|
||||
{
|
||||
new NotificationConfigModule
|
||||
{
|
||||
ModuleKey = "sale.create",
|
||||
EmailEnabled = false,
|
||||
SmsEnabled = false,
|
||||
InAppEnabled = false
|
||||
}
|
||||
});
|
||||
|
||||
var service = CreateService(db);
|
||||
|
||||
var channels = await service.ResolveEnabledChannelsAsync("sale.create");
|
||||
|
||||
Assert.Empty(channels);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveEnabledChannelsAsync_ReturnsMultipleChannels()
|
||||
{
|
||||
var dbName = Guid.NewGuid().ToString("N");
|
||||
await using var db = NotificationConfigTestSupport.CreateContext(dbName);
|
||||
await NotificationConfigTestSupport.SeedAsync(db, new[]
|
||||
{
|
||||
new NotificationConfigModule
|
||||
{
|
||||
ModuleKey = "sale.create",
|
||||
EmailEnabled = true,
|
||||
SmsEnabled = true,
|
||||
InAppEnabled = false
|
||||
}
|
||||
});
|
||||
|
||||
var service = CreateService(db);
|
||||
|
||||
var channels = await service.ResolveEnabledChannelsAsync("sale.create");
|
||||
|
||||
Assert.Equal(new[] { NotificationChannel.Email, NotificationChannel.Sms }, channels);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_MapsConcurrencyFailuresToDomainException()
|
||||
{
|
||||
var dbName = Guid.NewGuid().ToString("N");
|
||||
await using var db = NotificationConfigTestSupport.CreateContext(dbName);
|
||||
await NotificationConfigTestSupport.SeedAsync(db);
|
||||
|
||||
var service = CreateService(db, new ThrowingUnitOfWork());
|
||||
var catalog = new NotificationModuleCatalog();
|
||||
var request = new ERPCore.Dtos.Notifications.UpdateNotificationConfigRequest
|
||||
{
|
||||
Modules = catalog.GetAll().Select(module => new ERPCore.Dtos.Notifications.UpdateNotificationModuleRequest
|
||||
{
|
||||
ModuleKey = module.Key,
|
||||
EmailEnabled = false,
|
||||
SmsEnabled = false,
|
||||
InAppEnabled = false
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(request, 7));
|
||||
Assert.Equal(ErrorCodes.ConcurrencyConflict, ex.Code);
|
||||
Assert.Equal(412, ex.StatusCode);
|
||||
}
|
||||
|
||||
private static NotificationConfigService CreateService(ERPCore.Infra.Persistence.ErpDbContext db, ERPCore.Infra.UoW.IUnitOfWork? uow = null)
|
||||
{
|
||||
var configRepo = new Repository<NotificationConfig>(db);
|
||||
var moduleRepo = new Repository<NotificationConfigModule>(db);
|
||||
return new NotificationConfigService(
|
||||
configRepo,
|
||||
moduleRepo,
|
||||
new NotificationModuleCatalog(),
|
||||
new TestCurrentUser(),
|
||||
uow ?? new ERPCore.Infra.UoW.UnitOfWork(db));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using ERPCore.Services;
|
||||
|
||||
namespace ERPCore.Tests;
|
||||
|
||||
public class NotificationModuleCatalogTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetAll_ReturnsStableUniqueModuleKeys()
|
||||
{
|
||||
var catalog = new NotificationModuleCatalog();
|
||||
|
||||
Assert.NotEmpty(catalog.GetAll());
|
||||
Assert.Contains(catalog.GetAll(), m => m.Key == "sale.create");
|
||||
Assert.Contains(catalog.GetAll(), m => m.Key == "grn.confirmed");
|
||||
Assert.Equal(catalog.GetAll().Count, catalog.GetAll().Select(m => m.Key).Distinct(StringComparer.OrdinalIgnoreCase).Count());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
|
||||
namespace ERPCore.Tests.TestSupport;
|
||||
|
||||
internal sealed class TestCurrentUser : ICurrentUser
|
||||
{
|
||||
public string UserId => "system";
|
||||
public int AuditUserId => User.SystemUserId;
|
||||
public bool IsAuthenticated => false;
|
||||
}
|
||||
|
||||
internal sealed class ThrowingUnitOfWork : ERPCore.Infra.UoW.IUnitOfWork
|
||||
{
|
||||
public Task<int> SaveChangesAsync(CancellationToken ct = default)
|
||||
=> throw new DbUpdateConcurrencyException("Test concurrency conflict.");
|
||||
|
||||
public Task ExecuteInTransactionAsync(Func<CancellationToken, Task> action, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<T> ExecuteInTransactionAsync<T>(Func<CancellationToken, Task<T>> action, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
|
||||
internal static class NotificationConfigTestSupport
|
||||
{
|
||||
private static readonly InMemoryDatabaseRoot Root = new();
|
||||
|
||||
public static ErpDbContext CreateContext(string name)
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ErpDbContext>()
|
||||
.UseInMemoryDatabase(name, Root)
|
||||
.Options;
|
||||
|
||||
return new ErpDbContext(options, new TestCurrentUser());
|
||||
}
|
||||
|
||||
public static async Task SeedAsync(ErpDbContext db, IEnumerable<NotificationConfigModule>? modules = null)
|
||||
{
|
||||
db.Users.Add(new User
|
||||
{
|
||||
UserId = User.SystemUserId,
|
||||
Username = "system",
|
||||
DisplayName = "System"
|
||||
});
|
||||
|
||||
var config = new NotificationConfig
|
||||
{
|
||||
NotificationConfigId = NotificationConfig.SingletonId,
|
||||
UpdatedBy = User.SystemUserId,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
RowVersion = 7,
|
||||
};
|
||||
|
||||
if (modules is not null)
|
||||
{
|
||||
foreach (var module in modules)
|
||||
{
|
||||
module.NotificationConfigId = NotificationConfig.SingletonId;
|
||||
config.Modules.Add(module);
|
||||
}
|
||||
}
|
||||
|
||||
db.NotificationConfig.Add(config);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,14 @@ public sealed class BundleSalesController : ApiControllerBase
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("templates")]
|
||||
[ProducesResponseType(typeof(BundleSaleTemplateDto), StatusCodes.Status201Created)]
|
||||
public async Task<ActionResult<BundleSaleTemplateDto>> CreateTemplate([FromBody] CreateBundleSaleTemplateRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _bundles.CreateTemplateAsync(request, ct);
|
||||
return Created($"/api/v1/bundle-sales/templates/{result.BundleSaleTemplateId}", result);
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<BundleSaleSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<BundleSaleSummaryDto>>> List(
|
||||
|
||||
@@ -11,8 +11,13 @@ namespace ERPCore.Controllers;
|
||||
public sealed class GrnsController : ApiControllerBase
|
||||
{
|
||||
private readonly IGrnService _grns;
|
||||
private readonly IGrnPaymentService _payments;
|
||||
|
||||
public GrnsController(IGrnService grns) => _grns = grns;
|
||||
public GrnsController(IGrnService grns, IGrnPaymentService payments)
|
||||
{
|
||||
_grns = grns;
|
||||
_payments = payments;
|
||||
}
|
||||
|
||||
/// <summary>List GRNs, newest first.</summary>
|
||||
[HttpGet]
|
||||
@@ -59,4 +64,23 @@ public sealed class GrnsController : ApiControllerBase
|
||||
public async Task<ActionResult<ReleaseLineResultDto>> Release(
|
||||
int grnId, int grnLineId, [FromBody] ReleaseLineRequest request, CancellationToken ct)
|
||||
=> Ok(await _grns.ReleaseLineAsync(grnId, grnLineId, request.Action, ct));
|
||||
|
||||
/// <summary>Pay the vendor against this GRN's balance, in full or in installments; posts a real GL journal entry.</summary>
|
||||
[HttpPost("{grnId:int}/payments")]
|
||||
[ProducesResponseType(typeof(GrnPaymentDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<GrnPaymentDto>> Pay(int grnId, [FromBody] CreateGrnPaymentRequest request, CancellationToken ct)
|
||||
{
|
||||
var dto = await _payments.PayAsync(grnId, request, ct);
|
||||
return Created($"/api/v1/grns/{grnId}/payments/{dto.GrnPaymentId}", dto);
|
||||
}
|
||||
|
||||
/// <summary>Payment history for this GRN, newest first.</summary>
|
||||
[HttpGet("{grnId:int}/payments")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<GrnPaymentDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<IReadOnlyList<GrnPaymentDto>>> ListPayments(int grnId, CancellationToken ct)
|
||||
=> Ok(await _payments.ListAsync(grnId, ct));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using ERPCore.Dtos.Notifications;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
[Route("api/v1/notification-config")]
|
||||
public sealed class NotificationConfigController : ApiControllerBase
|
||||
{
|
||||
private readonly INotificationConfigService _config;
|
||||
|
||||
public NotificationConfigController(INotificationConfigService config) => _config = config;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(NotificationConfigDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<NotificationConfigDto>> Get(CancellationToken ct)
|
||||
{
|
||||
var result = await _config.GetAsync(ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPut]
|
||||
[ProducesResponseType(typeof(NotificationConfigDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<NotificationConfigDto>> Update([FromBody] UpdateNotificationConfigRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _config.UpdateAsync(request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,19 @@ public sealed class ProductionRunsController : ApiControllerBase
|
||||
CancellationToken ct)
|
||||
=> Ok(await _runs.ListAsync(query, status, templateId, warehouseId, ct));
|
||||
|
||||
/// <summary>Produced-items report (FR-MFG-13): one row per run that has produced
|
||||
/// something, plus a summary total over every filtered row.</summary>
|
||||
[HttpGet("produced")]
|
||||
[ProducesResponseType(typeof(ProducedListResponseDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ProducedListResponseDto>> ListProduced(
|
||||
[FromQuery] DateOnly? from,
|
||||
[FromQuery] DateOnly? to,
|
||||
[FromQuery] int? templateId,
|
||||
[FromQuery] int? warehouseId,
|
||||
[FromQuery] ProductionRunStatus? status,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _runs.ListProducedAsync(from, to, templateId, warehouseId, status, ct));
|
||||
|
||||
[HttpGet("{runId:int}")]
|
||||
[ProducesResponseType(typeof(RunGraphDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Sales;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Cashier day-end close-out — locks a cashier's Posted sales slips for a business
|
||||
/// date and posts one consolidated GL journal entry for the day (docs/14 Sales API).</summary>
|
||||
[Route("api/v1/sales-day-end")]
|
||||
public sealed class SalesDayEndController : ApiControllerBase
|
||||
{
|
||||
private readonly ISalesDayEndService _dayEnds;
|
||||
|
||||
public SalesDayEndController(ISalesDayEndService dayEnds) => _dayEnds = dayEnds;
|
||||
|
||||
/// <summary>List day-end closes, newest first.</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<SalesDayEndSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<SalesDayEndSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] int? cashierUserId, CancellationToken ct)
|
||||
=> Ok(await _dayEnds.ListAsync(query, cashierUserId, ct));
|
||||
|
||||
/// <summary>What closing this cashier/date right now would include (or the existing close's totals, if already closed).</summary>
|
||||
[HttpGet("preview")]
|
||||
[ProducesResponseType(typeof(SalesDayEndPreviewDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<SalesDayEndPreviewDto>> Preview(
|
||||
[FromQuery] int cashierUserId, [FromQuery] DateOnly? businessDate, CancellationToken ct)
|
||||
=> Ok(await _dayEnds.PreviewAsync(cashierUserId, businessDate ?? DateOnly.FromDateTime(DateTime.UtcNow), ct));
|
||||
|
||||
[HttpGet("{salesDayEndId:int}")]
|
||||
[ProducesResponseType(typeof(SalesDayEndDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<SalesDayEndDto>> GetById(int salesDayEndId, CancellationToken ct)
|
||||
{
|
||||
var dto = await _dayEnds.GetAsync(salesDayEndId, ct);
|
||||
return dto is null ? NotFound() : Ok(dto);
|
||||
}
|
||||
|
||||
/// <summary>Close the day: lock every Posted slip for (CashierUserId, BusinessDate) and post the
|
||||
/// consolidated GL journal entry. Idempotent — closing an already-closed date replays it.</summary>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(SalesDayEndDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<SalesDayEndDto>> Close([FromBody] CreateSalesDayEndRequest request, CancellationToken ct)
|
||||
{
|
||||
var dto = await _dayEnds.CloseAsync(request, ct);
|
||||
return Created($"/api/v1/sales-day-end/{dto.SalesDayEndId}", dto);
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,13 @@ namespace ERPCore.Controllers;
|
||||
public sealed class SalesInvoicesController : ApiControllerBase
|
||||
{
|
||||
private readonly ISalesInvoiceService _invoices;
|
||||
private readonly ISalesInvoicePaymentService _payments;
|
||||
|
||||
public SalesInvoicesController(ISalesInvoiceService invoices) => _invoices = invoices;
|
||||
public SalesInvoicesController(ISalesInvoiceService invoices, ISalesInvoicePaymentService payments)
|
||||
{
|
||||
_invoices = invoices;
|
||||
_payments = payments;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<SalesInvoiceSummaryDto>), StatusCodes.Status200OK)]
|
||||
@@ -70,4 +75,23 @@ public sealed class SalesInvoicesController : ApiControllerBase
|
||||
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<SalesInvoiceDto>> Cancel(int salesInvoiceId, CancellationToken ct)
|
||||
=> Ok(await _invoices.CancelAsync(salesInvoiceId, ct));
|
||||
|
||||
/// <summary>Pay the customer's balance against this invoice, in full or in installments; posts a real GL journal entry.</summary>
|
||||
[HttpPost("{salesInvoiceId:int}/payments")]
|
||||
[ProducesResponseType(typeof(SalesInvoicePaymentDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<SalesInvoicePaymentDto>> Pay(int salesInvoiceId, [FromBody] CreateSalesInvoicePaymentRequest request, CancellationToken ct)
|
||||
{
|
||||
var dto = await _payments.PayAsync(salesInvoiceId, request, ct);
|
||||
return Created($"/api/v1/sales-invoices/{salesInvoiceId}/payments/{dto.SalesInvoicePaymentId}", dto);
|
||||
}
|
||||
|
||||
/// <summary>Payment history for this invoice, newest first.</summary>
|
||||
[HttpGet("{salesInvoiceId:int}/payments")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<SalesInvoicePaymentDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<IReadOnlyList<SalesInvoicePaymentDto>>> ListPayments(int salesInvoiceId, CancellationToken ct)
|
||||
=> Ok(await _payments.ListAsync(salesInvoiceId, ct));
|
||||
}
|
||||
|
||||
@@ -20,5 +20,9 @@ public static class DocumentTypes
|
||||
public const string SalesInvoice = "SI";
|
||||
public const string SalesSlip = "SSL";
|
||||
public const string BundleSale = "BND";
|
||||
public const string BundleSaleTemplate = "TPL";
|
||||
public const string SalesReturn = "SRET";
|
||||
|
||||
/// <summary>Cashier day-end close-out (Sales) — <c>DEND-2026-00001</c>.</summary>
|
||||
public const string SalesDayEnd = "DEND";
|
||||
}
|
||||
|
||||
@@ -29,5 +29,11 @@ public class BundleSale
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
public int ConcurrencyStamp { get; set; }
|
||||
|
||||
/// <summary>Set once this bundle's business date is closed by <see cref="SalesDayEnd"/> — a
|
||||
/// closed bundle sale is frozen (no further edits). Same role as <see cref="SalesSlip.DayEndId"/>;
|
||||
/// a bundle sale is a cashier document exactly like a sales slip, just priced as a set.</summary>
|
||||
public int? DayEndId { get; set; }
|
||||
public SalesDayEnd? DayEnd { get; set; }
|
||||
|
||||
public ICollection<BundleSaleLine> Lines { get; set; } = new List<BundleSaleLine>();
|
||||
}
|
||||
|
||||
@@ -30,8 +30,18 @@ public class Grn
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? PostedAt { get; set; }
|
||||
|
||||
/// <summary>Journal number of the real GL journal entry posted for this GRN's receipt (set on confirm).</summary>
|
||||
public string? GlJournalNo { get; set; }
|
||||
public DateTime? GlPostedAt { get; set; }
|
||||
|
||||
/// <summary>Sum of vendor payments made against this GRN's total payable (<see cref="GrnLine.LineTotal"/>).</summary>
|
||||
public decimal PaidAmount { get; set; }
|
||||
/// <summary>Total payable minus <see cref="PaidAmount"/>; installments accrue against this until it reaches zero.</summary>
|
||||
public decimal BalanceAmount { get; set; }
|
||||
|
||||
/// <summary>PostgreSQL xmin-backed optimistic concurrency token.</summary>
|
||||
public uint RowVersion { get; set; }
|
||||
|
||||
public ICollection<GrnLine> Lines { get; set; } = new List<GrnLine>();
|
||||
public ICollection<GrnPayment> Payments { get; set; } = new List<GrnPayment>();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A vendor payment made against a confirmed GRN's balance (installments allowed —
|
||||
/// see GrnPaymentService.PayAsync). Posts its own real GL journal entry (Debit GRN
|
||||
/// Clearing / Credit the selected bank-or-cash account) before being recorded here.
|
||||
/// </summary>
|
||||
public class GrnPayment
|
||||
{
|
||||
public int GrnPaymentId { get; set; }
|
||||
|
||||
public int GrnId { get; set; }
|
||||
public Grn? Grn { get; set; }
|
||||
|
||||
public decimal Amount { get; set; }
|
||||
public DateTime PaymentDate { get; set; }
|
||||
|
||||
/// <summary>GL's numeric id for the bank/cash account the payment was made from.</summary>
|
||||
public long GlBankAccountId { get; set; }
|
||||
/// <summary>Snapshot of the account's display name at payment time (GL account lists have no local FK).</summary>
|
||||
public string BankAccountName { get; set; } = string.Empty;
|
||||
|
||||
public string? Reference { get; set; }
|
||||
|
||||
/// <summary>Journal number of the real GL journal entry this payment posted.</summary>
|
||||
public string? GlJournalNo { get; set; }
|
||||
|
||||
public int CreatedBy { get; set; }
|
||||
public User? Creator { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
@@ -44,11 +44,12 @@ public class Item
|
||||
public string? TaxClass { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional fixed selling price used by Sales only. <c>null</c> means "use stock value"
|
||||
/// (the item is sold at its FIFO stock cost at sale time); a value is the fixed sale price.
|
||||
/// Never enters costing/GRN/FIFO (docs/10 Part C.1, C.9).
|
||||
/// Optional selling price range and flat price used by Sales only.
|
||||
/// <c>null</c> values mean the field is not set. These values never enter costing/GRN/FIFO.
|
||||
/// </summary>
|
||||
public decimal? SalePrice { get; set; }
|
||||
public decimal? SellingMinPrice { get; set; }
|
||||
public decimal? SellingMaxPrice { get; set; }
|
||||
public decimal? SellingFlatPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// How much one pack holds, as the user entered it — <c>500</c> with
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Notification configuration (new settings module) - singleton root row.
|
||||
/// </summary>
|
||||
public class NotificationConfig
|
||||
{
|
||||
public const int SingletonId = 1;
|
||||
|
||||
public int NotificationConfigId { get; set; }
|
||||
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
public int? UpdatedBy { get; set; }
|
||||
public User? UpdatedByUser { get; set; }
|
||||
|
||||
public uint RowVersion { get; set; }
|
||||
|
||||
public ICollection<NotificationConfigModule> Modules { get; set; } = new List<NotificationConfigModule>();
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
public class NotificationConfigModule
|
||||
{
|
||||
public int NotificationConfigModuleId { get; set; }
|
||||
|
||||
public int NotificationConfigId { get; set; }
|
||||
public NotificationConfig? NotificationConfig { get; set; }
|
||||
|
||||
public string ModuleKey { get; set; } = string.Empty;
|
||||
public bool EmailEnabled { get; set; }
|
||||
public bool SmsEnabled { get; set; }
|
||||
public bool InAppEnabled { get; set; }
|
||||
}
|
||||
@@ -1,22 +1,24 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Product configuration (FR-MD-11) — a <b>singleton row</b> (single-tenant, docs/00-CORE §1)
|
||||
/// Product configuration (FR-MD-11) - a singleton row (single-tenant, docs/00-CORE §1)
|
||||
/// gating optional product master-data features.
|
||||
/// <para>
|
||||
/// <see cref="SubcategoriesEnabled"/> and <see cref="BrandsEnabled"/> are enforced
|
||||
/// server-side: an Item write carrying a subcategory/brand while the flag is off is
|
||||
/// rejected with <c>CONFIG_DISABLED</c>. <see cref="ItemTypesEnabled"/> is
|
||||
/// <b>advisory only</b> — items carry no item-type reference (see <see cref="ItemType"/>),
|
||||
/// so there is nothing on a write to reject; the frontend honours it by hiding the
|
||||
/// builder's type section. Reads are never gated, so existing data stays visible after a
|
||||
/// flag is switched off.
|
||||
/// rejected with <c>CONFIG_DISABLED</c>. <see cref="ItemTypesEnabled"/> is advisory only
|
||||
/// - items carry no item-type reference (see <see cref="ItemType"/>), so there is nothing
|
||||
/// on a write to reject; the frontend honours it by hiding the variant builder.
|
||||
/// <see cref="SellingPricesEnabled"/>, <see cref="SerialNumbersEnabled"/>,
|
||||
/// <see cref="BatchListsEnabled"/> and <see cref="WarrantyPeriodsEnabled"/> are enforced
|
||||
/// on item writes and drive the create/edit screens. Reads are never gated, so existing
|
||||
/// data stays visible after a flag is switched off.
|
||||
/// </para>
|
||||
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
||||
/// </summary>
|
||||
public class ProductConfig
|
||||
{
|
||||
/// <summary>Always 1 — the singleton row's id.</summary>
|
||||
/// <summary>Always 1 - the singleton row's id.</summary>
|
||||
public const int SingletonId = 1;
|
||||
|
||||
public int ConfigId { get; set; }
|
||||
@@ -24,6 +26,10 @@ public class ProductConfig
|
||||
public bool SubcategoriesEnabled { get; set; } = true;
|
||||
public bool BrandsEnabled { get; set; } = true;
|
||||
public bool ItemTypesEnabled { get; set; } = true;
|
||||
public bool SellingPricesEnabled { get; set; } = true;
|
||||
public bool SerialNumbersEnabled { get; set; } = true;
|
||||
public bool BatchListsEnabled { get; set; } = true;
|
||||
public bool WarrantyPeriodsEnabled { get; set; } = true;
|
||||
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
|
||||
@@ -28,5 +28,12 @@ public class PurchaseReturn
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
/// <summary>Journal number of the real GL journal entry posted for this return (set on create — it auto-posts).
|
||||
/// Reverses the Inventory/Clearing lines a Grn posts — <see cref="PurchaseReturnLine"/> carries no unit
|
||||
/// price/VAT of its own, so it can't reverse a VAT-recoverable line the way a real credit note would.</summary>
|
||||
public string? GlJournalNo { get; set; }
|
||||
public DateTime? GlPostedAt { get; set; }
|
||||
|
||||
public ICollection<PurchaseReturnLine> Lines { get; set; } = new List<PurchaseReturnLine>();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A cashier's end-of-day close-out. Aggregates every <see cref="SalesSlip"/> the
|
||||
/// cashier posted on <see cref="BusinessDate"/>, freezes them against this record
|
||||
/// (<see cref="SalesSlip.DayEndId"/>) so a slip can only ever belong to one day-end,
|
||||
/// and posts one consolidated GL journal entry for the day's revenue/tax/COGS —
|
||||
/// the sales-side equivalent of how <see cref="Grn"/> posts per receipt. Unique per
|
||||
/// (CashierUserId, BusinessDate): closing twice replays the existing record instead
|
||||
/// of creating a second one (same idempotent-replay pattern as Grn.ConfirmAsync).
|
||||
/// </summary>
|
||||
public class SalesDayEnd
|
||||
{
|
||||
public int SalesDayEndId { get; set; }
|
||||
public string DocNo { get; set; } = string.Empty;
|
||||
|
||||
public int CashierUserId { get; set; }
|
||||
public User? CashierUser { get; set; }
|
||||
|
||||
/// <summary>The calendar date being closed (matched against each slip's <see cref="SalesSlip.SlipDate"/>, UTC).</summary>
|
||||
public DateOnly BusinessDate { get; set; }
|
||||
|
||||
public int SlipCount { get; set; }
|
||||
public int BundleCount { get; set; }
|
||||
public decimal Subtotal { get; set; }
|
||||
public decimal DiscountTotal { get; set; }
|
||||
public decimal TaxTotal { get; set; }
|
||||
public decimal GrandTotal { get; set; }
|
||||
|
||||
/// <summary>Sum of the FIFO cost consumed for these slips (from <see cref="StockLedger"/>), for the COGS GL lines.</summary>
|
||||
public decimal CostOfGoodsSold { get; set; }
|
||||
|
||||
/// <summary>Journal number of the real GL journal entry posted for this close (null when there was nothing to post).</summary>
|
||||
public string? GlJournalNo { get; set; }
|
||||
public DateTime? GlPostedAt { get; set; }
|
||||
|
||||
public int ClosedBy { get; set; }
|
||||
public User? ClosedByUser { get; set; }
|
||||
public DateTime ClosedAt { get; set; }
|
||||
|
||||
/// <summary>PostgreSQL xmin-backed optimistic concurrency token.</summary>
|
||||
public uint RowVersion { get; set; }
|
||||
|
||||
public ICollection<SalesSlip> Slips { get; set; } = new List<SalesSlip>();
|
||||
public ICollection<BundleSale> BundleSales { get; set; } = new List<BundleSale>();
|
||||
}
|
||||
@@ -29,6 +29,14 @@ public class SalesInvoice
|
||||
public decimal PaidAmount { get; set; }
|
||||
public decimal BalanceAmount { get; set; }
|
||||
|
||||
/// <summary>Journal number of the revenue-recognition GL journal entry posted when this
|
||||
/// invoice is Posted (Debit Accounts Receivable / Credit Sales Revenue + Tax, plus COGS —
|
||||
/// see SalesPostingService.PostInvoiceAsync). Unlike a SalesSlip, an invoice posts its own
|
||||
/// GL entry immediately rather than waiting for Sales Day End, since invoices aren't a
|
||||
/// cashier document and don't go through that close-out.</summary>
|
||||
public string? GlJournalNo { get; set; }
|
||||
public DateTime? GlPostedAt { get; set; }
|
||||
|
||||
public int CreatedBy { get; set; }
|
||||
public User? Creator { get; set; }
|
||||
|
||||
@@ -38,4 +46,5 @@ public class SalesInvoice
|
||||
public uint RowVersion { get; set; }
|
||||
|
||||
public ICollection<SalesInvoiceLine> Lines { get; set; } = new List<SalesInvoiceLine>();
|
||||
public ICollection<SalesInvoicePayment> Payments { get; set; } = new List<SalesInvoicePayment>();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A customer payment made against a Posted sales invoice's balance (installments
|
||||
/// allowed — see SalesInvoicePaymentService.PayAsync). Posts its own real GL journal
|
||||
/// entry (Debit the selected bank-or-cash account / Credit Accounts Receivable) before
|
||||
/// being recorded here — the mirror image of <see cref="GrnPayment"/>.
|
||||
/// </summary>
|
||||
public class SalesInvoicePayment
|
||||
{
|
||||
public int SalesInvoicePaymentId { get; set; }
|
||||
|
||||
public int SalesInvoiceId { get; set; }
|
||||
public SalesInvoice? SalesInvoice { get; set; }
|
||||
|
||||
public decimal Amount { get; set; }
|
||||
public DateTime PaymentDate { get; set; }
|
||||
|
||||
/// <summary>GL's numeric id for the bank/cash account the payment was received into.</summary>
|
||||
public long GlBankAccountId { get; set; }
|
||||
/// <summary>Snapshot of the account's display name at payment time (GL account lists have no local FK).</summary>
|
||||
public string BankAccountName { get; set; } = string.Empty;
|
||||
|
||||
public string? Reference { get; set; }
|
||||
|
||||
/// <summary>Journal number of the real GL journal entry this payment posted.</summary>
|
||||
public string? GlJournalNo { get; set; }
|
||||
|
||||
public int CreatedBy { get; set; }
|
||||
public User? Creator { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
@@ -28,5 +28,11 @@ public class SalesReturn
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
/// <summary>Journal number of the real GL journal entry posted for this return (set on create — it auto-posts).
|
||||
/// Reverses Inventory/COGS only — <see cref="SalesReturnLine"/> carries no unit price, so revenue/tax aren't
|
||||
/// reversed here (they'd need a per-line price this document doesn't capture).</summary>
|
||||
public string? GlJournalNo { get; set; }
|
||||
public DateTime? GlPostedAt { get; set; }
|
||||
|
||||
public ICollection<SalesReturnLine> Lines { get; set; } = new List<SalesReturnLine>();
|
||||
}
|
||||
|
||||
@@ -27,6 +27,10 @@ public class SalesSlip
|
||||
public decimal PaidAmount { get; set; }
|
||||
public decimal BalanceAmount { get; set; }
|
||||
|
||||
/// <summary>Set once this slip's business date is closed by <see cref="SalesDayEnd"/> — a closed slip is frozen (no further edits).</summary>
|
||||
public int? DayEndId { get; set; }
|
||||
public SalesDayEnd? DayEnd { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
|
||||
@@ -27,5 +27,10 @@ public class StockAdjustment
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public uint RowVersion { get; set; }
|
||||
|
||||
/// <summary>Journal number of the real GL journal entry posted for this adjustment (set on create — it
|
||||
/// auto-posts). Increases and decreases post separately (never netted) so gains/losses stay visible.</summary>
|
||||
public string? GlJournalNo { get; set; }
|
||||
public DateTime? GlPostedAt { get; set; }
|
||||
|
||||
public ICollection<StockAdjustmentLine> Lines { get; set; } = new List<StockAdjustmentLine>();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace ERPCore.Domain.Notifications.Modules;
|
||||
|
||||
[NotificationModule("sale.create", "Sale Created", "Raised when a sales document is created.", 10)]
|
||||
public sealed class SaleCreateNotificationModule;
|
||||
|
||||
[NotificationModule("sale.posted", "Sale Posted", "Raised when a sales document is posted.", 20)]
|
||||
public sealed class SalePostedNotificationModule;
|
||||
|
||||
[NotificationModule("grn.created", "GRN Created", "Raised when a goods receipt note is created.", 30)]
|
||||
public sealed class GrnCreatedNotificationModule;
|
||||
|
||||
[NotificationModule("grn.confirmed", "GRN Confirmed", "Raised when a goods receipt note is confirmed.", 40)]
|
||||
public sealed class GrnConfirmedNotificationModule;
|
||||
|
||||
[NotificationModule("purchase.order.created", "Purchase Order Created", "Raised when a purchase order is created.", 50)]
|
||||
public sealed class PurchaseOrderCreatedNotificationModule;
|
||||
|
||||
[NotificationModule("purchase.order.approved", "Purchase Order Approved", "Raised when a purchase order is approved.", 60)]
|
||||
public sealed class PurchaseOrderApprovedNotificationModule;
|
||||
|
||||
[NotificationModule("purchase.return.created", "Purchase Return Created", "Raised when a purchase return is created.", 70)]
|
||||
public sealed class PurchaseReturnCreatedNotificationModule;
|
||||
|
||||
[NotificationModule("stock.adjustment.created", "Stock Adjustment Created", "Raised when a stock adjustment is created.", 80)]
|
||||
public sealed class StockAdjustmentCreatedNotificationModule;
|
||||
|
||||
[NotificationModule("stock.transfer.created", "Stock Transfer Created", "Raised when a stock transfer is created.", 90)]
|
||||
public sealed class StockTransferCreatedNotificationModule;
|
||||
|
||||
[NotificationModule("item.created", "Item Created", "Raised when an item master record is created.", 100)]
|
||||
public sealed class ItemCreatedNotificationModule;
|
||||
|
||||
[NotificationModule("item.updated", "Item Updated", "Raised when an item master record is updated.", 110)]
|
||||
public sealed class ItemUpdatedNotificationModule;
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ERPCore.Domain.Notifications;
|
||||
|
||||
public enum NotificationChannel
|
||||
{
|
||||
Email,
|
||||
Sms,
|
||||
InApp
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace ERPCore.Domain.Notifications;
|
||||
|
||||
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
|
||||
public sealed class NotificationModuleAttribute : Attribute
|
||||
{
|
||||
public string Key { get; }
|
||||
public string Label { get; }
|
||||
public string Description { get; }
|
||||
public int SortOrder { get; }
|
||||
|
||||
public NotificationModuleAttribute(string key, string label, string description, int sortOrder)
|
||||
{
|
||||
Key = key;
|
||||
Label = label;
|
||||
Description = description;
|
||||
SortOrder = sortOrder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ERPCore.Domain.Notifications;
|
||||
|
||||
public sealed record NotificationModuleDefinition(
|
||||
string Key,
|
||||
string Label,
|
||||
string Description,
|
||||
int SortOrder);
|
||||
@@ -4,10 +4,14 @@ namespace ERPCore.Dtos.Config;
|
||||
|
||||
/// <summary>
|
||||
/// Product configuration resource (docs/11-BACKEND-PHASE1.md §2.8). Singleton.
|
||||
/// <see cref="ItemTypesEnabled"/> is advisory (frontend-honoured) — see the entity docs.
|
||||
/// <see cref="ItemTypesEnabled"/> is advisory (frontend-honoured) - see the entity docs.
|
||||
/// <see cref="SellingPricesEnabled"/>, <see cref="SerialNumbersEnabled"/>,
|
||||
/// <see cref="BatchListsEnabled"/> and <see cref="WarrantyPeriodsEnabled"/> are enforced
|
||||
/// when items are written.
|
||||
/// </summary>
|
||||
public sealed record ProductConfigDto(
|
||||
bool SubcategoriesEnabled, bool BrandsEnabled, bool ItemTypesEnabled,
|
||||
bool SellingPricesEnabled, bool SerialNumbersEnabled, bool BatchListsEnabled, bool WarrantyPeriodsEnabled,
|
||||
DateTime? UpdatedAt, int? UpdatedBy);
|
||||
|
||||
/// <summary>
|
||||
@@ -15,8 +19,8 @@ public sealed record ProductConfigDto(
|
||||
/// <para>
|
||||
/// The flags are <see cref="bool"/>? deliberately: <c>[Required]</c> on a non-nullable bool
|
||||
/// is a no-op (it always has a value), so a body of <c>{}</c> would bind every flag to
|
||||
/// <c>false</c> and silently switch all three features off. Nullable makes the requirement
|
||||
/// actually bind — an omitted flag is a 400, not an accidental disable.
|
||||
/// <c>false</c> and silently switch everything off. Nullable makes the requirement actually
|
||||
/// bind - an omitted flag is a 400, not an accidental disable.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class UpdateProductConfigRequest
|
||||
@@ -24,4 +28,8 @@ public sealed class UpdateProductConfigRequest
|
||||
[Required] public bool? SubcategoriesEnabled { get; set; }
|
||||
[Required] public bool? BrandsEnabled { get; set; }
|
||||
[Required] public bool? ItemTypesEnabled { get; set; }
|
||||
[Required] public bool? SellingPricesEnabled { get; set; }
|
||||
[Required] public bool? SerialNumbersEnabled { get; set; }
|
||||
[Required] public bool? BatchListsEnabled { get; set; }
|
||||
[Required] public bool? WarrantyPeriodsEnabled { get; set; }
|
||||
}
|
||||
|
||||
@@ -16,23 +16,36 @@ public sealed record GrnLineDto(
|
||||
|
||||
public sealed record GrnDto(
|
||||
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
|
||||
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, IReadOnlyList<GrnLineDto> Lines);
|
||||
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, string? GlJournalNo,
|
||||
decimal PaidAmount, decimal BalanceAmount, IReadOnlyList<GrnLineDto> Lines);
|
||||
|
||||
/// <summary>Row shape for <c>GET /grns</c> — line count instead of the lines themselves.</summary>
|
||||
public sealed record GrnSummaryDto(
|
||||
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
|
||||
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount);
|
||||
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount,
|
||||
decimal PaidAmount, decimal BalanceAmount);
|
||||
|
||||
public sealed record CreatedLayerDto(
|
||||
int LayerId, int ItemId, int WarehouseId, int? BatchId,
|
||||
decimal QtyReceived, decimal QtyRemaining, decimal UnitCost, DateTime ReceiptDate);
|
||||
|
||||
public sealed record GrnConfirmResultDto(
|
||||
int GrnId, GrnStatus Status, DateTime PostedAt,
|
||||
int GrnId, GrnStatus Status, DateTime PostedAt, string GlJournalNo, decimal BalanceAmount,
|
||||
IReadOnlyList<CreatedLayerDto> CreatedLayers, IReadOnlyList<int> LedgerRefs, PurchaseOrderStatus? PoStatus);
|
||||
|
||||
public sealed record ReleaseLineResultDto(int GrnLineId, HoldStatus HoldStatus);
|
||||
|
||||
public sealed record GrnPaymentDto(
|
||||
int GrnPaymentId, int GrnId, decimal Amount, DateTime PaymentDate,
|
||||
long GlBankAccountId, string BankAccountName, string? Reference, string? GlJournalNo, DateTime CreatedAt);
|
||||
|
||||
public sealed class CreateGrnPaymentRequest
|
||||
{
|
||||
[Range(0.01, double.MaxValue)] public decimal Amount { get; set; }
|
||||
[Required] public long GlBankAccountId { get; set; }
|
||||
[StringLength(100)] public string? Reference { get; set; }
|
||||
}
|
||||
|
||||
// Requests ----------------------------------------------------------------------
|
||||
|
||||
public sealed class BatchInput
|
||||
|
||||
@@ -10,7 +10,7 @@ public sealed record ItemListItemDto(
|
||||
int ItemId, string Sku, string Name, int CategoryId, int? SubCategoryId, int? BrandId,
|
||||
int BaseUomId, int? DefaultVendorId, StockNature StockNature, TrackingMode TrackingMode,
|
||||
Warranty Warranty, int? WarrantyPeriodMonths,
|
||||
string? TaxClass, decimal? SalePrice,
|
||||
string? TaxClass, decimal? SellingMinPrice, decimal? SellingMaxPrice, decimal? SellingFlatPrice,
|
||||
decimal? ContentQty, MeasureUnit? ContentUnit,
|
||||
decimal? ContentBaseQty, MeasureUnit? ContentBaseUnit,
|
||||
EntityStatus Status);
|
||||
@@ -31,7 +31,7 @@ public sealed record ItemDetailDto(
|
||||
int? SubCategoryId, int? BrandId, int BaseUomId, int? DefaultVendorId,
|
||||
StockNature StockNature, TrackingMode TrackingMode,
|
||||
Warranty Warranty, int? WarrantyPeriodMonths,
|
||||
string? TaxClass, decimal? SalePrice,
|
||||
string? TaxClass, decimal? SellingMinPrice, decimal? SellingMaxPrice, decimal? SellingFlatPrice,
|
||||
decimal? ContentQty, MeasureUnit? ContentUnit,
|
||||
decimal? ContentBaseQty, MeasureUnit? ContentBaseUnit,
|
||||
EntityStatus Status, IReadOnlyList<ItemReorderDto> Reorder,
|
||||
@@ -66,7 +66,9 @@ public sealed class CreateItemRequest
|
||||
public int? WarrantyPeriodMonths { get; set; }
|
||||
[StringLength(20)] public string? TaxClass { get; set; }
|
||||
/// <summary>Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value.</summary>
|
||||
[Range(0, double.MaxValue)] public decimal? SalePrice { get; set; }
|
||||
[Range(0, double.MaxValue)] public decimal? SellingMinPrice { get; set; }
|
||||
[Range(0, double.MaxValue)] public decimal? SellingMaxPrice { get; set; }
|
||||
[Range(0, double.MaxValue)] public decimal? SellingFlatPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// How much one pack holds. Supply with <see cref="ContentUnit"/> or leave both null
|
||||
@@ -96,7 +98,9 @@ public sealed class UpdateItemRequest
|
||||
public int? WarrantyPeriodMonths { get; set; }
|
||||
[StringLength(20)] public string? TaxClass { get; set; }
|
||||
/// <summary>Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value.</summary>
|
||||
[Range(0, double.MaxValue)] public decimal? SalePrice { get; set; }
|
||||
[Range(0, double.MaxValue)] public decimal? SellingMinPrice { get; set; }
|
||||
[Range(0, double.MaxValue)] public decimal? SellingMaxPrice { get; set; }
|
||||
[Range(0, double.MaxValue)] public decimal? SellingFlatPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// How much one pack holds. Supply with <see cref="ContentUnit"/> or leave both null
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace ERPCore.Dtos.Notifications;
|
||||
|
||||
public sealed record NotificationModuleConfigDto(
|
||||
string ModuleKey,
|
||||
string Label,
|
||||
string Description,
|
||||
bool EmailEnabled,
|
||||
bool SmsEnabled,
|
||||
bool InAppEnabled);
|
||||
|
||||
public sealed record NotificationConfigDto(
|
||||
IReadOnlyList<NotificationModuleConfigDto> Modules,
|
||||
DateTime? UpdatedAt,
|
||||
int? UpdatedBy);
|
||||
|
||||
public sealed class UpdateNotificationModuleRequest
|
||||
{
|
||||
[Required, StringLength(100)] public string ModuleKey { get; set; } = string.Empty;
|
||||
[Required] public bool? EmailEnabled { get; set; }
|
||||
[Required] public bool? SmsEnabled { get; set; }
|
||||
[Required] public bool? InAppEnabled { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateNotificationConfigRequest
|
||||
{
|
||||
[Required, MinLength(0)] public List<UpdateNotificationModuleRequest> Modules { get; set; } = new();
|
||||
}
|
||||
@@ -9,12 +9,13 @@ public sealed record PurchaseReturnLineDto(int ReturnLineId, int? GrnLineId, int
|
||||
|
||||
public sealed record PurchaseReturnDto(
|
||||
int ReturnId, string DocNo, int VendorId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
|
||||
int CreatedBy, DateTime CreatedAt, IReadOnlyList<PurchaseReturnLineDto> Lines, IReadOnlyList<int> LedgerRefs);
|
||||
int CreatedBy, DateTime CreatedAt, string? GlJournalNo, DateTime? GlPostedAt,
|
||||
IReadOnlyList<PurchaseReturnLineDto> Lines, IReadOnlyList<int> LedgerRefs);
|
||||
|
||||
/// <summary>Row shape for <c>GET /purchase-returns</c> — no lines/ledgerRefs (those need a per-row query).</summary>
|
||||
public sealed record PurchaseReturnSummaryDto(
|
||||
int ReturnId, string DocNo, int VendorId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
|
||||
int CreatedBy, DateTime CreatedAt, int LineCount);
|
||||
int CreatedBy, DateTime CreatedAt, int LineCount, string? GlJournalNo);
|
||||
|
||||
// Requests ----------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -70,6 +70,35 @@ public sealed record RunEventDto(
|
||||
int EventId, int? RunStageId, RunStageEventType EventType, string? Note,
|
||||
JsonElement? Payload, int UserId, DateTime CreatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// One row of the produced-items report (<c>GET /production-runs/produced</c>) — the
|
||||
/// terminal output of a run that has produced something, costed the same way
|
||||
/// <c>PostReceiptAsync</c> costs the finished layer at approve (FR-MFG-13):
|
||||
/// <c>unitCost = (Σ ConsumedValue − Σ ReturnedValue) / (ProducedQty − ScrappedQty)</c>.
|
||||
/// One row per run — server validation (<see cref="ProductionGraphValidator"/> via
|
||||
/// FR-MFG-05) guarantees exactly one item-bearing output per run, always the terminal
|
||||
/// one, so run and produced line coincide 1:1.
|
||||
/// <para><c>UnitCost</c>/<c>TotalCost</c> are final only once <c>Status</c> is
|
||||
/// <c>Completed</c> — a leftover return can still shrink the pool between the terminal
|
||||
/// stage's Done and its Approve, same caveat as <see cref="CostPoolDto"/>.</para>
|
||||
/// </summary>
|
||||
public sealed record ProducedLineDto(
|
||||
int RunId, string DocNo, ProductionRunStatus Status,
|
||||
int TemplateId, string TemplateName,
|
||||
int WarehouseId, string WarehouseName,
|
||||
int ItemId, string ItemName,
|
||||
decimal ProducedQty, decimal ScrappedQty, decimal GoodQty,
|
||||
decimal UnitCost, decimal TotalCost,
|
||||
DateTime CreatedAt, DateTime? CompletedAt);
|
||||
|
||||
/// <summary>Aggregate footer for the produced-items report — totals over every row the filter
|
||||
/// matched, not just the current page (there is no pagination here, matching the Sales report
|
||||
/// convention of flat, filter-bounded rows).</summary>
|
||||
public sealed record ProducedSummaryDto(
|
||||
int RunCount, decimal TotalProducedQty, decimal TotalScrappedQty, decimal TotalGoodQty, decimal TotalCost);
|
||||
|
||||
public sealed record ProducedListResponseDto(IReadOnlyList<ProducedLineDto> Items, ProducedSummaryDto Summary);
|
||||
|
||||
/// <summary>Full run graph (docs/30 §D.2 <c>GET /production-runs/{id}</c>).</summary>
|
||||
public sealed record RunGraphDto(
|
||||
int RunId, string DocNo, int TemplateId, string TemplateName,
|
||||
@@ -99,8 +128,10 @@ public sealed class CreateRunRequest
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-run scaling override (FR-MFG-08). Only accepted while the stage has not started
|
||||
/// (<c>409 STAGE_NOT_EDITABLE</c>).
|
||||
/// Per-run scaling override (FR-MFG-08). Accepted before the stage starts, and — for a Stock
|
||||
/// input only — while it is running too: raising <see cref="StageQuantityLine.PlannedQty"/>
|
||||
/// draws the extra amount from FIFO, lowering it returns the excess (<c>409
|
||||
/// STAGE_NOT_EDITABLE</c> for every other status).
|
||||
/// </summary>
|
||||
public sealed class UpdateStageQuantitiesRequest
|
||||
{
|
||||
@@ -116,4 +147,11 @@ public sealed class StageQuantityLine
|
||||
|
||||
[Range(0.0001, double.MaxValue)]
|
||||
public decimal PlannedQty { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Required only when this line lowers a running Stock input below what it has already
|
||||
/// consumed — the excess is returned to stock the same way <c>ReturnLeftoverAsync</c>
|
||||
/// does, and that always needs a reason (FR-MFG-14). Ignored otherwise.
|
||||
/// </summary>
|
||||
public int? ReasonCodeId { get; set; }
|
||||
}
|
||||
|
||||
@@ -51,7 +51,6 @@ public sealed class CreateBundleSaleTemplateLineRequest
|
||||
|
||||
public sealed class CreateBundleSaleTemplateRequest
|
||||
{
|
||||
[Required] public string TemplateCode { get; set; } = string.Empty;
|
||||
[Required] public string TemplateName { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
[Required, MinLength(1)] public List<CreateBundleSaleTemplateLineRequest> Lines { get; set; } = new();
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace ERPCore.Dtos.Sales;
|
||||
|
||||
// Responses -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Qty/revenue sold for one item across a closed (or about-to-close) day, for the day-end report.
|
||||
/// Merges sales-slip and bundle-sale lines for the same item into one row.</summary>
|
||||
public sealed record SalesDayEndItemLineDto(int ItemId, string Sku, string Name, decimal Qty, decimal Revenue);
|
||||
|
||||
/// <summary>Read-only visibility into that cashier's Sales Invoices for the date — informational
|
||||
/// only, appended to <see cref="SalesDayEndDto"/> and <see cref="SalesDayEndPreviewDto"/>.
|
||||
/// Invoices post their own GL entry on Confirm (SalesPostingService.PostInvoiceAsync) and have no
|
||||
/// DayEndId, so they're never locked against a close: these fields are computed live from current
|
||||
/// data on every read, unlike the frozen Slip/Bundle totals alongside them.</summary>
|
||||
public sealed record SalesDayEndInvoiceSummaryDto(
|
||||
int InvoiceCount, decimal InvoiceSubtotal, decimal InvoiceDiscountTotal, decimal InvoiceTaxTotal,
|
||||
decimal InvoiceGrandTotal, IReadOnlyList<string> InvoiceNumbers);
|
||||
|
||||
public sealed record SalesDayEndDto(
|
||||
int SalesDayEndId, string DocNo, int CashierUserId, DateOnly BusinessDate,
|
||||
int SlipCount, int BundleCount, decimal Subtotal, decimal DiscountTotal, decimal TaxTotal, decimal GrandTotal,
|
||||
decimal CostOfGoodsSold, string? GlJournalNo, DateTime? GlPostedAt, int ClosedBy, DateTime ClosedAt,
|
||||
IReadOnlyList<string> SlipNumbers, IReadOnlyList<string> BundleNumbers, IReadOnlyList<SalesDayEndItemLineDto> ItemBreakdown,
|
||||
SalesDayEndInvoiceSummaryDto Invoices);
|
||||
|
||||
public sealed record SalesDayEndSummaryDto(
|
||||
int SalesDayEndId, string DocNo, int CashierUserId, DateOnly BusinessDate,
|
||||
int SlipCount, int BundleCount, decimal GrandTotal, string? GlJournalNo, DateTime ClosedAt);
|
||||
|
||||
/// <summary>What closing right now would include — call before <c>POST /sales-day-end</c> so the
|
||||
/// cashier can see the day's totals, and any still-Draft slips/bundles blocking the close.</summary>
|
||||
public sealed record SalesDayEndPreviewDto(
|
||||
int CashierUserId, DateOnly BusinessDate, bool AlreadyClosed,
|
||||
int SlipCount, int BundleCount, decimal Subtotal, decimal DiscountTotal, decimal TaxTotal, decimal GrandTotal,
|
||||
IReadOnlyList<string> SlipNumbers, IReadOnlyList<string> BundleNumbers,
|
||||
IReadOnlyList<DraftSlipBlockingCloseDto> DraftSlipsBlockingClose,
|
||||
IReadOnlyList<DraftBundleBlockingCloseDto> DraftBundleSalesBlockingClose,
|
||||
SalesDayEndInvoiceSummaryDto Invoices);
|
||||
|
||||
public sealed record DraftSlipBlockingCloseDto(int SalesSlipId, string SlipNo, decimal GrandTotal);
|
||||
public sealed record DraftBundleBlockingCloseDto(int BundleSaleId, string BundleNo, decimal GrandTotal);
|
||||
|
||||
// Requests ------------------------------------------------------------------------
|
||||
|
||||
public sealed class CreateSalesDayEndRequest
|
||||
{
|
||||
[Required] public int CashierUserId { get; set; }
|
||||
/// <summary>Defaults to today (UTC) when omitted.</summary>
|
||||
public DateOnly? BusinessDate { get; set; }
|
||||
}
|
||||
@@ -17,12 +17,24 @@ public sealed record SalesInvoiceDto(
|
||||
int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId,
|
||||
string CustomerSnapshotName, string? CustomerSnapshotTaxNo, int WarehouseId,
|
||||
SalesInvoiceType InvoiceType, SalesInvoiceStatus Status, int CreatedBy, DateTime CreatedAt,
|
||||
DateTime? UpdatedAt, SalesInvoiceTotalsDto Totals, IReadOnlyList<SalesInvoiceLineDto> Lines);
|
||||
DateTime? UpdatedAt, string? GlJournalNo, DateTime? GlPostedAt,
|
||||
SalesInvoiceTotalsDto Totals, IReadOnlyList<SalesInvoiceLineDto> Lines);
|
||||
|
||||
public sealed record SalesInvoiceSummaryDto(
|
||||
int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId,
|
||||
string CustomerSnapshotName, int WarehouseId, SalesInvoiceType InvoiceType,
|
||||
SalesInvoiceStatus Status, SalesInvoiceTotalsDto Totals, DateTime CreatedAt);
|
||||
SalesInvoiceStatus Status, SalesInvoiceTotalsDto Totals, DateTime CreatedAt, string? GlJournalNo);
|
||||
|
||||
public sealed record SalesInvoicePaymentDto(
|
||||
int SalesInvoicePaymentId, int SalesInvoiceId, decimal Amount, DateTime PaymentDate,
|
||||
long GlBankAccountId, string BankAccountName, string? Reference, string? GlJournalNo, DateTime CreatedAt);
|
||||
|
||||
public sealed class CreateSalesInvoicePaymentRequest
|
||||
{
|
||||
[Range(0.01, double.MaxValue)] public decimal Amount { get; set; }
|
||||
[Required] public long GlBankAccountId { get; set; }
|
||||
[StringLength(100)] public string? Reference { get; set; }
|
||||
}
|
||||
|
||||
public sealed record SalesInvoicePostingIssueDto(
|
||||
int SalesInvoiceLineId, int ItemId, string ItemSku, string ItemName, int WarehouseId,
|
||||
|
||||
@@ -4,7 +4,7 @@ public sealed record SalesFreeIssueRewardOptionDto(
|
||||
int ItemId,
|
||||
string Sku,
|
||||
string Name,
|
||||
decimal? SalePrice);
|
||||
decimal? SellingFlatPrice);
|
||||
|
||||
public sealed record SalesFreeIssueSuggestionLineDto(
|
||||
int SalesSlipLineId,
|
||||
@@ -20,4 +20,4 @@ public sealed record SalesFreeIssueSuggestionDto(
|
||||
int SalesSlipId,
|
||||
string SlipNo,
|
||||
DateTime SlipDate,
|
||||
IReadOnlyList<SalesFreeIssueSuggestionLineDto> Lines);
|
||||
IReadOnlyList<SalesFreeIssueSuggestionLineDto> Lines);
|
||||
|
||||
@@ -9,12 +9,13 @@ public sealed record SalesReturnLineDto(int ReturnLineId, int? SalesInvoiceLineI
|
||||
|
||||
public sealed record SalesReturnDto(
|
||||
int ReturnId, string DocNo, int CustomerId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
|
||||
int CreatedBy, DateTime CreatedAt, IReadOnlyList<SalesReturnLineDto> Lines, IReadOnlyList<int> LedgerRefs);
|
||||
int CreatedBy, DateTime CreatedAt, string? GlJournalNo, DateTime? GlPostedAt,
|
||||
IReadOnlyList<SalesReturnLineDto> Lines, IReadOnlyList<int> LedgerRefs);
|
||||
|
||||
/// <summary>Row shape for <c>GET /sales-returns</c> — no lines/ledgerRefs (those need a per-row query).</summary>
|
||||
public sealed record SalesReturnSummaryDto(
|
||||
int ReturnId, string DocNo, int CustomerId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
|
||||
int CreatedBy, DateTime CreatedAt, int LineCount, decimal TotalQty);
|
||||
int CreatedBy, DateTime CreatedAt, int LineCount, decimal TotalQty, string? GlJournalNo);
|
||||
|
||||
/// <summary>
|
||||
/// Remaining returnable qty for one sales invoice line — the invoiced qty minus
|
||||
|
||||
@@ -9,12 +9,13 @@ public sealed record AdjustmentLineDto(int AdjLineId, int ItemId, int? BinId, in
|
||||
|
||||
public sealed record AdjustmentDto(
|
||||
int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status,
|
||||
int CreatedBy, DateTime CreatedAt, IReadOnlyList<AdjustmentLineDto> Lines, IReadOnlyList<int> LedgerRefs);
|
||||
int CreatedBy, DateTime CreatedAt, string? GlJournalNo, DateTime? GlPostedAt,
|
||||
IReadOnlyList<AdjustmentLineDto> Lines, IReadOnlyList<int> LedgerRefs);
|
||||
|
||||
/// <summary>Row shape for <c>GET /stock-adjustments</c> — no lines/ledgerRefs (those need a per-row query).</summary>
|
||||
public sealed record AdjustmentSummaryDto(
|
||||
int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status,
|
||||
int CreatedBy, DateTime CreatedAt, int LineCount);
|
||||
int CreatedBy, DateTime CreatedAt, int LineCount, string? GlJournalNo);
|
||||
|
||||
// Requests ----------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ public sealed record CountSummaryDto(
|
||||
int CountId, string DocNo, int WarehouseId, CountType CountType, CountStatus Status,
|
||||
int CreatedBy, DateTime CreatedAt, int LineCount);
|
||||
|
||||
public sealed record CountPostResultDto(int CountId, CountStatus Status, int? AdjustmentId, IReadOnlyList<int> LedgerRefs);
|
||||
public sealed record CountPostResultDto(int CountId, CountStatus Status, int? AdjustmentId, string? GlJournalNo, IReadOnlyList<int> LedgerRefs);
|
||||
|
||||
// Requests ----------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ public sealed class GrnConfiguration : IEntityTypeConfiguration<Grn>
|
||||
|
||||
builder.Property(g => g.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||
builder.Property(g => g.CreatedAt).IsRequired();
|
||||
builder.Property(g => g.GlJournalNo).HasMaxLength(30);
|
||||
builder.Property(g => g.PaidAmount).HasPrecision(18, 4);
|
||||
builder.Property(g => g.BalanceAmount).HasPrecision(18, 4);
|
||||
builder.Property(g => g.RowVersion).IsRowVersion();
|
||||
|
||||
builder.HasOne(g => g.PurchaseOrder).WithMany().HasForeignKey(g => g.PoId).OnDelete(DeleteBehavior.Restrict);
|
||||
@@ -28,6 +31,27 @@ public sealed class GrnConfiguration : IEntityTypeConfiguration<Grn>
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class GrnPaymentConfiguration : IEntityTypeConfiguration<GrnPayment>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<GrnPayment> builder)
|
||||
{
|
||||
builder.ToTable("grn_payments");
|
||||
builder.HasKey(p => p.GrnPaymentId);
|
||||
|
||||
builder.Property(p => p.Amount).HasPrecision(18, 4);
|
||||
builder.Property(p => p.PaymentDate).IsRequired();
|
||||
builder.Property(p => p.BankAccountName).IsRequired().HasMaxLength(200);
|
||||
builder.Property(p => p.Reference).HasMaxLength(100);
|
||||
builder.Property(p => p.GlJournalNo).HasMaxLength(30);
|
||||
builder.Property(p => p.CreatedAt).IsRequired();
|
||||
|
||||
builder.HasOne(p => p.Grn).WithMany(g => g.Payments).HasForeignKey(p => p.GrnId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(p => p.Creator).WithMany().HasForeignKey(p => p.CreatedBy).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasIndex(p => p.GrnId);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class GrnLineConfiguration : IEntityTypeConfiguration<GrnLine>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<GrnLine> builder)
|
||||
|
||||
@@ -20,7 +20,9 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration<Item>
|
||||
builder.Property(i => i.TaxClass).HasMaxLength(20);
|
||||
|
||||
// Sales-only fixed selling price; nullable (null ⇒ sell at stock/FIFO value).
|
||||
builder.Property(i => i.SalePrice).HasPrecision(18, 4);
|
||||
builder.Property(i => i.SellingMinPrice).HasPrecision(18, 4);
|
||||
builder.Property(i => i.SellingMaxPrice).HasPrecision(18, 4);
|
||||
builder.Property(i => i.SellingFlatPrice).HasPrecision(18, 4);
|
||||
|
||||
// Optional content size (how much one stocked pack holds). All four are nullable
|
||||
// together: null ⇒ the item has no measurable content. The base pair is derived
|
||||
|
||||
@@ -39,7 +39,8 @@ public sealed class NavItemConfiguration : IEntityTypeConfiguration<NavItem>
|
||||
new NavItem { NavItemId = 10, Code = "help", Label = "Help", Href = "/dashboard/help", SortOrder = 10 },
|
||||
new NavItem { NavItemId = 11, Code = "ledgers", Label = "Ledgers", Href = "/dashboard/ledgers", SortOrder = 11 },
|
||||
new NavItem { NavItemId = 12, Code = "accounts", Label = "Accounts", Href = "/dashboard/accounts", SortOrder = 12 },
|
||||
new NavItem { NavItemId = 13, Code = "sales", Label = "Sales", Href = "/dashboard/sales", SortOrder = 13 }
|
||||
new NavItem { NavItemId = 13, Code = "sales", Label = "Sales", Href = "/dashboard/sales", SortOrder = 13 },
|
||||
new NavItem { NavItemId = 14, Code = "production", Label = "Production", Href = "/dashboard/production", SortOrder = 14 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Configurations;
|
||||
|
||||
public sealed class NotificationConfigConfiguration : IEntityTypeConfiguration<NotificationConfig>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<NotificationConfig> builder)
|
||||
{
|
||||
builder.ToTable("notification_config", t =>
|
||||
t.HasCheckConstraint("ck_notification_config_singleton", $"\"NotificationConfigId\" = {NotificationConfig.SingletonId}"));
|
||||
|
||||
builder.HasKey(c => c.NotificationConfigId);
|
||||
builder.Property(c => c.NotificationConfigId).ValueGeneratedNever();
|
||||
|
||||
builder.Property(c => c.RowVersion).IsRowVersion();
|
||||
|
||||
builder.HasMany(c => c.Modules)
|
||||
.WithOne(m => m.NotificationConfig)
|
||||
.HasForeignKey(m => m.NotificationConfigId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasOne(c => c.UpdatedByUser)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.UpdatedBy)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Configurations;
|
||||
|
||||
public sealed class NotificationConfigModuleConfiguration : IEntityTypeConfiguration<NotificationConfigModule>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<NotificationConfigModule> builder)
|
||||
{
|
||||
builder.ToTable("notification_config_modules");
|
||||
builder.HasKey(m => m.NotificationConfigModuleId);
|
||||
|
||||
builder.Property(m => m.ModuleKey).IsRequired().HasMaxLength(100);
|
||||
builder.HasIndex(m => m.ModuleKey).IsUnique();
|
||||
|
||||
builder.Property(m => m.EmailEnabled).IsRequired().HasDefaultValue(false);
|
||||
builder.Property(m => m.SmsEnabled).IsRequired().HasDefaultValue(false);
|
||||
builder.Property(m => m.InAppEnabled).IsRequired().HasDefaultValue(false);
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,11 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration<Permissio
|
||||
new Permission { PermissionId = 26, Code = "NAV:accounts.bank-accounts", SubNavItemId = 15 },
|
||||
new Permission { PermissionId = 32, Code = "NAV:accounts", NavItemId = 12 },
|
||||
new Permission { PermissionId = 33, Code = "NAV:accounts.cheque-books", SubNavItemId = 21 },
|
||||
new Permission { PermissionId = 34, Code = "NAV:accounts.received-cheques", SubNavItemId = 22 }
|
||||
new Permission { PermissionId = 34, Code = "NAV:accounts.received-cheques", SubNavItemId = 22 },
|
||||
new Permission { PermissionId = 35, Code = "NAV:production", NavItemId = 14 },
|
||||
new Permission { PermissionId = 36, Code = "NAV:production.templates", SubNavItemId = 24 },
|
||||
new Permission { PermissionId = 37, Code = "NAV:production.runs", SubNavItemId = 25 },
|
||||
new Permission { PermissionId = 38, Code = "NAV:production.produced", SubNavItemId = 26 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@ public sealed class ProductConfigConfiguration : IEntityTypeConfiguration<Produc
|
||||
builder.Property(c => c.SubcategoriesEnabled).IsRequired().HasDefaultValue(true);
|
||||
builder.Property(c => c.BrandsEnabled).IsRequired().HasDefaultValue(true);
|
||||
builder.Property(c => c.ItemTypesEnabled).IsRequired().HasDefaultValue(true);
|
||||
builder.Property(c => c.SellingPricesEnabled).IsRequired().HasDefaultValue(true);
|
||||
builder.Property(c => c.SerialNumbersEnabled).IsRequired().HasDefaultValue(true);
|
||||
builder.Property(c => c.BatchListsEnabled).IsRequired().HasDefaultValue(true);
|
||||
builder.Property(c => c.WarrantyPeriodsEnabled).IsRequired().HasDefaultValue(true);
|
||||
|
||||
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
|
||||
builder.Property(c => c.RowVersion).IsRowVersion();
|
||||
|
||||
@@ -16,6 +16,7 @@ public sealed class PurchaseReturnConfiguration : IEntityTypeConfiguration<Purch
|
||||
|
||||
builder.Property(r => r.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||
builder.Property(r => r.CreatedAt).IsRequired();
|
||||
builder.Property(r => r.GlJournalNo).HasMaxLength(30);
|
||||
|
||||
builder.HasOne(r => r.Vendor).WithMany().HasForeignKey(r => r.VendorId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(r => r.Warehouse).WithMany().HasForeignKey(r => r.WarehouseId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Configurations;
|
||||
|
||||
public sealed class SalesDayEndConfiguration : IEntityTypeConfiguration<SalesDayEnd>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SalesDayEnd> builder)
|
||||
{
|
||||
builder.ToTable("sales_day_ends");
|
||||
builder.HasKey(x => x.SalesDayEndId);
|
||||
|
||||
builder.Property(x => x.DocNo).IsRequired().HasMaxLength(30);
|
||||
builder.HasIndex(x => x.DocNo).IsUnique();
|
||||
|
||||
builder.Property(x => x.BusinessDate).IsRequired();
|
||||
|
||||
foreach (var p in new[] { nameof(SalesDayEnd.Subtotal), nameof(SalesDayEnd.DiscountTotal), nameof(SalesDayEnd.TaxTotal), nameof(SalesDayEnd.GrandTotal), nameof(SalesDayEnd.CostOfGoodsSold) })
|
||||
builder.Property<decimal>(p).HasPrecision(18, 4);
|
||||
builder.Property(x => x.BundleCount).HasDefaultValue(0);
|
||||
|
||||
builder.Property(x => x.GlJournalNo).HasMaxLength(30);
|
||||
builder.Property(x => x.ClosedAt).IsRequired();
|
||||
builder.Property(x => x.RowVersion).IsRowVersion();
|
||||
|
||||
builder.HasOne(x => x.CashierUser).WithMany().HasForeignKey(x => x.CashierUserId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(x => x.ClosedByUser).WithMany().HasForeignKey(x => x.ClosedBy).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// A cashier can only close a given business date once (CloseAsync replays this
|
||||
// row idempotently on a second call instead of erroring — see SalesDayEndService).
|
||||
builder.HasIndex(x => new { x.CashierUserId, x.BusinessDate }).IsUnique();
|
||||
|
||||
builder.HasMany(x => x.Slips)
|
||||
.WithOne(x => x.DayEnd)
|
||||
.HasForeignKey(x => x.DayEndId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasMany(x => x.BundleSales)
|
||||
.WithOne(x => x.DayEnd)
|
||||
.HasForeignKey(x => x.DayEndId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ public sealed class SalesInvoiceConfiguration : IEntityTypeConfiguration<SalesIn
|
||||
|
||||
foreach (var p in new[] { nameof(SalesInvoice.Subtotal), nameof(SalesInvoice.DiscountTotal), nameof(SalesInvoice.TaxTotal), nameof(SalesInvoice.GrandTotal), nameof(SalesInvoice.RoundOff), nameof(SalesInvoice.NetPayable), nameof(SalesInvoice.PaidAmount), nameof(SalesInvoice.BalanceAmount) })
|
||||
builder.Property<decimal>(p).HasPrecision(18, 4);
|
||||
builder.Property(x => x.GlJournalNo).HasMaxLength(30);
|
||||
|
||||
builder.Property(x => x.CreatedAt).IsRequired();
|
||||
builder.Property(x => x.RowVersion).IsRowVersion();
|
||||
@@ -54,6 +55,27 @@ public sealed class SalesInvoiceConfiguration : IEntityTypeConfiguration<SalesIn
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SalesInvoicePaymentConfiguration : IEntityTypeConfiguration<SalesInvoicePayment>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SalesInvoicePayment> builder)
|
||||
{
|
||||
builder.ToTable("sales_invoice_payments");
|
||||
builder.HasKey(p => p.SalesInvoicePaymentId);
|
||||
|
||||
builder.Property(p => p.Amount).HasPrecision(18, 4);
|
||||
builder.Property(p => p.PaymentDate).IsRequired();
|
||||
builder.Property(p => p.BankAccountName).IsRequired().HasMaxLength(200);
|
||||
builder.Property(p => p.Reference).HasMaxLength(100);
|
||||
builder.Property(p => p.GlJournalNo).HasMaxLength(30);
|
||||
builder.Property(p => p.CreatedAt).IsRequired();
|
||||
|
||||
builder.HasOne(p => p.SalesInvoice).WithMany(x => x.Payments).HasForeignKey(p => p.SalesInvoiceId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(p => p.Creator).WithMany().HasForeignKey(p => p.CreatedBy).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasIndex(p => p.SalesInvoiceId);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SalesInvoiceLineConfiguration : IEntityTypeConfiguration<SalesInvoiceLine>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SalesInvoiceLine> builder)
|
||||
|
||||
@@ -16,6 +16,7 @@ public sealed class SalesReturnConfiguration : IEntityTypeConfiguration<SalesRet
|
||||
|
||||
builder.Property(r => r.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||
builder.Property(r => r.CreatedAt).IsRequired();
|
||||
builder.Property(r => r.GlJournalNo).HasMaxLength(30);
|
||||
|
||||
builder.HasOne(r => r.Customer).WithMany().HasForeignKey(r => r.CustomerId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(r => r.Warehouse).WithMany().HasForeignKey(r => r.WarehouseId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
@@ -16,6 +16,7 @@ public sealed class StockAdjustmentConfiguration : IEntityTypeConfiguration<Stoc
|
||||
|
||||
builder.Property(a => a.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||
builder.Property(a => a.CreatedAt).IsRequired();
|
||||
builder.Property(a => a.GlJournalNo).HasMaxLength(30);
|
||||
builder.Property(a => a.RowVersion).IsRowVersion();
|
||||
|
||||
builder.HasOne(a => a.Warehouse).WithMany().HasForeignKey(a => a.WarehouseId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
@@ -33,6 +33,7 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
|
||||
new SubNavItem { SubNavItemId = 6, NavItemId = 2, Code = "products.configuration", Label = "Configuration", Href = "/dashboard/products/settings", SortOrder = 6 },
|
||||
new SubNavItem { SubNavItemId = 7, NavItemId = 9, Code = "settings.roles", Label = "Roles", Href = "/dashboard/settings/roles", SortOrder = 1 },
|
||||
new SubNavItem { SubNavItemId = 8, NavItemId = 9, Code = "settings.users", Label = "Users", Href = "/dashboard/settings/users", SortOrder = 2 },
|
||||
new SubNavItem { SubNavItemId = 24, NavItemId = 9, Code = "settings.notification-config", Label = "Notification Config", Href = "/dashboard/settings/notification-config", SortOrder = 3 },
|
||||
new SubNavItem { SubNavItemId = 23, NavItemId = 13, Code = "sales.bundle-sales", Label = "Bundle Sales", Href = "/dashboard/sales/bundles", SortOrder = 1 },
|
||||
// Procurement (NavItemId 4) children — mirror the hub page order.
|
||||
// IDs 17-20 (not 9-12): 9-12 were already claimed by the Ledgers sub-items below;
|
||||
@@ -53,7 +54,11 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
|
||||
// doesn't silently lose it just because the section it lives under changed.
|
||||
new SubNavItem { SubNavItemId = 15, NavItemId = 12, Code = "accounts.bank-accounts", Label = "Cash / Bank Accounts", Href = "/dashboard/accounts/bank-accounts", SortOrder = 1 },
|
||||
new SubNavItem { SubNavItemId = 21, NavItemId = 12, Code = "accounts.cheque-books", Label = "Cheque Books", Href = "/dashboard/accounts/cheque-books", SortOrder = 2 },
|
||||
new SubNavItem { SubNavItemId = 22, NavItemId = 12, Code = "accounts.received-cheques", Label = "Received Cheques", Href = "/dashboard/accounts/received-cheques", SortOrder = 3 }
|
||||
new SubNavItem { SubNavItemId = 22, NavItemId = 12, Code = "accounts.received-cheques", Label = "Received Cheques", Href = "/dashboard/accounts/received-cheques", SortOrder = 3 },
|
||||
// Production (NavItemId 14) children — mirror the sidebar order (AppSidebar.tsx).
|
||||
new SubNavItem { SubNavItemId = 24, NavItemId = 14, Code = "production.templates", Label = "Templates", Href = "/dashboard/production/templates", SortOrder = 1 },
|
||||
new SubNavItem { SubNavItemId = 25, NavItemId = 14, Code = "production.runs", Label = "Runs", Href = "/dashboard/production/runs", SortOrder = 2 },
|
||||
new SubNavItem { SubNavItemId = 26, NavItemId = 14, Code = "production.produced", Label = "Produced", Href = "/dashboard/production/produced", SortOrder = 3 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,8 +43,9 @@ public static class DataSeeder
|
||||
{
|
||||
var dirty = await SeedReasonCodesAsync(db, ct);
|
||||
dirty |= await SeedItemTypesAsync(db, ct);
|
||||
// dirty |= await SeedCompanyProfileAsync(db, ct);
|
||||
// dirty |= await SeedCompanyProfileAsync(db, ct);
|
||||
dirty |= await SeedProductConfigAsync(db, ct);
|
||||
dirty |= await SeedNotificationConfigAsync(db, ct);
|
||||
dirty |= await SeedSalesMastersAsync(db, ct);
|
||||
dirty |= await SeedSalesStockAsync(db, ct);
|
||||
dirty |= await SeedSalesAsync(db, ct);
|
||||
@@ -100,7 +101,22 @@ public static class DataSeeder
|
||||
ConfigId = ProductConfig.SingletonId,
|
||||
SubcategoriesEnabled = true,
|
||||
BrandsEnabled = true,
|
||||
ItemTypesEnabled = true
|
||||
ItemTypesEnabled = true,
|
||||
SellingPricesEnabled = true,
|
||||
SerialNumbersEnabled = true,
|
||||
BatchListsEnabled = true,
|
||||
WarrantyPeriodsEnabled = true
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
private static async Task<bool> SeedNotificationConfigAsync(ErpDbContext db, CancellationToken ct)
|
||||
{
|
||||
if (await db.NotificationConfig.AnyAsync(c => c.NotificationConfigId == NotificationConfig.SingletonId, ct)) return false;
|
||||
|
||||
db.NotificationConfig.Add(new NotificationConfig
|
||||
{
|
||||
NotificationConfigId = NotificationConfig.SingletonId
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -182,7 +198,7 @@ public static class DataSeeder
|
||||
WarehouseId = warehouse.WarehouseId,
|
||||
QtyReceived = 100m,
|
||||
QtyRemaining = 100m,
|
||||
UnitCost = item.SalePrice.GetValueOrDefault() > 0m ? item.SalePrice.GetValueOrDefault() / 2m : 25m,
|
||||
UnitCost = item.SellingFlatPrice.GetValueOrDefault() > 0m ? item.SellingFlatPrice.GetValueOrDefault() / 2m : 25m,
|
||||
ReceiptDate = DateTime.UtcNow.AddDays(-7)
|
||||
});
|
||||
db.StockLayers.Add(new StockLayer
|
||||
@@ -191,7 +207,7 @@ public static class DataSeeder
|
||||
WarehouseId = warehouse.WarehouseId,
|
||||
QtyReceived = 5m,
|
||||
QtyRemaining = 5m,
|
||||
UnitCost = secondItem.SalePrice.GetValueOrDefault() > 0m ? secondItem.SalePrice.GetValueOrDefault() / 2m : 15m,
|
||||
UnitCost = secondItem.SellingFlatPrice.GetValueOrDefault() > 0m ? secondItem.SellingFlatPrice.GetValueOrDefault() / 2m : 15m,
|
||||
ReceiptDate = DateTime.UtcNow.AddDays(-6)
|
||||
});
|
||||
return true;
|
||||
@@ -278,7 +294,7 @@ public static class DataSeeder
|
||||
BaseUomId = uom.UomId,
|
||||
StockNature = StockNature.Stocked,
|
||||
TrackingMode = TrackingMode.None,
|
||||
SalePrice = 100m,
|
||||
SellingFlatPrice = 100m,
|
||||
Status = EntityStatus.Active,
|
||||
CreatedAt = now
|
||||
},
|
||||
@@ -292,7 +308,7 @@ public static class DataSeeder
|
||||
BaseUomId = uom.UomId,
|
||||
StockNature = StockNature.Stocked,
|
||||
TrackingMode = TrackingMode.None,
|
||||
SalePrice = 50m,
|
||||
SellingFlatPrice = 50m,
|
||||
ContentQty = 500m,
|
||||
ContentUnit = MeasureUnit.Ml,
|
||||
ContentBaseQty = 500m,
|
||||
@@ -445,7 +461,7 @@ public static class DataSeeder
|
||||
ItemId = items[0].ItemId,
|
||||
WarehouseId = warehouse.WarehouseId,
|
||||
Qty = 1m,
|
||||
UnitPrice = items[0].SalePrice.GetValueOrDefault(),
|
||||
UnitPrice = items[0].SellingFlatPrice.GetValueOrDefault(),
|
||||
IncludeInBundle = true,
|
||||
SortOrder = 1
|
||||
},
|
||||
@@ -454,7 +470,7 @@ public static class DataSeeder
|
||||
ItemId = items[1].ItemId,
|
||||
WarehouseId = warehouse.WarehouseId,
|
||||
Qty = 1m,
|
||||
UnitPrice = items[1].SalePrice.GetValueOrDefault(),
|
||||
UnitPrice = items[1].SellingFlatPrice.GetValueOrDefault(),
|
||||
IncludeInBundle = true,
|
||||
SortOrder = 2
|
||||
}
|
||||
@@ -478,10 +494,10 @@ public static class DataSeeder
|
||||
BundleName = "Demo Bundle Draft",
|
||||
BundleCode = "BND-DEMO-001",
|
||||
Status = BundleSaleStatus.Draft,
|
||||
ComponentSubtotal = items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault(),
|
||||
ComponentSubtotal = items[0].SellingFlatPrice.GetValueOrDefault() + items[1].SellingFlatPrice.GetValueOrDefault(),
|
||||
BundlePrice = 0m,
|
||||
MarginAmount = -(items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault()),
|
||||
DiscountTotal = items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault(),
|
||||
MarginAmount = -(items[0].SellingFlatPrice.GetValueOrDefault() + items[1].SellingFlatPrice.GetValueOrDefault()),
|
||||
DiscountTotal = items[0].SellingFlatPrice.GetValueOrDefault() + items[1].SellingFlatPrice.GetValueOrDefault(),
|
||||
TaxTotal = 0m,
|
||||
GrandTotal = 0m,
|
||||
CreatedAt = now.AddDays(-2),
|
||||
@@ -493,8 +509,8 @@ public static class DataSeeder
|
||||
Description = items[0].Name,
|
||||
Qty = 1m,
|
||||
WarehouseId = warehouse.WarehouseId,
|
||||
UnitPrice = items[0].SalePrice.GetValueOrDefault(),
|
||||
LineTotal = items[0].SalePrice.GetValueOrDefault(),
|
||||
UnitPrice = items[0].SellingFlatPrice.GetValueOrDefault(),
|
||||
LineTotal = items[0].SellingFlatPrice.GetValueOrDefault(),
|
||||
IncludeInBundle = true,
|
||||
IsComponent = true
|
||||
},
|
||||
@@ -504,8 +520,8 @@ public static class DataSeeder
|
||||
Description = items[1].Name,
|
||||
Qty = 1m,
|
||||
WarehouseId = warehouse.WarehouseId,
|
||||
UnitPrice = items[1].SalePrice.GetValueOrDefault(),
|
||||
LineTotal = items[1].SalePrice.GetValueOrDefault(),
|
||||
UnitPrice = items[1].SellingFlatPrice.GetValueOrDefault(),
|
||||
LineTotal = items[1].SellingFlatPrice.GetValueOrDefault(),
|
||||
IncludeInBundle = true,
|
||||
IsComponent = true
|
||||
}
|
||||
@@ -523,12 +539,12 @@ public static class DataSeeder
|
||||
BundleName = "Demo Bundle Posted",
|
||||
BundleCode = "BND-DEMO-001",
|
||||
Status = BundleSaleStatus.Posted,
|
||||
ComponentSubtotal = items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault(),
|
||||
BundlePrice = (items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault()) - 10m,
|
||||
ComponentSubtotal = items[0].SellingFlatPrice.GetValueOrDefault() + items[1].SellingFlatPrice.GetValueOrDefault(),
|
||||
BundlePrice = (items[0].SellingFlatPrice.GetValueOrDefault() + items[1].SellingFlatPrice.GetValueOrDefault()) - 10m,
|
||||
MarginAmount = -10m,
|
||||
DiscountTotal = 10m,
|
||||
TaxTotal = 0m,
|
||||
GrandTotal = (items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault()) - 10m,
|
||||
GrandTotal = (items[0].SellingFlatPrice.GetValueOrDefault() + items[1].SellingFlatPrice.GetValueOrDefault()) - 10m,
|
||||
CreatedAt = now.AddDays(-1),
|
||||
UpdatedAt = now.AddHours(-2),
|
||||
Lines =
|
||||
@@ -539,8 +555,8 @@ public static class DataSeeder
|
||||
Description = items[0].Name,
|
||||
Qty = 1m,
|
||||
WarehouseId = warehouse.WarehouseId,
|
||||
UnitPrice = items[0].SalePrice.GetValueOrDefault(),
|
||||
LineTotal = items[0].SalePrice.GetValueOrDefault(),
|
||||
UnitPrice = items[0].SellingFlatPrice.GetValueOrDefault(),
|
||||
LineTotal = items[0].SellingFlatPrice.GetValueOrDefault(),
|
||||
IncludeInBundle = true,
|
||||
IsComponent = true
|
||||
},
|
||||
@@ -550,8 +566,8 @@ public static class DataSeeder
|
||||
Description = items[1].Name,
|
||||
Qty = 1m,
|
||||
WarehouseId = warehouse.WarehouseId,
|
||||
UnitPrice = items[1].SalePrice.GetValueOrDefault(),
|
||||
LineTotal = items[1].SalePrice.GetValueOrDefault(),
|
||||
UnitPrice = items[1].SellingFlatPrice.GetValueOrDefault(),
|
||||
LineTotal = items[1].SellingFlatPrice.GetValueOrDefault(),
|
||||
IncludeInBundle = true,
|
||||
IsComponent = true
|
||||
}
|
||||
@@ -569,12 +585,12 @@ public static class DataSeeder
|
||||
BundleName = "Demo Bundle Cancelled",
|
||||
BundleCode = "BND-DEMO-001",
|
||||
Status = BundleSaleStatus.Cancelled,
|
||||
ComponentSubtotal = items[0].SalePrice.GetValueOrDefault(),
|
||||
BundlePrice = items[0].SalePrice.GetValueOrDefault(),
|
||||
ComponentSubtotal = items[0].SellingFlatPrice.GetValueOrDefault(),
|
||||
BundlePrice = items[0].SellingFlatPrice.GetValueOrDefault(),
|
||||
MarginAmount = 0m,
|
||||
DiscountTotal = 0m,
|
||||
TaxTotal = 0m,
|
||||
GrandTotal = items[0].SalePrice.GetValueOrDefault(),
|
||||
GrandTotal = items[0].SellingFlatPrice.GetValueOrDefault(),
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
Lines =
|
||||
@@ -585,8 +601,8 @@ public static class DataSeeder
|
||||
Description = items[0].Name,
|
||||
Qty = 1m,
|
||||
WarehouseId = secondaryWarehouse.WarehouseId,
|
||||
UnitPrice = items[0].SalePrice.GetValueOrDefault(),
|
||||
LineTotal = items[0].SalePrice.GetValueOrDefault(),
|
||||
UnitPrice = items[0].SellingFlatPrice.GetValueOrDefault(),
|
||||
LineTotal = items[0].SellingFlatPrice.GetValueOrDefault(),
|
||||
IncludeInBundle = true,
|
||||
IsComponent = true
|
||||
}
|
||||
|
||||
@@ -38,6 +38,9 @@ public class ErpDbContext : DbContext
|
||||
|
||||
/// <summary>Singleton row (FR-MD-11).</summary>
|
||||
public DbSet<ProductConfig> ProductConfig => Set<ProductConfig>();
|
||||
/// <summary>Singleton row for notification delivery preferences.</summary>
|
||||
public DbSet<NotificationConfig> NotificationConfig => Set<NotificationConfig>();
|
||||
public DbSet<NotificationConfigModule> NotificationConfigModules => Set<NotificationConfigModule>();
|
||||
|
||||
// --- Cross-cutting (docs/10 Part C.7) ---
|
||||
public DbSet<User> Users => Set<User>();
|
||||
@@ -63,6 +66,7 @@ public class ErpDbContext : DbContext
|
||||
// --- Goods Receipt (docs/10 Part C.3) ---
|
||||
public DbSet<Grn> Grns => Set<Grn>();
|
||||
public DbSet<GrnLine> GrnLines => Set<GrnLine>();
|
||||
public DbSet<GrnPayment> GrnPayments => Set<GrnPayment>();
|
||||
|
||||
// --- Batch / Serial (docs/10 Part C.4) ---
|
||||
public DbSet<Batch> Batches => Set<Batch>();
|
||||
@@ -87,8 +91,10 @@ public class ErpDbContext : DbContext
|
||||
// --- Sales (Phase 1) ---
|
||||
public DbSet<SalesInvoice> SalesInvoices => Set<SalesInvoice>();
|
||||
public DbSet<SalesInvoiceLine> SalesInvoiceLines => Set<SalesInvoiceLine>();
|
||||
public DbSet<SalesInvoicePayment> SalesInvoicePayments => Set<SalesInvoicePayment>();
|
||||
public DbSet<SalesSlip> SalesSlips => Set<SalesSlip>();
|
||||
public DbSet<SalesSlipLine> SalesSlipLines => Set<SalesSlipLine>();
|
||||
public DbSet<SalesDayEnd> SalesDayEnds => Set<SalesDayEnd>();
|
||||
public DbSet<BundleSaleTemplate> BundleSaleTemplates => Set<BundleSaleTemplate>();
|
||||
public DbSet<BundleSaleTemplateLine> BundleSaleTemplateLines => Set<BundleSaleTemplateLine>();
|
||||
public DbSet<BundleSale> BundleSales => Set<BundleSale>();
|
||||
|
||||
+7188
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,113 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddGrnGlPostingAndPayments : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "BalanceAmount",
|
||||
table: "grns",
|
||||
type: "numeric(18,4)",
|
||||
precision: 18,
|
||||
scale: 4,
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "GlJournalNo",
|
||||
table: "grns",
|
||||
type: "character varying(30)",
|
||||
maxLength: 30,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "GlPostedAt",
|
||||
table: "grns",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "PaidAmount",
|
||||
table: "grns",
|
||||
type: "numeric(18,4)",
|
||||
precision: 18,
|
||||
scale: 4,
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "grn_payments",
|
||||
columns: table => new
|
||||
{
|
||||
GrnPaymentId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
GrnId = table.Column<int>(type: "integer", nullable: false),
|
||||
Amount = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||
PaymentDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
GlBankAccountId = table.Column<long>(type: "bigint", nullable: false),
|
||||
BankAccountName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
Reference = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||
GlJournalNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: true),
|
||||
CreatedBy = table.Column<int>(type: "integer", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_grn_payments", x => x.GrnPaymentId);
|
||||
table.ForeignKey(
|
||||
name: "FK_grn_payments_grns_GrnId",
|
||||
column: x => x.GrnId,
|
||||
principalTable: "grns",
|
||||
principalColumn: "GrnId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_grn_payments_users_CreatedBy",
|
||||
column: x => x.CreatedBy,
|
||||
principalTable: "users",
|
||||
principalColumn: "UserId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_grn_payments_CreatedBy",
|
||||
table: "grn_payments",
|
||||
column: "CreatedBy");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_grn_payments_GrnId",
|
||||
table: "grn_payments",
|
||||
column: "GrnId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "grn_payments");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BalanceAmount",
|
||||
table: "grns");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GlJournalNo",
|
||||
table: "grns");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GlPostedAt",
|
||||
table: "grns");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PaidAmount",
|
||||
table: "grns");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,196 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class warrenty : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Warranty",
|
||||
table: "items",
|
||||
type: "character varying(20)",
|
||||
maxLength: 20,
|
||||
nullable: false,
|
||||
defaultValue: "NonWarranty");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "WarrantyPeriodMonths",
|
||||
table: "items",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "grn_line_warranty_numbers",
|
||||
columns: table => new
|
||||
{
|
||||
GrnLineWarrantyNumberId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
GrnLineId = table.Column<int>(type: "integer", nullable: false),
|
||||
WarrantyNo = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
WarrantyPeriodMonths = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_grn_line_warranty_numbers", x => x.GrnLineWarrantyNumberId);
|
||||
table.ForeignKey(
|
||||
name: "FK_grn_line_warranty_numbers_grn_lines_GrnLineId",
|
||||
column: x => x.GrnLineId,
|
||||
principalTable: "grn_lines",
|
||||
principalColumn: "GrnLineId",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "sales_returns",
|
||||
columns: table => new
|
||||
{
|
||||
ReturnId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
DocNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
|
||||
CustomerId = table.Column<int>(type: "integer", nullable: false),
|
||||
WarehouseId = table.Column<int>(type: "integer", nullable: false),
|
||||
ReasonCodeId = table.Column<int>(type: "integer", nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
CreatedBy = table.Column<int>(type: "integer", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_sales_returns", x => x.ReturnId);
|
||||
table.ForeignKey(
|
||||
name: "FK_sales_returns_customers_CustomerId",
|
||||
column: x => x.CustomerId,
|
||||
principalTable: "customers",
|
||||
principalColumn: "CustomerId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_sales_returns_reason_codes_ReasonCodeId",
|
||||
column: x => x.ReasonCodeId,
|
||||
principalTable: "reason_codes",
|
||||
principalColumn: "ReasonCodeId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_sales_returns_users_CreatedBy",
|
||||
column: x => x.CreatedBy,
|
||||
principalTable: "users",
|
||||
principalColumn: "UserId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_sales_returns_warehouses_WarehouseId",
|
||||
column: x => x.WarehouseId,
|
||||
principalTable: "warehouses",
|
||||
principalColumn: "WarehouseId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "sales_return_lines",
|
||||
columns: table => new
|
||||
{
|
||||
ReturnLineId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
ReturnId = table.Column<int>(type: "integer", nullable: false),
|
||||
SalesInvoiceLineId = table.Column<int>(type: "integer", nullable: true),
|
||||
ItemId = table.Column<int>(type: "integer", nullable: false),
|
||||
Qty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_sales_return_lines", x => x.ReturnLineId);
|
||||
table.ForeignKey(
|
||||
name: "FK_sales_return_lines_items_ItemId",
|
||||
column: x => x.ItemId,
|
||||
principalTable: "items",
|
||||
principalColumn: "ItemId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_sales_return_lines_sales_invoice_lines_SalesInvoiceLineId",
|
||||
column: x => x.SalesInvoiceLineId,
|
||||
principalTable: "sales_invoice_lines",
|
||||
principalColumn: "SalesInvoiceLineId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_sales_return_lines_sales_returns_ReturnId",
|
||||
column: x => x.ReturnId,
|
||||
principalTable: "sales_returns",
|
||||
principalColumn: "ReturnId",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_grn_line_warranty_numbers_GrnLineId_WarrantyNo",
|
||||
table: "grn_line_warranty_numbers",
|
||||
columns: new[] { "GrnLineId", "WarrantyNo" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_sales_return_lines_ItemId",
|
||||
table: "sales_return_lines",
|
||||
column: "ItemId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_sales_return_lines_ReturnId",
|
||||
table: "sales_return_lines",
|
||||
column: "ReturnId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_sales_return_lines_SalesInvoiceLineId",
|
||||
table: "sales_return_lines",
|
||||
column: "SalesInvoiceLineId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_sales_returns_CreatedBy",
|
||||
table: "sales_returns",
|
||||
column: "CreatedBy");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_sales_returns_CustomerId",
|
||||
table: "sales_returns",
|
||||
column: "CustomerId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_sales_returns_DocNo",
|
||||
table: "sales_returns",
|
||||
column: "DocNo",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_sales_returns_ReasonCodeId",
|
||||
table: "sales_returns",
|
||||
column: "ReasonCodeId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_sales_returns_WarehouseId",
|
||||
table: "sales_returns",
|
||||
column: "WarehouseId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "grn_line_warranty_numbers");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "sales_return_lines");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "sales_returns");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Warranty",
|
||||
table: "items");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "WarrantyPeriodMonths",
|
||||
table: "items");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,109 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSalesDayEnd : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "DayEndId",
|
||||
table: "sales_slips",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "sales_day_ends",
|
||||
columns: table => new
|
||||
{
|
||||
SalesDayEndId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
DocNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
|
||||
CashierUserId = table.Column<int>(type: "integer", nullable: false),
|
||||
BusinessDate = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
SlipCount = table.Column<int>(type: "integer", nullable: false),
|
||||
Subtotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||
DiscountTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||
TaxTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||
GrandTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||
CostOfGoodsSold = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||
GlJournalNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: true),
|
||||
GlPostedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
ClosedBy = table.Column<int>(type: "integer", nullable: false),
|
||||
ClosedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_sales_day_ends", x => x.SalesDayEndId);
|
||||
table.ForeignKey(
|
||||
name: "FK_sales_day_ends_users_CashierUserId",
|
||||
column: x => x.CashierUserId,
|
||||
principalTable: "users",
|
||||
principalColumn: "UserId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_sales_day_ends_users_ClosedBy",
|
||||
column: x => x.ClosedBy,
|
||||
principalTable: "users",
|
||||
principalColumn: "UserId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_sales_slips_DayEndId",
|
||||
table: "sales_slips",
|
||||
column: "DayEndId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_sales_day_ends_CashierUserId_BusinessDate",
|
||||
table: "sales_day_ends",
|
||||
columns: new[] { "CashierUserId", "BusinessDate" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_sales_day_ends_ClosedBy",
|
||||
table: "sales_day_ends",
|
||||
column: "ClosedBy");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_sales_day_ends_DocNo",
|
||||
table: "sales_day_ends",
|
||||
column: "DocNo",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_sales_slips_sales_day_ends_DayEndId",
|
||||
table: "sales_slips",
|
||||
column: "DayEndId",
|
||||
principalTable: "sales_day_ends",
|
||||
principalColumn: "SalesDayEndId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_sales_slips_sales_day_ends_DayEndId",
|
||||
table: "sales_slips");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "sales_day_ends");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_sales_slips_DayEndId",
|
||||
table: "sales_slips");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DayEndId",
|
||||
table: "sales_slips");
|
||||
}
|
||||
}
|
||||
}
|
||||
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 AddReturnGlPosting : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "GlJournalNo",
|
||||
table: "sales_returns",
|
||||
type: "character varying(30)",
|
||||
maxLength: 30,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "GlPostedAt",
|
||||
table: "sales_returns",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "GlJournalNo",
|
||||
table: "purchase_returns",
|
||||
type: "character varying(30)",
|
||||
maxLength: 30,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "GlPostedAt",
|
||||
table: "purchase_returns",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GlJournalNo",
|
||||
table: "sales_returns");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GlPostedAt",
|
||||
table: "sales_returns");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GlJournalNo",
|
||||
table: "purchase_returns");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GlPostedAt",
|
||||
table: "purchase_returns");
|
||||
}
|
||||
}
|
||||
}
|
||||
+7372
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAdjustmentGlPosting : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "GlJournalNo",
|
||||
table: "stock_adjustments",
|
||||
type: "character varying(30)",
|
||||
maxLength: 30,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "GlPostedAt",
|
||||
table: "stock_adjustments",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GlJournalNo",
|
||||
table: "stock_adjustments");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GlPostedAt",
|
||||
table: "stock_adjustments");
|
||||
}
|
||||
}
|
||||
}
|
||||
+7391
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddBundleSaleToDayEnd : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "BundleCount",
|
||||
table: "sales_day_ends",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "DayEndId",
|
||||
table: "bundle_sales",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_bundle_sales_DayEndId",
|
||||
table: "bundle_sales",
|
||||
column: "DayEndId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_bundle_sales_sales_day_ends_DayEndId",
|
||||
table: "bundle_sales",
|
||||
column: "DayEndId",
|
||||
principalTable: "sales_day_ends",
|
||||
principalColumn: "SalesDayEndId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_bundle_sales_sales_day_ends_DayEndId",
|
||||
table: "bundle_sales");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_bundle_sales_DayEndId",
|
||||
table: "bundle_sales");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BundleCount",
|
||||
table: "sales_day_ends");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DayEndId",
|
||||
table: "bundle_sales");
|
||||
}
|
||||
}
|
||||
}
|
||||
+7468
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,87 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSalesInvoicePayments : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "GlJournalNo",
|
||||
table: "sales_invoices",
|
||||
type: "character varying(30)",
|
||||
maxLength: 30,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "GlPostedAt",
|
||||
table: "sales_invoices",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "sales_invoice_payments",
|
||||
columns: table => new
|
||||
{
|
||||
SalesInvoicePaymentId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
SalesInvoiceId = table.Column<int>(type: "integer", nullable: false),
|
||||
Amount = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||
PaymentDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
GlBankAccountId = table.Column<long>(type: "bigint", nullable: false),
|
||||
BankAccountName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
Reference = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||
GlJournalNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: true),
|
||||
CreatedBy = table.Column<int>(type: "integer", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_sales_invoice_payments", x => x.SalesInvoicePaymentId);
|
||||
table.ForeignKey(
|
||||
name: "FK_sales_invoice_payments_sales_invoices_SalesInvoiceId",
|
||||
column: x => x.SalesInvoiceId,
|
||||
principalTable: "sales_invoices",
|
||||
principalColumn: "SalesInvoiceId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_sales_invoice_payments_users_CreatedBy",
|
||||
column: x => x.CreatedBy,
|
||||
principalTable: "users",
|
||||
principalColumn: "UserId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_sales_invoice_payments_CreatedBy",
|
||||
table: "sales_invoice_payments",
|
||||
column: "CreatedBy");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_sales_invoice_payments_SalesInvoiceId",
|
||||
table: "sales_invoice_payments",
|
||||
column: "SalesInvoiceId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "sales_invoice_payments");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GlJournalNo",
|
||||
table: "sales_invoices");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GlPostedAt",
|
||||
table: "sales_invoices");
|
||||
}
|
||||
}
|
||||
}
|
||||
+7531
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddProductionNavPermissions : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.InsertData(
|
||||
table: "nav_items",
|
||||
columns: new[] { "NavItemId", "Code", "Href", "Icon", "Label", "SortOrder" },
|
||||
values: new object[] { 14, "production", "/dashboard/production", null, "Production", 14 });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "permissions",
|
||||
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
|
||||
values: new object[] { 35, "NAV:production", 14, null });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "sub_nav_items",
|
||||
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 24, "production.templates", "/dashboard/production/templates", null, "Templates", 14, 1 },
|
||||
{ 25, "production.runs", "/dashboard/production/runs", null, "Runs", 14, 2 },
|
||||
{ 26, "production.produced", "/dashboard/production/produced", null, "Produced", 14, 3 }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "permissions",
|
||||
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 36, "NAV:production.templates", null, 24 },
|
||||
{ 37, "NAV:production.runs", null, 25 },
|
||||
{ 38, "NAV:production.produced", null, 26 }
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 35);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 36);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 37);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 38);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 24);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 25);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 26);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "nav_items",
|
||||
keyColumn: "NavItemId",
|
||||
keyValue: 14);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class itemModel : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "SalePrice",
|
||||
table: "items",
|
||||
newName: "SellingMinPrice");
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "SellingFlatPrice",
|
||||
table: "items",
|
||||
type: "numeric(18,4)",
|
||||
precision: 18,
|
||||
scale: 4,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "SellingMaxPrice",
|
||||
table: "items",
|
||||
type: "numeric(18,4)",
|
||||
precision: 18,
|
||||
scale: 4,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SellingFlatPrice",
|
||||
table: "items");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SellingMaxPrice",
|
||||
table: "items");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "SellingMinPrice",
|
||||
table: "items",
|
||||
newName: "SalePrice");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+7580
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,103 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddNotificationConfigModulePreferences : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "notification_config",
|
||||
columns: table => new
|
||||
{
|
||||
NotificationConfigId = table.Column<int>(type: "integer", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
UpdatedBy = table.Column<int>(type: "integer", nullable: true),
|
||||
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_notification_config", x => x.NotificationConfigId);
|
||||
table.CheckConstraint("ck_notification_config_singleton", "\"NotificationConfigId\" = 1");
|
||||
table.ForeignKey(
|
||||
name: "FK_notification_config_users_UpdatedBy",
|
||||
column: x => x.UpdatedBy,
|
||||
principalTable: "users",
|
||||
principalColumn: "UserId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "notification_config_modules",
|
||||
columns: table => new
|
||||
{
|
||||
NotificationConfigModuleId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
NotificationConfigId = table.Column<int>(type: "integer", nullable: false),
|
||||
ModuleKey = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
EmailEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
||||
SmsEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
||||
InAppEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_notification_config_modules", x => x.NotificationConfigModuleId);
|
||||
table.ForeignKey(
|
||||
name: "FK_notification_config_modules_notification_config_Notificatio~",
|
||||
column: x => x.NotificationConfigId,
|
||||
principalTable: "notification_config",
|
||||
principalColumn: "NotificationConfigId",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.Sql("""
|
||||
INSERT INTO sub_nav_items ("SubNavItemId", "NavItemId", "Code", "Href", "Icon", "Label", "SortOrder")
|
||||
VALUES (24, 9, 'settings.notification-config', '/dashboard/settings/notification-config', NULL, 'Notification Config', 3)
|
||||
ON CONFLICT ("SubNavItemId") DO UPDATE
|
||||
SET "NavItemId" = EXCLUDED."NavItemId",
|
||||
"Code" = EXCLUDED."Code",
|
||||
"Href" = EXCLUDED."Href",
|
||||
"Icon" = EXCLUDED."Icon",
|
||||
"Label" = EXCLUDED."Label",
|
||||
"SortOrder" = EXCLUDED."SortOrder";
|
||||
""");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_notification_config_UpdatedBy",
|
||||
table: "notification_config",
|
||||
column: "UpdatedBy");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_notification_config_modules_ModuleKey",
|
||||
table: "notification_config_modules",
|
||||
column: "ModuleKey",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_notification_config_modules_NotificationConfigId",
|
||||
table: "notification_config_modules",
|
||||
column: "NotificationConfigId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "notification_config_modules");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "notification_config");
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 24);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
public partial class UpdateItemSellingPrices : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "SalePrice",
|
||||
table: "items",
|
||||
newName: "SellingFlatPrice");
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "SellingMinPrice",
|
||||
table: "items",
|
||||
type: "numeric(18,4)",
|
||||
precision: 18,
|
||||
scale: 4,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "SellingMaxPrice",
|
||||
table: "items",
|
||||
type: "numeric(18,4)",
|
||||
precision: 18,
|
||||
scale: 4,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SellingMinPrice",
|
||||
table: "items");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SellingMaxPrice",
|
||||
table: "items");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "SellingFlatPrice",
|
||||
table: "items",
|
||||
newName: "SalePrice");
|
||||
}
|
||||
}
|
||||
}
|
||||
+7600
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddProductConfigFeatureFlags : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "BatchListsEnabled",
|
||||
table: "product_config",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "SellingPricesEnabled",
|
||||
table: "product_config",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "SerialNumbersEnabled",
|
||||
table: "product_config",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "WarrantyPeriodsEnabled",
|
||||
table: "product_config",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BatchListsEnabled",
|
||||
table: "product_config");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SellingPricesEnabled",
|
||||
table: "product_config");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SerialNumbersEnabled",
|
||||
table: "product_config");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "WarrantyPeriodsEnabled",
|
||||
table: "product_config");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -422,6 +422,9 @@ namespace ERPCore.Migrations
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<int?>("DayEndId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<decimal>("DiscountTotal")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
@@ -462,6 +465,8 @@ namespace ERPCore.Migrations
|
||||
|
||||
b.HasIndex("CustomerId");
|
||||
|
||||
b.HasIndex("DayEndId");
|
||||
|
||||
b.HasIndex("WarehouseId");
|
||||
|
||||
b.ToTable("bundle_sales", (string)null);
|
||||
@@ -1406,6 +1411,10 @@ namespace ERPCore.Migrations
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("GrnId"));
|
||||
|
||||
b.Property<decimal>("BalanceAmount")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
@@ -1417,6 +1426,17 @@ namespace ERPCore.Migrations
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<string>("GlJournalNo")
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<DateTime?>("GlPostedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<decimal>("PaidAmount")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
|
||||
b.Property<int?>("PoId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
@@ -1537,6 +1557,82 @@ namespace ERPCore.Migrations
|
||||
b.ToTable("grn_lines", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLineWarrantyNumber", b =>
|
||||
{
|
||||
b.Property<int>("GrnLineWarrantyNumberId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("GrnLineWarrantyNumberId"));
|
||||
|
||||
b.Property<int>("GrnLineId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("WarrantyNo")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<int>("WarrantyPeriodMonths")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("GrnLineWarrantyNumberId");
|
||||
|
||||
b.HasIndex("GrnLineId", "WarrantyNo")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("grn_line_warranty_numbers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.GrnPayment", b =>
|
||||
{
|
||||
b.Property<int>("GrnPaymentId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("GrnPaymentId"));
|
||||
|
||||
b.Property<decimal>("Amount")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
|
||||
b.Property<string>("BankAccountName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("CreatedBy")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<long>("GlBankAccountId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("GlJournalNo")
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<int>("GrnId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("PaymentDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Reference")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.HasKey("GrnPaymentId");
|
||||
|
||||
b.HasIndex("CreatedBy");
|
||||
|
||||
b.HasIndex("GrnId");
|
||||
|
||||
b.ToTable("grn_payments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.HrDocumentType", b =>
|
||||
{
|
||||
b.Property<int>("HrDocumentTypeId")
|
||||
@@ -1649,7 +1745,15 @@ namespace ERPCore.Migrations
|
||||
.HasColumnType("xid")
|
||||
.HasColumnName("xmin");
|
||||
|
||||
b.Property<decimal?>("SalePrice")
|
||||
b.Property<decimal?>("SellingFlatPrice")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
|
||||
b.Property<decimal?>("SellingMaxPrice")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
|
||||
b.Property<decimal?>("SellingMinPrice")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
|
||||
@@ -1685,6 +1789,16 @@ namespace ERPCore.Migrations
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Warranty")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("NonWarranty");
|
||||
|
||||
b.Property<int?>("WarrantyPeriodMonths")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("ItemId");
|
||||
|
||||
b.HasIndex("BaseUomId");
|
||||
@@ -2228,9 +2342,86 @@ namespace ERPCore.Migrations
|
||||
Label = "Sales",
|
||||
SortOrder = 13,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
NavItemId = 14,
|
||||
Code = "production",
|
||||
Href = "/dashboard/production",
|
||||
Label = "Production",
|
||||
SortOrder = 14,
|
||||
Status = "Active"
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.NotificationConfig", b =>
|
||||
{
|
||||
b.Property<int>("NotificationConfigId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<uint>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("xid")
|
||||
.HasColumnName("xmin");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int?>("UpdatedBy")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("NotificationConfigId");
|
||||
|
||||
b.HasIndex("UpdatedBy");
|
||||
|
||||
b.ToTable("notification_config", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_notification_config_singleton", "\"NotificationConfigId\" = 1");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.NotificationConfigModule", b =>
|
||||
{
|
||||
b.Property<int>("NotificationConfigModuleId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("NotificationConfigModuleId"));
|
||||
|
||||
b.Property<bool>("EmailEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<bool>("InAppEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<string>("ModuleKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<int>("NotificationConfigId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("SmsEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.HasKey("NotificationConfigModuleId");
|
||||
|
||||
b.HasIndex("ModuleKey")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("NotificationConfigId");
|
||||
|
||||
b.ToTable("notification_config_modules", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.NumberSequence", b =>
|
||||
{
|
||||
b.Property<int>("SequenceId")
|
||||
@@ -2774,6 +2965,30 @@ namespace ERPCore.Migrations
|
||||
PermissionId = 34,
|
||||
Code = "NAV:accounts.received-cheques",
|
||||
SubNavItemId = 22
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 35,
|
||||
Code = "NAV:production",
|
||||
NavItemId = 14
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 36,
|
||||
Code = "NAV:production.templates",
|
||||
SubNavItemId = 24
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 37,
|
||||
Code = "NAV:production.runs",
|
||||
SubNavItemId = 25
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 38,
|
||||
Code = "NAV:production.produced",
|
||||
SubNavItemId = 26
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2826,6 +3041,11 @@ namespace ERPCore.Migrations
|
||||
b.Property<int>("ConfigId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("BatchListsEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<bool>("BrandsEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
@@ -2842,6 +3062,16 @@ namespace ERPCore.Migrations
|
||||
.HasColumnType("xid")
|
||||
.HasColumnName("xmin");
|
||||
|
||||
b.Property<bool>("SellingPricesEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<bool>("SerialNumbersEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<bool>("SubcategoriesEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
@@ -2853,6 +3083,11 @@ namespace ERPCore.Migrations
|
||||
b.Property<int?>("UpdatedBy")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("WarrantyPeriodsEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.HasKey("ConfigId");
|
||||
|
||||
b.HasIndex("UpdatedBy");
|
||||
@@ -3070,6 +3305,13 @@ namespace ERPCore.Migrations
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<string>("GlJournalNo")
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<DateTime?>("GlPostedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("ReasonCodeId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
@@ -3675,6 +3917,85 @@ namespace ERPCore.Migrations
|
||||
b.ToTable("hr_salary_components", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesDayEnd", b =>
|
||||
{
|
||||
b.Property<int>("SalesDayEndId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("SalesDayEndId"));
|
||||
|
||||
b.Property<int>("BundleCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
b.Property<DateOnly>("BusinessDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<int>("CashierUserId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("ClosedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("ClosedBy")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<decimal>("CostOfGoodsSold")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
|
||||
b.Property<decimal>("DiscountTotal")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
|
||||
b.Property<string>("DocNo")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<string>("GlJournalNo")
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<DateTime?>("GlPostedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<decimal>("GrandTotal")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
|
||||
b.Property<uint>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("xid")
|
||||
.HasColumnName("xmin");
|
||||
|
||||
b.Property<int>("SlipCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<decimal>("Subtotal")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
|
||||
b.Property<decimal>("TaxTotal")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
|
||||
b.HasKey("SalesDayEndId");
|
||||
|
||||
b.HasIndex("ClosedBy");
|
||||
|
||||
b.HasIndex("DocNo")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("CashierUserId", "BusinessDate")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("sales_day_ends", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoice", b =>
|
||||
{
|
||||
b.Property<int>("SalesInvoiceId")
|
||||
@@ -3712,6 +4033,13 @@ namespace ERPCore.Migrations
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
|
||||
b.Property<string>("GlJournalNo")
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<DateTime?>("GlPostedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<decimal>("GrandTotal")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
@@ -3881,6 +4209,143 @@ namespace ERPCore.Migrations
|
||||
b.ToTable("sales_invoice_lines", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoicePayment", b =>
|
||||
{
|
||||
b.Property<int>("SalesInvoicePaymentId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("SalesInvoicePaymentId"));
|
||||
|
||||
b.Property<decimal>("Amount")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
|
||||
b.Property<string>("BankAccountName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("CreatedBy")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<long>("GlBankAccountId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("GlJournalNo")
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<DateTime>("PaymentDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Reference")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<int>("SalesInvoiceId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("SalesInvoicePaymentId");
|
||||
|
||||
b.HasIndex("CreatedBy");
|
||||
|
||||
b.HasIndex("SalesInvoiceId");
|
||||
|
||||
b.ToTable("sales_invoice_payments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b =>
|
||||
{
|
||||
b.Property<int>("ReturnId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("ReturnId"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("CreatedBy")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("CustomerId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("DocNo")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<string>("GlJournalNo")
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<DateTime?>("GlPostedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("ReasonCodeId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<int>("WarehouseId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("ReturnId");
|
||||
|
||||
b.HasIndex("CreatedBy");
|
||||
|
||||
b.HasIndex("CustomerId");
|
||||
|
||||
b.HasIndex("DocNo")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("ReasonCodeId");
|
||||
|
||||
b.HasIndex("WarehouseId");
|
||||
|
||||
b.ToTable("sales_returns", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturnLine", b =>
|
||||
{
|
||||
b.Property<int>("ReturnLineId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("ReturnLineId"));
|
||||
|
||||
b.Property<int>("ItemId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<decimal>("Qty")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
|
||||
b.Property<int>("ReturnId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("SalesInvoiceLineId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("ReturnLineId");
|
||||
|
||||
b.HasIndex("ItemId");
|
||||
|
||||
b.HasIndex("ReturnId");
|
||||
|
||||
b.HasIndex("SalesInvoiceLineId");
|
||||
|
||||
b.ToTable("sales_return_lines", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b =>
|
||||
{
|
||||
b.Property<int>("SalesSlipId")
|
||||
@@ -3907,6 +4372,9 @@ namespace ERPCore.Migrations
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<int?>("DayEndId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<decimal>("DiscountTotal")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
@@ -3960,6 +4428,8 @@ namespace ERPCore.Migrations
|
||||
|
||||
b.HasIndex("CustomerId");
|
||||
|
||||
b.HasIndex("DayEndId");
|
||||
|
||||
b.HasIndex("SlipDate");
|
||||
|
||||
b.HasIndex("SlipNo")
|
||||
@@ -4221,6 +4691,13 @@ namespace ERPCore.Migrations
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<string>("GlJournalNo")
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<DateTime?>("GlPostedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("ReasonCodeId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
@@ -4800,6 +5277,16 @@ namespace ERPCore.Migrations
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 24,
|
||||
Code = "settings.notification-config",
|
||||
Href = "/dashboard/settings/notification-config",
|
||||
Label = "Notification Config",
|
||||
NavItemId = 9,
|
||||
SortOrder = 3,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 23,
|
||||
Code = "sales.bundle-sales",
|
||||
@@ -4948,6 +5435,36 @@ namespace ERPCore.Migrations
|
||||
NavItemId = 12,
|
||||
SortOrder = 3,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 24,
|
||||
Code = "production.templates",
|
||||
Href = "/dashboard/production/templates",
|
||||
Label = "Templates",
|
||||
NavItemId = 14,
|
||||
SortOrder = 1,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 25,
|
||||
Code = "production.runs",
|
||||
Href = "/dashboard/production/runs",
|
||||
Label = "Runs",
|
||||
NavItemId = 14,
|
||||
SortOrder = 2,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 26,
|
||||
Code = "production.produced",
|
||||
Href = "/dashboard/production/produced",
|
||||
Label = "Produced",
|
||||
NavItemId = 14,
|
||||
SortOrder = 3,
|
||||
Status = "Active"
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5409,6 +5926,11 @@ namespace ERPCore.Migrations
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.SalesDayEnd", "DayEnd")
|
||||
.WithMany("BundleSales")
|
||||
.HasForeignKey("DayEndId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
|
||||
.WithMany()
|
||||
.HasForeignKey("WarehouseId")
|
||||
@@ -5421,6 +5943,8 @@ namespace ERPCore.Migrations
|
||||
|
||||
b.Navigation("Customer");
|
||||
|
||||
b.Navigation("DayEnd");
|
||||
|
||||
b.Navigation("Warehouse");
|
||||
});
|
||||
|
||||
@@ -5713,6 +6237,36 @@ namespace ERPCore.Migrations
|
||||
b.Navigation("PoLine");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLineWarrantyNumber", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine")
|
||||
.WithMany("WarrantyNumbers")
|
||||
.HasForeignKey("GrnLineId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("GrnLine");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.GrnPayment", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
|
||||
.WithMany()
|
||||
.HasForeignKey("CreatedBy")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Grn", "Grn")
|
||||
.WithMany("Payments")
|
||||
.HasForeignKey("GrnId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Creator");
|
||||
|
||||
b.Navigation("Grn");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.Item", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Uom", "BaseUom")
|
||||
@@ -5828,6 +6382,27 @@ namespace ERPCore.Migrations
|
||||
b.Navigation("PayrollRun");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.NotificationConfig", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser")
|
||||
.WithMany()
|
||||
.HasForeignKey("UpdatedBy")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("UpdatedByUser");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.NotificationConfigModule", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.NotificationConfig", "NotificationConfig")
|
||||
.WithMany("Modules")
|
||||
.HasForeignKey("NotificationConfigId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("NotificationConfig");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLine", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Employee", "Employee")
|
||||
@@ -6286,6 +6861,25 @@ namespace ERPCore.Migrations
|
||||
b.Navigation("Uom");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesDayEnd", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.User", "CashierUser")
|
||||
.WithMany()
|
||||
.HasForeignKey("CashierUserId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.User", "ClosedByUser")
|
||||
.WithMany()
|
||||
.HasForeignKey("ClosedBy")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("CashierUser");
|
||||
|
||||
b.Navigation("ClosedByUser");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoice", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
|
||||
@@ -6338,6 +6932,86 @@ namespace ERPCore.Migrations
|
||||
b.Navigation("Warehouse");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoicePayment", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
|
||||
.WithMany()
|
||||
.HasForeignKey("CreatedBy")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.SalesInvoice", "SalesInvoice")
|
||||
.WithMany("Payments")
|
||||
.HasForeignKey("SalesInvoiceId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Creator");
|
||||
|
||||
b.Navigation("SalesInvoice");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
|
||||
.WithMany()
|
||||
.HasForeignKey("CreatedBy")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Customer", "Customer")
|
||||
.WithMany()
|
||||
.HasForeignKey("CustomerId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ReasonCode")
|
||||
.WithMany()
|
||||
.HasForeignKey("ReasonCodeId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
|
||||
.WithMany()
|
||||
.HasForeignKey("WarehouseId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Creator");
|
||||
|
||||
b.Navigation("Customer");
|
||||
|
||||
b.Navigation("ReasonCode");
|
||||
|
||||
b.Navigation("Warehouse");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturnLine", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
|
||||
.WithMany()
|
||||
.HasForeignKey("ItemId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.SalesReturn", "Return")
|
||||
.WithMany("Lines")
|
||||
.HasForeignKey("ReturnId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.SalesInvoiceLine", "SalesInvoiceLine")
|
||||
.WithMany()
|
||||
.HasForeignKey("SalesInvoiceLineId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("Item");
|
||||
|
||||
b.Navigation("Return");
|
||||
|
||||
b.Navigation("SalesInvoiceLine");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.User", "CashierUser")
|
||||
@@ -6352,6 +7026,11 @@ namespace ERPCore.Migrations
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.SalesDayEnd", "DayEnd")
|
||||
.WithMany("Slips")
|
||||
.HasForeignKey("DayEndId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
|
||||
.WithMany()
|
||||
.HasForeignKey("WarehouseId")
|
||||
@@ -6362,6 +7041,8 @@ namespace ERPCore.Migrations
|
||||
|
||||
b.Navigation("Customer");
|
||||
|
||||
b.Navigation("DayEnd");
|
||||
|
||||
b.Navigation("Warehouse");
|
||||
});
|
||||
|
||||
@@ -6835,6 +7516,13 @@ namespace ERPCore.Migrations
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b =>
|
||||
{
|
||||
b.Navigation("Lines");
|
||||
|
||||
b.Navigation("Payments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b =>
|
||||
{
|
||||
b.Navigation("WarrantyNumbers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.Item", b =>
|
||||
@@ -6847,6 +7535,11 @@ namespace ERPCore.Migrations
|
||||
b.Navigation("Children");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.NotificationConfig", b =>
|
||||
{
|
||||
b.Navigation("Modules");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLine", b =>
|
||||
{
|
||||
b.Navigation("Components");
|
||||
@@ -6906,7 +7599,21 @@ namespace ERPCore.Migrations
|
||||
b.Navigation("Outputs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesDayEnd", b =>
|
||||
{
|
||||
b.Navigation("BundleSales");
|
||||
|
||||
b.Navigation("Slips");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoice", b =>
|
||||
{
|
||||
b.Navigation("Lines");
|
||||
|
||||
b.Navigation("Payments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b =>
|
||||
{
|
||||
b.Navigation("Lines");
|
||||
});
|
||||
|
||||
@@ -85,6 +85,8 @@ builder.Services.AddScoped<IBrandService, BrandService>();
|
||||
builder.Services.AddScoped<IItemTypeService, ItemTypeService>();
|
||||
//builder.Services.AddScoped<ICompanyProfileService, CompanyProfileService>();
|
||||
builder.Services.AddScoped<IProductConfigService, ProductConfigService>();
|
||||
builder.Services.AddSingleton<INotificationModuleCatalog, NotificationModuleCatalog>();
|
||||
builder.Services.AddScoped<INotificationConfigService, NotificationConfigService>();
|
||||
builder.Services.AddScoped<IVendorService, VendorService>();
|
||||
builder.Services.AddScoped<IWarehouseService, WarehouseService>();
|
||||
|
||||
@@ -103,6 +105,7 @@ builder.Services.AddScoped<IItemMeasure, ItemMeasure>();
|
||||
builder.Services.AddScoped<IFifoCostingService, FifoCostingService>();
|
||||
builder.Services.AddScoped<IStockService, StockService>();
|
||||
builder.Services.AddScoped<IGrnService, GrnService>();
|
||||
builder.Services.AddScoped<IGrnPaymentService, GrnPaymentService>();
|
||||
|
||||
// Sales (Phase 1)
|
||||
builder.Services.AddScoped<ISalesPricingService, SalesPricingService>();
|
||||
@@ -111,11 +114,13 @@ builder.Services.AddScoped<ISalesPostingService, SalesPostingService>();
|
||||
builder.Services.AddScoped<ISalesDocumentWorkflowService, SalesDocumentWorkflowService>();
|
||||
builder.Services.AddScoped<ISalesMappingService, SalesMappingService>();
|
||||
builder.Services.AddScoped<ISalesInvoiceService, SalesInvoiceService>();
|
||||
builder.Services.AddScoped<ISalesInvoicePaymentService, SalesInvoicePaymentService>();
|
||||
builder.Services.AddScoped<ISalesSlipService, SalesSlipService>();
|
||||
builder.Services.AddScoped<IBundleSaleService, BundleSaleService>();
|
||||
builder.Services.AddScoped<ISalesPromotionSuggestionService, SalesPromotionSuggestionService>();
|
||||
builder.Services.AddScoped<ISalesReportService, SalesReportService>();
|
||||
builder.Services.AddScoped<ISalesReturnService, SalesReturnService>();
|
||||
builder.Services.AddScoped<ISalesDayEndService, SalesDayEndService>();
|
||||
|
||||
// Stock transactions + reference data (docs/11 §5–6)
|
||||
builder.Services.AddScoped<IStockMutator, StockMutator>();
|
||||
|
||||
@@ -6,6 +6,7 @@ using ERPCore.Dtos.Stock;
|
||||
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;
|
||||
@@ -16,7 +17,10 @@ namespace ERPCore.Services;
|
||||
/// Stock-adjustment service — the highest-risk feature (02-SECURITY C.5). Auto-posts
|
||||
/// with a mandatory reason code and user stamp. Line application (FIFO consume on a
|
||||
/// decrease, layer create on an increase) + ledger posting is delegated to
|
||||
/// <see cref="IStockMutator"/>. Runs in a single UoW transaction (NFR-02/05).
|
||||
/// <see cref="IStockMutator"/>. Runs in a single UoW transaction (NFR-02/05). Also posts
|
||||
/// a real GL journal entry — increases and decreases post as separate Inventory/Gain and
|
||||
/// Loss/Inventory lines respectively (never netted against each other), so a count that's
|
||||
/// simultaneously over on one item and under on another shows both, not a false net.
|
||||
/// </summary>
|
||||
public sealed class AdjustmentService : IAdjustmentService
|
||||
{
|
||||
@@ -29,11 +33,16 @@ public sealed class AdjustmentService : IAdjustmentService
|
||||
private readonly INumberSequenceService _numbers;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly IGeneralLedgerService _gl;
|
||||
private readonly string _glInventoryAccountCode;
|
||||
private readonly string _glGainAccountCode;
|
||||
private readonly string _glLossAccountCode;
|
||||
|
||||
public AdjustmentService(
|
||||
IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses, IRepository<Item> items,
|
||||
IRepository<ReasonCode> reasonCodes, IRepository<StockLedger> ledger, IStockMutator mutator,
|
||||
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
|
||||
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow,
|
||||
IGeneralLedgerService gl, IConfiguration configuration)
|
||||
{
|
||||
_adjustments = adjustments;
|
||||
_warehouses = warehouses;
|
||||
@@ -44,6 +53,45 @@ public sealed class AdjustmentService : IAdjustmentService
|
||||
_numbers = numbers;
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
_gl = gl;
|
||||
_glInventoryAccountCode = configuration["Adjustment:GlInventoryAccountCode"] ?? string.Empty;
|
||||
_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.
|
||||
/// <see cref="CountService"/>'s variance posting mirrors this exact logic for the same reason
|
||||
/// (it creates a <see cref="StockAdjustment"/> through the same <see cref="IStockMutator"/> call).</summary>
|
||||
private async Task<string?> PostAdjustmentJournalAsync(
|
||||
string docNo, DateTime now, IReadOnlyList<StockLedger> refs, CancellationToken ct)
|
||||
{
|
||||
var gain = refs.Where(r => r.Direction == Direction.In).Sum(r => r.Value);
|
||||
var loss = refs.Where(r => r.Direction == Direction.Out).Sum(r => r.Value);
|
||||
if (gain <= 0 && loss <= 0) return null;
|
||||
|
||||
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), ct);
|
||||
var lines = new List<GlJournalEntryLineRequest>();
|
||||
if (gain > 0)
|
||||
{
|
||||
lines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, gain, 0m, $"Adjustment {docNo} — stock increase"));
|
||||
lines.Add(new GlJournalEntryLineRequest(_glGainAccountCode, 0m, gain, $"Adjustment {docNo} — inventory gain"));
|
||||
}
|
||||
if (loss > 0)
|
||||
{
|
||||
lines.Add(new GlJournalEntryLineRequest(_glLossAccountCode, loss, 0m, $"Adjustment {docNo} — inventory loss"));
|
||||
lines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, 0m, loss, $"Adjustment {docNo} — stock decrease"));
|
||||
}
|
||||
|
||||
var result = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||
{
|
||||
PeriodId = period.PeriodId,
|
||||
EntryDate = DateOnly.FromDateTime(now),
|
||||
SourceModule = "ADJUSTMENT",
|
||||
Reference = docNo,
|
||||
Narration = $"Stock adjustment {docNo}",
|
||||
Lines = lines
|
||||
}, ct);
|
||||
return result.JournalNo;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<AdjustmentSummaryDto>> ListAsync(
|
||||
@@ -64,7 +112,7 @@ public sealed class AdjustmentService : IAdjustmentService
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.Select(a => new AdjustmentSummaryDto(
|
||||
a.AdjustmentId, a.DocNo, a.WarehouseId, a.ReasonCodeId, a.Status,
|
||||
a.CreatedBy, a.CreatedAt, a.Lines.Count))
|
||||
a.CreatedBy, a.CreatedAt, a.Lines.Count, a.GlJournalNo))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return PagedResponse<AdjustmentSummaryDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
@@ -136,6 +184,10 @@ public sealed class AdjustmentService : IAdjustmentService
|
||||
await _uow.SaveChangesAsync(token); // flush so AdjustmentId is a valid ledger sourceDocId
|
||||
|
||||
var refs = await _mutator.ApplyAsync(request.WarehouseId, DocumentTypes.Adjustment, entity.AdjustmentId, now, deltas, token);
|
||||
|
||||
entity.GlJournalNo = await PostAdjustmentJournalAsync(docNo, now, refs, token);
|
||||
if (entity.GlJournalNo is not null) entity.GlPostedAt = now;
|
||||
|
||||
return (entity, refs);
|
||||
}, ct);
|
||||
|
||||
@@ -144,6 +196,7 @@ public sealed class AdjustmentService : IAdjustmentService
|
||||
|
||||
private static AdjustmentDto ToDto(StockAdjustment a, IReadOnlyList<int> ledgerRefs) => new(
|
||||
a.AdjustmentId, a.DocNo, a.WarehouseId, a.ReasonCodeId, a.Status, a.CreatedBy, a.CreatedAt,
|
||||
a.GlJournalNo, a.GlPostedAt,
|
||||
a.Lines.OrderBy(l => l.AdjLineId)
|
||||
.Select(l => new AdjustmentLineDto(l.AdjLineId, l.ItemId, l.BinId, l.BatchId, l.QtyDelta)).ToList(),
|
||||
ledgerRefs);
|
||||
|
||||
@@ -17,6 +17,7 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
{
|
||||
private readonly IRepository<BundleSaleTemplate> _templates;
|
||||
private readonly IRepository<BundleSale> _bundles;
|
||||
private readonly IRepository<SalesDayEnd> _dayEnds;
|
||||
private readonly IRepository<Customer> _customers;
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
@@ -30,6 +31,7 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
public BundleSaleService(
|
||||
IRepository<BundleSale> bundles,
|
||||
IRepository<BundleSaleTemplate> templates,
|
||||
IRepository<SalesDayEnd> dayEnds,
|
||||
IRepository<Customer> customers,
|
||||
IRepository<Item> items,
|
||||
IRepository<Warehouse> warehouses,
|
||||
@@ -42,6 +44,7 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
{
|
||||
_templates = templates;
|
||||
_bundles = bundles;
|
||||
_dayEnds = dayEnds;
|
||||
_customers = customers;
|
||||
_items = items;
|
||||
_warehouses = warehouses;
|
||||
@@ -82,6 +85,45 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
x.BundleSaleTemplateLineId, x.ItemId, x.WarehouseId, x.Qty, x.UnitPrice, x.IncludeInBundle, x.SortOrder)).ToList());
|
||||
}
|
||||
|
||||
public async Task<BundleSaleTemplateDto> CreateTemplateAsync(CreateBundleSaleTemplateRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var template = new BundleSaleTemplate
|
||||
{
|
||||
TemplateCode = await _numbers.NextAsync(DocumentTypes.BundleSaleTemplate, ct),
|
||||
TemplateName = request.TemplateName,
|
||||
Description = request.Description,
|
||||
Status = EntityStatus.Active,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
foreach (var line in request.Lines)
|
||||
{
|
||||
if (!await _items.Query().AsNoTracking().AnyAsync(x => x.ItemId == line.ItemId, ct))
|
||||
throw new NotFoundException($"Item {line.ItemId} was not found.");
|
||||
if (!await _warehouses.Query().AsNoTracking().AnyAsync(x => x.WarehouseId == line.WarehouseId, ct))
|
||||
throw new NotFoundException($"Warehouse {line.WarehouseId} was not found.");
|
||||
|
||||
template.Lines.Add(new BundleSaleTemplateLine
|
||||
{
|
||||
ItemId = line.ItemId,
|
||||
WarehouseId = line.WarehouseId,
|
||||
Qty = line.Qty,
|
||||
UnitPrice = line.UnitPrice,
|
||||
IncludeInBundle = line.IncludeInBundle,
|
||||
SortOrder = line.SortOrder
|
||||
});
|
||||
}
|
||||
|
||||
await _templates.AddAsync(template, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
return new BundleSaleTemplateDto(
|
||||
template.BundleSaleTemplateId, template.TemplateCode, template.TemplateName, template.Description,
|
||||
template.Status, template.CreatedAt, template.UpdatedAt,
|
||||
template.Lines.OrderBy(x => x.SortOrder).Select(x => new BundleSaleTemplateLineDto(
|
||||
x.BundleSaleTemplateLineId, x.ItemId, x.WarehouseId, x.Qty, x.UnitPrice, x.IncludeInBundle, x.SortOrder)).ToList());
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<BundleSaleSummaryDto>> ListAsync(PageQuery query, BundleSaleStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default)
|
||||
{
|
||||
IQueryable<BundleSale> q = _bundles.Query().AsNoTracking().Include(x => x.Lines);
|
||||
@@ -110,6 +152,15 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
public async Task<BundleSaleDto> CreateAsync(CreateBundleSaleRequest request, CancellationToken ct = default)
|
||||
{
|
||||
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, true, ct);
|
||||
|
||||
// Same guard as SalesSlipService.CreateAsync — a bundle sale is a cashier document
|
||||
// exactly like a sales slip, so it's blocked by the same closed day (docs/14 Sales Day End).
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var alreadyClosed = await _dayEnds.Query().AsNoTracking()
|
||||
.AnyAsync(x => x.CashierUserId == request.CashierUserId && x.BusinessDate == today, ct);
|
||||
if (alreadyClosed)
|
||||
throw new ConflictException($"Cashier {request.CashierUserId} already closed today's ({today:yyyy-MM-dd}) sales — day-end has been posted.");
|
||||
|
||||
var template = await _templates.Query().AsNoTracking().Include(x => x.Lines)
|
||||
.FirstAsync(x => x.BundleSaleTemplateId == request.BundleSaleTemplateId, ct);
|
||||
var bundle = new BundleSale
|
||||
|
||||
@@ -6,6 +6,7 @@ using ERPCore.Dtos.Stock;
|
||||
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;
|
||||
@@ -16,7 +17,9 @@ namespace ERPCore.Services;
|
||||
/// Stock-count service (FR-STK-08). Create snapshots system quantities (immutable,
|
||||
/// 02-SECURITY C.7); posting emits a variance <see cref="StockAdjustment"/> via the
|
||||
/// shared <see cref="IStockMutator"/> (a variance is an adjustment in disguise, C.7)
|
||||
/// and closes the count — all in one UoW transaction.
|
||||
/// and closes the count — all in one UoW transaction. GL posting for that variance
|
||||
/// mirrors <see cref="AdjustmentService"/>'s (same account config, same gain/loss
|
||||
/// split) since it's the exact same kind of document under the hood.
|
||||
/// </summary>
|
||||
public sealed class CountService : ICountService
|
||||
{
|
||||
@@ -32,11 +35,16 @@ public sealed class CountService : ICountService
|
||||
private readonly INumberSequenceService _numbers;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly IGeneralLedgerService _gl;
|
||||
private readonly string _glInventoryAccountCode;
|
||||
private readonly string _glGainAccountCode;
|
||||
private readonly string _glLossAccountCode;
|
||||
|
||||
public CountService(
|
||||
IRepository<StockCount> counts, IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses,
|
||||
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IFifoCostingService fifo, IStockMutator mutator,
|
||||
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
|
||||
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow,
|
||||
IGeneralLedgerService gl, IConfiguration configuration)
|
||||
{
|
||||
_counts = counts;
|
||||
_adjustments = adjustments;
|
||||
@@ -48,6 +56,44 @@ public sealed class CountService : ICountService
|
||||
_numbers = numbers;
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
_gl = gl;
|
||||
_glInventoryAccountCode = configuration["Adjustment:GlInventoryAccountCode"] ?? string.Empty;
|
||||
_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
|
||||
/// gains/losses post as separate lines instead of a net.</summary>
|
||||
private async Task<string?> PostAdjustmentJournalAsync(
|
||||
string docNo, DateTime now, IReadOnlyList<StockLedger> refs, CancellationToken ct)
|
||||
{
|
||||
var gain = refs.Where(r => r.Direction == Direction.In).Sum(r => r.Value);
|
||||
var loss = refs.Where(r => r.Direction == Direction.Out).Sum(r => r.Value);
|
||||
if (gain <= 0 && loss <= 0) return null;
|
||||
|
||||
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), ct);
|
||||
var lines = new List<GlJournalEntryLineRequest>();
|
||||
if (gain > 0)
|
||||
{
|
||||
lines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, gain, 0m, $"Adjustment {docNo} — stock increase"));
|
||||
lines.Add(new GlJournalEntryLineRequest(_glGainAccountCode, 0m, gain, $"Adjustment {docNo} — inventory gain"));
|
||||
}
|
||||
if (loss > 0)
|
||||
{
|
||||
lines.Add(new GlJournalEntryLineRequest(_glLossAccountCode, loss, 0m, $"Adjustment {docNo} — inventory loss"));
|
||||
lines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, 0m, loss, $"Adjustment {docNo} — stock decrease"));
|
||||
}
|
||||
|
||||
var result = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||
{
|
||||
PeriodId = period.PeriodId,
|
||||
EntryDate = DateOnly.FromDateTime(now),
|
||||
SourceModule = "ADJUSTMENT",
|
||||
Reference = docNo,
|
||||
Narration = $"Stock adjustment {docNo}",
|
||||
Lines = lines
|
||||
}, ct);
|
||||
return result.JournalNo;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<CountSummaryDto>> ListAsync(
|
||||
@@ -160,7 +206,7 @@ public sealed class CountService : ICountService
|
||||
{
|
||||
count.Status = CountStatus.Posted;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
return new CountPostResultDto(count.CountId, count.Status, null, Array.Empty<int>());
|
||||
return new CountPostResultDto(count.CountId, count.Status, null, null, Array.Empty<int>());
|
||||
}
|
||||
|
||||
var reason = await _reasonCodes.Query().AsNoTracking()
|
||||
@@ -168,7 +214,7 @@ public sealed class CountService : ICountService
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"Reason code '{VarianceReasonCode}' (Count Variance) is not configured.", 422);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var (adjustmentId, ledgerEntries) = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
var (adjustmentId, glJournalNo, ledgerEntries) = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
var docNo = await _numbers.NextAsync(DocumentTypes.Adjustment, token);
|
||||
var adjustment = new StockAdjustment
|
||||
@@ -191,12 +237,15 @@ public sealed class CountService : ICountService
|
||||
|
||||
var refs = await _mutator.ApplyAsync(count.WarehouseId, DocumentTypes.Adjustment, adjustment.AdjustmentId, now, deltas, token);
|
||||
|
||||
adjustment.GlJournalNo = await PostAdjustmentJournalAsync(docNo, now, refs, token);
|
||||
if (adjustment.GlJournalNo is not null) adjustment.GlPostedAt = now;
|
||||
|
||||
count.Status = CountStatus.Posted;
|
||||
return (adjustment.AdjustmentId, refs);
|
||||
return (adjustment.AdjustmentId, adjustment.GlJournalNo, refs);
|
||||
}, ct);
|
||||
|
||||
// Map ledger ids after commit so they are populated.
|
||||
return new CountPostResultDto(count.CountId, count.Status, adjustmentId, ledgerEntries.Select(r => r.LedgerId).ToList());
|
||||
return new CountPostResultDto(count.CountId, count.Status, adjustmentId, glJournalNo, ledgerEntries.Select(r => r.LedgerId).ToList());
|
||||
}
|
||||
|
||||
private static CountDto Map(StockCount c) => new(
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using ERPCore.Infra.Gl;
|
||||
using ERPCore.Services.Gl;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <inheritdoc cref="IGeneralLedgerService"/>
|
||||
public sealed class GeneralLedgerService : IGeneralLedgerService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly IGeneralLedgerClient _client;
|
||||
|
||||
public GeneralLedgerService(IGeneralLedgerClient client) => _client = client;
|
||||
@@ -13,4 +23,89 @@ public sealed class GeneralLedgerService : IGeneralLedgerService
|
||||
public Task<GeneralLedgerResponse> ForwardAsync(
|
||||
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct)
|
||||
=> _client.SendAsync(method, path, queryString, contentType, body, ct);
|
||||
|
||||
public async Task<GlJournalEntryResult> PostJournalEntryAsync(GlJournalEntryRequest request, CancellationToken ct)
|
||||
{
|
||||
using var body = new MemoryStream(JsonSerializer.SerializeToUtf8Bytes(request, JsonOptions));
|
||||
var response = await _client.SendAsync(HttpMethod.Post, "journal-entries", null, "application/json", body, ct);
|
||||
var data = ParseSuccess<GlJournalEntryData>(response);
|
||||
return new GlJournalEntryResult(data.JournalNo, data.IsPosted);
|
||||
}
|
||||
|
||||
public async Task<GlPeriod> GetPeriodByDateAsync(DateOnly date, CancellationToken ct)
|
||||
{
|
||||
var response = await _client.SendAsync(
|
||||
HttpMethod.Get, "fiscal-years/periods/by-date", $"?date={date:yyyy-MM-dd}", null, null, ct);
|
||||
var data = ParseSuccess<GlPeriodData>(response);
|
||||
return new GlPeriod(data.PeriodId, data.FiscalYearId);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<GlBankAccount>> ListBankAccountsAsync(CancellationToken ct)
|
||||
{
|
||||
var response = await _client.SendAsync(HttpMethod.Get, "bank-accounts", "?accountType=Both", null, null, ct);
|
||||
var data = ParseSuccess<List<GlBankAccountData>>(response);
|
||||
return data.Select(a => new GlBankAccount(
|
||||
a.AccountType, a.AccountId, a.AccountName, a.BankName,
|
||||
a.CashAccountTypeName, a.AccountNumber, a.GlAccountId, a.GlAccountCode, a.CurrencyCode)).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses GL's <c>ApiResponse</c> envelope (case-insensitively, since GL's success bodies are
|
||||
/// camelCase and its error bodies are PascalCase — docs/12 §4) and throws
|
||||
/// <see cref="DomainException"/> if the call didn't succeed.
|
||||
/// </summary>
|
||||
private static T ParseSuccess<T>(GeneralLedgerResponse response)
|
||||
{
|
||||
GlEnvelope<T>? envelope;
|
||||
try
|
||||
{
|
||||
envelope = JsonSerializer.Deserialize<GlEnvelope<T>>(response.Body, JsonOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
envelope = null;
|
||||
}
|
||||
|
||||
if (response.StatusCode is < 200 or >= 300 || envelope is null || !envelope.Success || envelope.Data is null)
|
||||
{
|
||||
var message = envelope?.Message ?? "The General Ledger service rejected the request.";
|
||||
var status = response.StatusCode is >= 400 and < 500 ? response.StatusCode : 502;
|
||||
throw new DomainException(ErrorCodes.GlRequestFailed, message, status);
|
||||
}
|
||||
|
||||
return envelope.Data;
|
||||
}
|
||||
|
||||
private sealed class GlEnvelope<T>
|
||||
{
|
||||
public int StatusCode { get; set; }
|
||||
public bool Success { get; set; }
|
||||
public string? Message { get; set; }
|
||||
public T? Data { get; set; }
|
||||
}
|
||||
|
||||
private sealed class GlJournalEntryData
|
||||
{
|
||||
public string JournalNo { get; set; } = string.Empty;
|
||||
public bool IsPosted { get; set; }
|
||||
}
|
||||
|
||||
private sealed class GlPeriodData
|
||||
{
|
||||
public int PeriodId { get; set; }
|
||||
public int FiscalYearId { get; set; }
|
||||
}
|
||||
|
||||
private sealed class GlBankAccountData
|
||||
{
|
||||
public string AccountType { get; set; } = string.Empty;
|
||||
public long AccountId { get; set; }
|
||||
public string AccountName { get; set; } = string.Empty;
|
||||
public string? BankName { get; set; }
|
||||
public string? CashAccountTypeName { get; set; }
|
||||
public string? AccountNumber { get; set; }
|
||||
public long GlAccountId { get; set; }
|
||||
public string GlAccountCode { get; set; } = string.Empty;
|
||||
public string CurrencyCode { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace ERPCore.Services.Gl;
|
||||
|
||||
/// <summary>
|
||||
/// Purpose-built request/response shapes for the specific GL endpoints ERPCore's GRN
|
||||
/// module calls directly (journal-entries, period lookup, bank-account listing) — not
|
||||
/// a full model of GL's contract (docs/12-GENERAL-LEDGER-INTEGRATION.md §5/§6: typed
|
||||
/// DTOs are added only for the flow that actually needs them).
|
||||
/// </summary>
|
||||
public sealed record GlJournalEntryLineRequest(string AccountCode, decimal DebitAmount, decimal CreditAmount, string? Memo = null);
|
||||
|
||||
public sealed class GlJournalEntryRequest
|
||||
{
|
||||
public int PeriodId { get; set; }
|
||||
public DateOnly EntryDate { get; set; }
|
||||
public string? SourceModule { get; set; }
|
||||
public string? Reference { get; set; }
|
||||
public string? Narration { get; set; }
|
||||
public List<GlJournalEntryLineRequest> Lines { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed record GlJournalEntryResult(string JournalNo, bool IsPosted);
|
||||
|
||||
public sealed record GlPeriod(int PeriodId, int FiscalYearId);
|
||||
|
||||
public sealed record GlBankAccount(
|
||||
string AccountType, long AccountId, string AccountName, string? BankName,
|
||||
string? CashAccountTypeName, string? AccountNumber, long GlAccountId, string GlAccountCode, string CurrencyCode);
|
||||
@@ -0,0 +1,120 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Grn;
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Vendor payments against a confirmed GRN. Multiple installments are allowed until
|
||||
/// <see cref="Grn.BalanceAmount"/> reaches zero. Each payment posts its own real GL
|
||||
/// journal entry (Debit GRN Clearing / Credit the selected bank-or-cash account) before
|
||||
/// being recorded, using the same call-GL-before-commit pattern as <see cref="GrnService.ConfirmAsync"/>
|
||||
/// so a rejected/unreachable GL post rolls back the whole payment atomically.
|
||||
/// </summary>
|
||||
public sealed class GrnPaymentService : IGrnPaymentService
|
||||
{
|
||||
private readonly IRepository<Grn> _grns;
|
||||
private readonly IRepository<GrnPayment> _payments;
|
||||
private readonly IGeneralLedgerService _gl;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly string _glClearingAccountCode;
|
||||
|
||||
public GrnPaymentService(
|
||||
IRepository<Grn> grns, IRepository<GrnPayment> payments, IGeneralLedgerService gl,
|
||||
ICurrentUser currentUser, IUnitOfWork uow, IConfiguration configuration)
|
||||
{
|
||||
_grns = grns;
|
||||
_payments = payments;
|
||||
_gl = gl;
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
_glClearingAccountCode = configuration["Grn:GlClearingAccountCode"] ?? string.Empty;
|
||||
}
|
||||
|
||||
public async Task<GrnPaymentDto> PayAsync(int grnId, CreateGrnPaymentRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var grn = await _grns.Query().FirstOrDefaultAsync(g => g.GrnId == grnId, ct)
|
||||
?? throw new NotFoundException($"GRN {grnId} was not found.");
|
||||
|
||||
if (grn.Status == GrnStatus.Draft)
|
||||
throw new DomainException(ErrorCodes.GrnNotPayable, $"GRN {grnId} must be confirmed before it can be paid.", 409);
|
||||
if (request.Amount > grn.BalanceAmount)
|
||||
throw new DomainException(ErrorCodes.GrnPaymentExceedsBalance,
|
||||
$"Payment amount {request.Amount} exceeds the remaining balance {grn.BalanceAmount}.", 400);
|
||||
|
||||
var accounts = await _gl.ListBankAccountsAsync(ct);
|
||||
var account = accounts.FirstOrDefault(a => a.AccountId == request.GlBankAccountId)
|
||||
?? throw new DomainException(ErrorCodes.GrnBankAccountNotFound, $"Bank/cash account {request.GlBankAccountId} was not found.", 404);
|
||||
|
||||
var actor = _currentUser.AuditUserId;
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var payment = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
// Same atomicity approach as GrnService.ConfirmAsync: post to GL first, inside
|
||||
// this transaction, before anything is committed — a GL rejection/timeout rolls
|
||||
// the whole payment back with no partial local state.
|
||||
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), token);
|
||||
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||
{
|
||||
PeriodId = period.PeriodId,
|
||||
EntryDate = DateOnly.FromDateTime(now),
|
||||
SourceModule = "GRN_PAYMENT",
|
||||
Reference = grn.DocNo,
|
||||
Narration = $"Payment against GRN {grn.DocNo}",
|
||||
Lines =
|
||||
[
|
||||
new GlJournalEntryLineRequest(_glClearingAccountCode, request.Amount, 0m, $"Payment against GRN {grn.DocNo}"),
|
||||
new GlJournalEntryLineRequest(account.GlAccountCode, 0m, request.Amount, $"Payment against GRN {grn.DocNo}")
|
||||
]
|
||||
}, token);
|
||||
|
||||
var entity = new GrnPayment
|
||||
{
|
||||
GrnId = grn.GrnId,
|
||||
Amount = request.Amount,
|
||||
PaymentDate = now,
|
||||
GlBankAccountId = account.AccountId,
|
||||
BankAccountName = account.AccountName,
|
||||
Reference = request.Reference,
|
||||
GlJournalNo = posted.JournalNo,
|
||||
CreatedBy = actor,
|
||||
CreatedAt = now
|
||||
};
|
||||
await _payments.AddAsync(entity, token);
|
||||
|
||||
grn.PaidAmount += request.Amount;
|
||||
grn.BalanceAmount -= request.Amount;
|
||||
|
||||
return entity;
|
||||
}, ct);
|
||||
|
||||
return Map(payment);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<GrnPaymentDto>> ListAsync(int grnId, CancellationToken ct = default)
|
||||
{
|
||||
if (!await _grns.Query().AnyAsync(g => g.GrnId == grnId, ct))
|
||||
throw new NotFoundException($"GRN {grnId} was not found.");
|
||||
|
||||
return await _payments.Query().AsNoTracking()
|
||||
.Where(p => p.GrnId == grnId)
|
||||
.OrderByDescending(p => p.GrnPaymentId)
|
||||
.Select(p => new GrnPaymentDto(
|
||||
p.GrnPaymentId, p.GrnId, p.Amount, p.PaymentDate,
|
||||
p.GlBankAccountId, p.BankAccountName, p.Reference, p.GlJournalNo, p.CreatedAt))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
private static GrnPaymentDto Map(GrnPayment p) => new(
|
||||
p.GrnPaymentId, p.GrnId, p.Amount, p.PaymentDate,
|
||||
p.GlBankAccountId, p.BankAccountName, p.Reference, p.GlJournalNo, p.CreatedAt);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ using ERPCore.Dtos.Grn;
|
||||
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;
|
||||
@@ -38,13 +39,18 @@ public sealed class GrnService : IGrnService
|
||||
private readonly INumberSequenceService _numbers;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly IGeneralLedgerService _gl;
|
||||
private readonly string _glInventoryAccountCode;
|
||||
private readonly string _glVatRecoverableAccountCode;
|
||||
private readonly string _glClearingAccountCode;
|
||||
|
||||
public GrnService(
|
||||
IRepository<Grn> grns, IRepository<PurchaseOrder> pos, IRepository<PoLine> poLines,
|
||||
IRepository<Item> items, IRepository<Warehouse> warehouses,
|
||||
IRepository<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches,
|
||||
IRepository<StockLayer> layers, IRepository<StockLedger> ledger,
|
||||
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
|
||||
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow,
|
||||
IGeneralLedgerService gl, IConfiguration configuration)
|
||||
{
|
||||
_grns = grns;
|
||||
_pos = pos;
|
||||
@@ -60,6 +66,10 @@ public sealed class GrnService : IGrnService
|
||||
_numbers = numbers;
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
_gl = gl;
|
||||
_glInventoryAccountCode = configuration["Grn:GlInventoryAccountCode"] ?? string.Empty;
|
||||
_glVatRecoverableAccountCode = configuration["Grn:GlVatRecoverableAccountCode"] ?? string.Empty;
|
||||
_glClearingAccountCode = configuration["Grn:GlClearingAccountCode"] ?? string.Empty;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<GrnSummaryDto>> ListAsync(
|
||||
@@ -82,7 +92,7 @@ public sealed class GrnService : IGrnService
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.Select(g => new GrnSummaryDto(
|
||||
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status,
|
||||
g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count))
|
||||
g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count, g.PaidAmount, g.BalanceAmount))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return PagedResponse<GrnSummaryDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
@@ -260,15 +270,47 @@ public sealed class GrnService : IGrnService
|
||||
}
|
||||
}
|
||||
|
||||
// Post the real GL journal entry for this receipt before committing — if GL
|
||||
// rejects it or is unreachable, the exception propagates out of this callback
|
||||
// and the whole transaction (FIFO layers, stock ledger, PO accrual) rolls back
|
||||
// with it, so inventory and the ledger never diverge (user-approved "fails
|
||||
// atomically" behavior; residual risk if GL posts but the local commit still
|
||||
// fails afterward is accepted for this phase, see the integration plan).
|
||||
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), token);
|
||||
var glLines = new List<GlJournalEntryLineRequest>();
|
||||
decimal totalPayable = 0m;
|
||||
foreach (var line in grn.Lines.OrderBy(l => l.GrnLineId))
|
||||
{
|
||||
glLines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, line.ReceivedValue, 0m, $"GRN {grn.DocNo} line {line.GrnLineId}"));
|
||||
if (line.VatAmount > 0)
|
||||
glLines.Add(new GlJournalEntryLineRequest(_glVatRecoverableAccountCode, line.VatAmount, 0m, $"GRN {grn.DocNo} line {line.GrnLineId} VAT"));
|
||||
totalPayable += line.LineTotal;
|
||||
}
|
||||
glLines.Add(new GlJournalEntryLineRequest(_glClearingAccountCode, 0m, totalPayable, $"GRN {grn.DocNo} received"));
|
||||
|
||||
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||
{
|
||||
PeriodId = period.PeriodId,
|
||||
EntryDate = DateOnly.FromDateTime(now),
|
||||
SourceModule = "GRN",
|
||||
Reference = grn.DocNo,
|
||||
Narration = $"Goods received - GRN {grn.DocNo}",
|
||||
Lines = glLines
|
||||
}, token);
|
||||
|
||||
grn.Status = GrnStatus.Confirmed;
|
||||
grn.PostedAt = now;
|
||||
grn.GlJournalNo = posted.JournalNo;
|
||||
grn.GlPostedAt = now;
|
||||
grn.PaidAmount = 0m;
|
||||
grn.BalanceAmount = totalPayable;
|
||||
|
||||
await UpdatePoStatusAsync(grn.PoId, token);
|
||||
return 0;
|
||||
}, ct);
|
||||
|
||||
return new GrnConfirmResultDto(
|
||||
grn.GrnId, grn.Status, now,
|
||||
grn.GrnId, grn.Status, now, grn.GlJournalNo ?? string.Empty, grn.BalanceAmount,
|
||||
createdLayers.Select(ToCreatedLayer).ToList(),
|
||||
ledgerRefs.Select(l => l.LedgerId).ToList(),
|
||||
await GetPoStatusAsync(grn.PoId, ct));
|
||||
@@ -395,7 +437,7 @@ public sealed class GrnService : IGrnService
|
||||
.Select(l => l.LedgerId).ToListAsync(ct);
|
||||
|
||||
return new GrnConfirmResultDto(
|
||||
grn.GrnId, grn.Status, grn.PostedAt ?? grn.CreatedAt,
|
||||
grn.GrnId, grn.Status, grn.PostedAt ?? grn.CreatedAt, grn.GlJournalNo ?? string.Empty, grn.BalanceAmount,
|
||||
layers.Select(ToCreatedLayer).ToList(), ledgerRefs, await GetPoStatusAsync(grn.PoId, ct));
|
||||
}
|
||||
|
||||
@@ -404,6 +446,7 @@ public sealed class GrnService : IGrnService
|
||||
|
||||
private static GrnDto Map(Grn g) => new(
|
||||
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status, g.CreatedBy, g.CreatedAt, g.PostedAt,
|
||||
g.GlJournalNo, g.PaidAmount, g.BalanceAmount,
|
||||
g.Lines.OrderBy(l => l.GrnLineId).Select(l => new GrnLineDto(
|
||||
l.GrnLineId, l.PoLineId, l.ItemId, l.BinId, l.Qty, l.UnitCost, l.PoUnitPrice,
|
||||
l.DiscountPct, l.NetUnitCost, l.VatPct, l.VatAmount, l.ReceivedValue, l.LineTotal,
|
||||
|
||||
@@ -9,6 +9,7 @@ public interface IBundleSaleService
|
||||
{
|
||||
Task<PagedResponse<BundleSaleTemplateSummaryDto>> ListTemplatesAsync(PageQuery query, CancellationToken ct = default);
|
||||
Task<BundleSaleTemplateDto?> GetTemplateAsync(int bundleSaleTemplateId, CancellationToken ct = default);
|
||||
Task<BundleSaleTemplateDto> CreateTemplateAsync(CreateBundleSaleTemplateRequest request, CancellationToken ct = default);
|
||||
Task<PagedResponse<BundleSaleSummaryDto>> ListAsync(PageQuery query, BundleSaleStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default);
|
||||
Task<BundleSaleDto?> GetAsync(int bundleSaleId, CancellationToken ct = default);
|
||||
Task<BundleSalePostingCheckDto> CheckPostingAsync(int bundleSaleId, CancellationToken ct = default);
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
using ERPCore.Infra.Gl;
|
||||
using ERPCore.Services.Gl;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Single entry point into the external General Ledger service — the one function
|
||||
/// used both by <see cref="ERPCore.Controllers.GeneralLedgerController"/> (frontend
|
||||
/// requests, forwarded verbatim) and, once wired, other ERPCore services that need to
|
||||
/// post directly to GL (e.g. GRN confirm, adjustments — docs/12-GENERAL-LEDGER-INTEGRATION.md).
|
||||
/// No business logic lives here yet; this pass only connects the transport.
|
||||
/// requests, forwarded verbatim) and by other ERPCore services that post directly to
|
||||
/// GL (docs/12-GENERAL-LEDGER-INTEGRATION.md §5/§6). <see cref="ForwardAsync"/> stays a
|
||||
/// byte-for-byte passthrough; the three typed methods below are the first internal
|
||||
/// callers (GRN receipt + payment posting) and model only what those flows need.
|
||||
/// </summary>
|
||||
public interface IGeneralLedgerService
|
||||
{
|
||||
Task<GeneralLedgerResponse> ForwardAsync(
|
||||
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct);
|
||||
|
||||
/// <summary>Creates and posts a balanced journal entry. Throws <see cref="ERPCore.System.Errors.DomainException"/> on rejection/unreachability.</summary>
|
||||
Task<GlJournalEntryResult> PostJournalEntryAsync(GlJournalEntryRequest request, CancellationToken ct);
|
||||
|
||||
/// <summary>Resolves the accounting period covering <paramref name="date"/>.</summary>
|
||||
Task<GlPeriod> GetPeriodByDateAsync(DateOnly date, CancellationToken ct);
|
||||
|
||||
/// <summary>Lists GL's cash and bank accounts (default: both types).</summary>
|
||||
Task<IReadOnlyList<GlBankAccount>> ListBankAccountsAsync(CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
using ERPCore.Dtos.Grn;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>Vendor payments against a confirmed GRN's balance (installments allowed).</summary>
|
||||
public interface IGrnPaymentService
|
||||
{
|
||||
Task<GrnPaymentDto> PayAsync(int grnId, CreateGrnPaymentRequest request, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<GrnPaymentDto>> ListAsync(int grnId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Domain.Notifications;
|
||||
using ERPCore.Dtos.Notifications;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
public interface INotificationConfigService
|
||||
{
|
||||
Task<ETagged<NotificationConfigDto>> GetAsync(CancellationToken ct = default);
|
||||
Task<ETagged<NotificationConfigDto>> UpdateAsync(UpdateNotificationConfigRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<NotificationChannel>> ResolveEnabledChannelsAsync(string moduleKey, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using ERPCore.Domain.Notifications;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
public interface INotificationModuleCatalog
|
||||
{
|
||||
IReadOnlyList<NotificationModuleDefinition> GetAll();
|
||||
NotificationModuleDefinition? Find(string moduleKey);
|
||||
}
|
||||
@@ -18,6 +18,15 @@ public interface IProductionRunService
|
||||
|
||||
Task<ETagged<RunGraphDto>?> GetAsync(int runId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Produced-items report (FR-MFG-13): one row per run that has produced something,
|
||||
/// terminal-output quantities and roll-up cost, plus a summary total over every
|
||||
/// filtered row. No pagination — bounded by the filters like the Sales reports.
|
||||
/// </summary>
|
||||
Task<ProducedListResponseDto> ListProducedAsync(
|
||||
DateOnly? from, DateOnly? to, int? templateId, int? warehouseId, ProductionRunStatus? status,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Instantiates a template (FR-MFG-08): copies every stage, input, output and edge,
|
||||
/// scales all quantities by <c>targetQty / terminalOutputQtyPerBatch</c>, issues a
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Sales;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
public interface ISalesDayEndService
|
||||
{
|
||||
/// <summary>What closing <paramref name="businessDate"/> for this cashier would include right now
|
||||
/// (or the already-closed record's totals, if it's already closed).</summary>
|
||||
Task<SalesDayEndPreviewDto> PreviewAsync(int cashierUserId, DateOnly businessDate, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Closes the day: locks every Posted slip for (CashierUserId, BusinessDate) against this
|
||||
/// record and posts one consolidated GL journal entry. Idempotent — a second call for an
|
||||
/// already-closed date replays the existing record rather than erroring.</summary>
|
||||
Task<SalesDayEndDto> CloseAsync(CreateSalesDayEndRequest request, CancellationToken ct = default);
|
||||
|
||||
Task<PagedResponse<SalesDayEndSummaryDto>> ListAsync(PageQuery query, int? cashierUserId, CancellationToken ct = default);
|
||||
|
||||
Task<SalesDayEndDto?> GetAsync(int salesDayEndId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using ERPCore.Dtos.Sales;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>Customer payments against a Posted sales invoice's balance (installments allowed).</summary>
|
||||
public interface ISalesInvoicePaymentService
|
||||
{
|
||||
Task<SalesInvoicePaymentDto> PayAsync(int salesInvoiceId, CreateSalesInvoicePaymentRequest request, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<SalesInvoicePaymentDto>> ListAsync(int salesInvoiceId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Config;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Items;
|
||||
using ERPCore.Infra.UoW;
|
||||
@@ -85,7 +86,8 @@ public sealed class ItemService : IItemService
|
||||
.Select(i => new ItemListItemDto(
|
||||
i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId,
|
||||
i.BaseUomId, i.DefaultVendorId,
|
||||
i.StockNature, i.TrackingMode, i.Warranty, i.WarrantyPeriodMonths, i.TaxClass, i.SalePrice,
|
||||
i.StockNature, i.TrackingMode, i.Warranty, i.WarrantyPeriodMonths, i.TaxClass,
|
||||
i.SellingMinPrice, i.SellingMaxPrice, i.SellingFlatPrice,
|
||||
i.ContentQty, i.ContentUnit, i.ContentBaseQty, i.ContentBaseUnit,
|
||||
i.Status))
|
||||
.ToListAsync(ct);
|
||||
@@ -107,9 +109,11 @@ public sealed class ItemService : IItemService
|
||||
if (await _items.Query().AnyAsync(i => i.Sku == request.Sku, ct))
|
||||
throw new DomainException(ErrorCodes.SkuDuplicate, $"An item with SKU '{request.Sku}' already exists.", 400);
|
||||
|
||||
var config = (await _config.GetAsync(ct)).Value;
|
||||
await ValidateReferencesAsync(
|
||||
request.CategoryId, request.SubCategoryId, request.BrandId,
|
||||
request.BaseUomId, request.DefaultVendorId, ct);
|
||||
request.BaseUomId, request.DefaultVendorId, config, ct);
|
||||
ValidateFeatureConfiguration(request, config);
|
||||
|
||||
ItemContent.ValidatePair(request.ContentQty, request.ContentUnit);
|
||||
var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(request.ContentQty, request.ContentUnit);
|
||||
@@ -130,7 +134,9 @@ public sealed class ItemService : IItemService
|
||||
Warranty = request.Warranty,
|
||||
WarrantyPeriodMonths = warrantyPeriodMonths,
|
||||
TaxClass = request.TaxClass,
|
||||
SalePrice = request.SalePrice,
|
||||
SellingMinPrice = request.SellingMinPrice,
|
||||
SellingMaxPrice = request.SellingMaxPrice,
|
||||
SellingFlatPrice = request.SellingFlatPrice,
|
||||
ContentQty = request.ContentQty,
|
||||
ContentUnit = request.ContentUnit,
|
||||
ContentBaseQty = contentBaseQty,
|
||||
@@ -160,9 +166,11 @@ public sealed class ItemService : IItemService
|
||||
&& await _items.Query().AnyAsync(i => i.Sku == request.Sku && i.ItemId != itemId, ct))
|
||||
throw new DomainException(ErrorCodes.SkuDuplicate, $"An item with SKU '{request.Sku}' already exists.", 400);
|
||||
|
||||
var config = (await _config.GetAsync(ct)).Value;
|
||||
await ValidateReferencesAsync(
|
||||
request.CategoryId, request.SubCategoryId, request.BrandId,
|
||||
request.BaseUomId, request.DefaultVendorId, ct);
|
||||
request.BaseUomId, request.DefaultVendorId, config, ct);
|
||||
ValidateFeatureConfiguration(request, config);
|
||||
|
||||
// The base UOM is the sole meaning of every quantity recorded against this item —
|
||||
// stock layers, ledger rows and document lines are all plain counts of it. Once any
|
||||
@@ -190,7 +198,9 @@ public sealed class ItemService : IItemService
|
||||
item.Warranty = request.Warranty;
|
||||
item.WarrantyPeriodMonths = warrantyPeriodMonths;
|
||||
item.TaxClass = request.TaxClass;
|
||||
item.SalePrice = request.SalePrice;
|
||||
item.SellingMinPrice = request.SellingMinPrice;
|
||||
item.SellingMaxPrice = request.SellingMaxPrice;
|
||||
item.SellingFlatPrice = request.SellingFlatPrice;
|
||||
item.ContentQty = request.ContentQty;
|
||||
item.ContentUnit = request.ContentUnit;
|
||||
item.ContentBaseQty = contentBaseQty;
|
||||
@@ -275,10 +285,9 @@ public sealed class ItemService : IItemService
|
||||
/// it is advisory and honoured by the frontend only (docs/11 §2.8).
|
||||
/// </summary>
|
||||
private async Task ValidateReferencesAsync(
|
||||
int categoryId, int? subCategoryId, int? brandId, int baseUomId, int? defaultVendorId, CancellationToken ct)
|
||||
int categoryId, int? subCategoryId, int? brandId, int baseUomId, int? defaultVendorId,
|
||||
ProductConfigDto config, CancellationToken ct)
|
||||
{
|
||||
var config = (await _config.GetAsync(ct)).Value;
|
||||
|
||||
if (!await _categories.Query().AnyAsync(c => c.CategoryId == categoryId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Category {categoryId} does not exist.", 422);
|
||||
|
||||
@@ -332,6 +341,46 @@ public sealed class ItemService : IItemService
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateFeatureConfiguration(CreateItemRequest request, ProductConfigDto config)
|
||||
=> ValidateFeatureConfiguration(
|
||||
request.SellingMinPrice, request.SellingMaxPrice, request.SellingFlatPrice,
|
||||
request.TrackingMode, request.Warranty, config);
|
||||
|
||||
private static void ValidateFeatureConfiguration(UpdateItemRequest request, ProductConfigDto config)
|
||||
=> ValidateFeatureConfiguration(
|
||||
request.SellingMinPrice, request.SellingMaxPrice, request.SellingFlatPrice,
|
||||
request.TrackingMode, request.Warranty, config);
|
||||
|
||||
private static void ValidateFeatureConfiguration(
|
||||
decimal? sellingMinPrice, decimal? sellingMaxPrice, decimal? sellingFlatPrice,
|
||||
TrackingMode trackingMode, Warranty warranty, ProductConfigDto config)
|
||||
{
|
||||
if (!config.SellingPricesEnabled &&
|
||||
(sellingMinPrice is not null || sellingMaxPrice is not null || sellingFlatPrice is not null))
|
||||
throw new DomainException(
|
||||
ErrorCodes.ConfigDisabled,
|
||||
"Selling prices are disabled in the product configuration; sellingMinPrice, sellingMaxPrice and sellingFlatPrice must be null.",
|
||||
422);
|
||||
|
||||
if (!config.SerialNumbersEnabled && trackingMode == TrackingMode.Serial)
|
||||
throw new DomainException(
|
||||
ErrorCodes.ConfigDisabled,
|
||||
"Serial numbers are disabled in the product configuration; trackingMode cannot be Serial.",
|
||||
422);
|
||||
|
||||
if (!config.BatchListsEnabled && trackingMode == TrackingMode.Batch)
|
||||
throw new DomainException(
|
||||
ErrorCodes.ConfigDisabled,
|
||||
"Batch lists are disabled in the product configuration; trackingMode cannot be Batch.",
|
||||
422);
|
||||
|
||||
if (!config.WarrantyPeriodsEnabled && warranty == Warranty.Warranty)
|
||||
throw new DomainException(
|
||||
ErrorCodes.ConfigDisabled,
|
||||
"Warranty periods are disabled in the product configuration; warranty cannot be Warranty.",
|
||||
422);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A warranty-tracked item must declare a coverage period (one of
|
||||
/// <see cref="WarrantyPeriods.AllowedMonths"/>); a non-warranty item carries none —
|
||||
@@ -364,7 +413,8 @@ public sealed class ItemService : IItemService
|
||||
private static ItemDetailDto ToDetail(Item i) => new(
|
||||
i.ItemId, i.Sku, i.Name, i.Description, i.CategoryId, i.SubCategoryId, i.BrandId,
|
||||
i.BaseUomId, i.DefaultVendorId,
|
||||
i.StockNature, i.TrackingMode, i.Warranty, i.WarrantyPeriodMonths, i.TaxClass, i.SalePrice,
|
||||
i.StockNature, i.TrackingMode, i.Warranty, i.WarrantyPeriodMonths, i.TaxClass,
|
||||
i.SellingMinPrice, i.SellingMaxPrice, i.SellingFlatPrice,
|
||||
i.ContentQty, i.ContentUnit, i.ContentBaseQty, i.ContentBaseUnit,
|
||||
i.Status,
|
||||
i.ReorderSettings
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Notifications;
|
||||
using ERPCore.Dtos.Notifications;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
public sealed class NotificationConfigService : INotificationConfigService
|
||||
{
|
||||
private readonly IRepository<NotificationConfig> _config;
|
||||
private readonly IRepository<NotificationConfigModule> _modules;
|
||||
private readonly INotificationModuleCatalog _catalog;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public NotificationConfigService(
|
||||
IRepository<NotificationConfig> config,
|
||||
IRepository<NotificationConfigModule> modules,
|
||||
INotificationModuleCatalog catalog,
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
_config = config;
|
||||
_modules = modules;
|
||||
_catalog = catalog;
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<ETagged<NotificationConfigDto>> GetAsync(CancellationToken ct = default)
|
||||
{
|
||||
var config = await _config.Query().AsNoTracking()
|
||||
.Include(c => c.Modules)
|
||||
.FirstOrDefaultAsync(c => c.NotificationConfigId == NotificationConfig.SingletonId, ct)
|
||||
?? throw new NotFoundException("Notification configuration has not been seeded.");
|
||||
|
||||
return new ETagged<NotificationConfigDto>(Map(config), config.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<ETagged<NotificationConfigDto>> UpdateAsync(UpdateNotificationConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
||||
{
|
||||
var config = await _config.Query()
|
||||
.Include(c => c.Modules)
|
||||
.FirstOrDefaultAsync(c => c.NotificationConfigId == NotificationConfig.SingletonId, ct)
|
||||
?? throw new NotFoundException("Notification configuration has not been seeded.");
|
||||
|
||||
if (config.RowVersion != expectedRowVersion)
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The notification configuration was modified by another request.", 412);
|
||||
|
||||
var catalog = _catalog.GetAll();
|
||||
var inputs = request.Modules ?? new List<UpdateNotificationModuleRequest>();
|
||||
if (inputs.Any(x => string.IsNullOrWhiteSpace(x.ModuleKey)))
|
||||
throw new DomainException(ErrorCodes.Validation, "Notification module keys cannot be empty.", 400);
|
||||
if (inputs.Select(x => x.ModuleKey.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).Count() != inputs.Count)
|
||||
throw new DomainException(ErrorCodes.Validation, "Duplicate notification module keys are not allowed.", 400);
|
||||
|
||||
var byKey = inputs.ToDictionary(x => x.ModuleKey.Trim(), StringComparer.OrdinalIgnoreCase);
|
||||
var unknown = byKey.Keys.Where(k => catalog.All(m => !string.Equals(m.Key, k, StringComparison.OrdinalIgnoreCase))).ToList();
|
||||
if (unknown.Count > 0)
|
||||
throw new DomainException(ErrorCodes.Validation, $"Unknown notification module key(s): {string.Join(", ", unknown)}.", 422);
|
||||
|
||||
var missing = catalog.Where(m => !byKey.ContainsKey(m.Key)).Select(m => m.Key).ToList();
|
||||
if (missing.Count > 0)
|
||||
throw new DomainException(ErrorCodes.Validation, $"Missing notification module settings for: {string.Join(", ", missing)}.", 422);
|
||||
|
||||
config.Modules.Clear();
|
||||
foreach (var module in catalog)
|
||||
{
|
||||
var input = byKey[module.Key];
|
||||
config.Modules.Add(new NotificationConfigModule
|
||||
{
|
||||
NotificationConfigId = config.NotificationConfigId,
|
||||
ModuleKey = module.Key,
|
||||
EmailEnabled = input.EmailEnabled!.Value,
|
||||
SmsEnabled = input.SmsEnabled!.Value,
|
||||
InAppEnabled = input.InAppEnabled!.Value
|
||||
});
|
||||
}
|
||||
|
||||
config.UpdatedAt = DateTime.UtcNow;
|
||||
config.UpdatedBy = _currentUser.AuditUserId;
|
||||
|
||||
await SaveGuardingConcurrencyAsync(ct);
|
||||
return new ETagged<NotificationConfigDto>(Map(config), config.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<NotificationChannel>> ResolveEnabledChannelsAsync(string moduleKey, CancellationToken ct = default)
|
||||
{
|
||||
var key = moduleKey?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(key)) return Array.Empty<NotificationChannel>();
|
||||
|
||||
var row = await _modules.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(m => m.ModuleKey == key, ct);
|
||||
|
||||
if (row is null) return Array.Empty<NotificationChannel>();
|
||||
|
||||
var channels = new List<NotificationChannel>(3);
|
||||
if (row.EmailEnabled) channels.Add(NotificationChannel.Email);
|
||||
if (row.SmsEnabled) channels.Add(NotificationChannel.Sms);
|
||||
if (row.InAppEnabled) channels.Add(NotificationChannel.InApp);
|
||||
return channels;
|
||||
}
|
||||
|
||||
private async Task SaveGuardingConcurrencyAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The notification configuration was modified by another request.", 412);
|
||||
}
|
||||
}
|
||||
|
||||
private NotificationConfigDto Map(NotificationConfig config)
|
||||
{
|
||||
var rows = config.Modules
|
||||
.ToDictionary(m => m.ModuleKey, m => m, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var modules = _catalog.GetAll()
|
||||
.Select(m =>
|
||||
{
|
||||
rows.TryGetValue(m.Key, out var row);
|
||||
return new NotificationModuleConfigDto(
|
||||
m.Key,
|
||||
m.Label,
|
||||
m.Description,
|
||||
row?.EmailEnabled ?? false,
|
||||
row?.SmsEnabled ?? false,
|
||||
row?.InAppEnabled ?? false);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new NotificationConfigDto(modules, config.UpdatedAt, config.UpdatedBy);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Reflection;
|
||||
using ERPCore.Domain.Notifications;
|
||||
using ERPCore.Domain.Notifications.Modules;
|
||||
using ERPCore.Services.Interfaces;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
public sealed class NotificationModuleCatalog : INotificationModuleCatalog
|
||||
{
|
||||
private readonly IReadOnlyList<NotificationModuleDefinition> _modules;
|
||||
|
||||
public NotificationModuleCatalog()
|
||||
{
|
||||
var moduleType = typeof(SaleCreateNotificationModule).Assembly;
|
||||
_modules = moduleType.GetTypes()
|
||||
.Select(t => new { Type = t, Attr = t.GetCustomAttribute<NotificationModuleAttribute>() })
|
||||
.Where(x => x.Attr is not null)
|
||||
.Select(x => new NotificationModuleDefinition(x.Attr!.Key, x.Attr.Label, x.Attr.Description, x.Attr.SortOrder))
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ThenBy(x => x.Key, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var duplicates = _modules.GroupBy(m => m.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.Where(g => g.Count() > 1)
|
||||
.Select(g => g.Key)
|
||||
.ToList();
|
||||
if (duplicates.Count > 0)
|
||||
throw new InvalidOperationException($"Duplicate notification module key(s) found: {string.Join(", ", duplicates)}.");
|
||||
}
|
||||
|
||||
public IReadOnlyList<NotificationModuleDefinition> GetAll() => _modules;
|
||||
|
||||
public NotificationModuleDefinition? Find(string moduleKey)
|
||||
=> _modules.FirstOrDefault(m => string.Equals(m.Key, moduleKey, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user