Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bebec10eb3 | |||
| 160c687db3 | |||
| d413ad4064 | |||
| 9b8c2493ad | |||
| 99b669395e | |||
| 4cc6a546c7 | |||
| 8835ba9500 | |||
| 251b23f4b5 | |||
| 283a56723f | |||
| 9a38cc1131 | |||
| 1c5b05e315 | |||
| efa4ad3770 | |||
| 6528fc8d9d | |||
| ae6a87022d | |||
| ee9fa40edf | |||
| b1b0fe4bac | |||
| 8555702a76 | |||
| fd95e92eb1 | |||
| 6e008773db | |||
| 179d5b0803 | |||
| 342012a321 | |||
| ee6ac913f1 | |||
| 2661169351 | |||
| 80213cf47d | |||
| 18475fdc9f | |||
| 9a32c5c609 | |||
| 32f40e9d1a | |||
| b2a218e2f8 | |||
| d16a227b54 | |||
| a7ba3d3e04 | |||
| 0ae80395cf | |||
| 15ddac178c | |||
| d37824cecc | |||
| 271c940640 | |||
| f2825900aa | |||
| 6520930aeb | |||
| a8c6b4cb5e | |||
| 8e9974b735 | |||
| c4e016c460 | |||
| 7e8418685c | |||
| cbc72ef830 | |||
| 4324ba1a96 | |||
| 1af16d3dec | |||
| f140959b43 | |||
| c31e23c2b9 | |||
| 7219480ca0 | |||
| 8e24ed6375 | |||
| 9f22026784 | |||
| ef105302bd |
+7
-9
@@ -30,12 +30,10 @@ yarn-error.log*
|
||||
Thumbs.db
|
||||
.idea/
|
||||
|
||||
# ── Migrations ─────────────────────────────────────────────────────────
|
||||
# Reverted 2026-07-31: excluding new EF Core migrations while
|
||||
# ErpDbContextModelSnapshot.cs stayed tracked meant every `dotnet ef
|
||||
# migrations add` after the initial 4 silently produced a migration git would
|
||||
# never see, while the (tracked) snapshot's changes committed normally —
|
||||
# so the snapshot kept claiming tables existed that no migration in git
|
||||
# history ever created them. Confirmed live: 25 HRM tables + 11 Manufacturing
|
||||
# tables were missing from the actual database for exactly this reason.
|
||||
# Migrations now stay tracked like any other source file — commit them.
|
||||
# ── Playwright E2E (Testing/e2e) ──────────────────────────────────────
|
||||
Testing/e2e/playwright-report/
|
||||
Testing/e2e/test-results/
|
||||
Testing/e2e/.auth/
|
||||
Testing/e2e/blob-report/
|
||||
|
||||
|
||||
|
||||
@@ -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,14 +29,23 @@ 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(
|
||||
[FromQuery] PageQuery query,
|
||||
[FromQuery] BundleSaleStatus? status,
|
||||
[FromQuery] int? customerId,
|
||||
[FromQuery] int? warehouseId,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _bundles.ListAsync(query, customerId, warehouseId, ct));
|
||||
=> Ok(await _bundles.ListAsync(query, status, customerId, warehouseId, ct));
|
||||
|
||||
[HttpGet("{bundleSaleId:int}")]
|
||||
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
|
||||
|
||||
@@ -12,7 +12,10 @@ public sealed class GrnsController : ApiControllerBase
|
||||
{
|
||||
private readonly IGrnService _grns;
|
||||
|
||||
public GrnsController(IGrnService grns) => _grns = grns;
|
||||
public GrnsController(IGrnService grns)
|
||||
{
|
||||
_grns = grns;
|
||||
}
|
||||
|
||||
/// <summary>List GRNs, newest first.</summary>
|
||||
[HttpGet]
|
||||
|
||||
@@ -81,11 +81,4 @@ public sealed class ItemsController : ApiControllerBase
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<ItemReorderSettingsDto>> UpdateReorder(int itemId, [FromBody] UpdateReorderRequest request, CancellationToken ct)
|
||||
=> Ok(await _items.UpdateReorderAsync(itemId, request, ct));
|
||||
|
||||
/// <summary>Replace the item's UOM conversions (FR-MD-02).</summary>
|
||||
[HttpPut("{itemId:int}/uom-conversions")]
|
||||
[ProducesResponseType(typeof(ItemUomConversionsDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<ItemUomConversionsDto>> UpdateUomConversions(int itemId, [FromBody] UpdateUomConversionsRequest request, CancellationToken ct)
|
||||
=> Ok(await _items.UpdateUomConversionsAsync(itemId, request, 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,102 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Grn;
|
||||
using ERPCore.Dtos.Procurement;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Purchase invoice endpoints — vendor invoices consolidating Confirmed GRNs.</summary>
|
||||
[Route("api/v1/purchase-invoices")]
|
||||
public sealed class PurchaseInvoicesController : ApiControllerBase
|
||||
{
|
||||
private readonly IPurchaseInvoiceService _invoices;
|
||||
private readonly IPurchaseInvoicePaymentService _payments;
|
||||
|
||||
public PurchaseInvoicesController(IPurchaseInvoiceService invoices, IPurchaseInvoicePaymentService payments)
|
||||
{
|
||||
_invoices = invoices;
|
||||
_payments = payments;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<PurchaseInvoiceSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<PurchaseInvoiceSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] PurchaseInvoiceStatus? status, [FromQuery] int? vendorId, CancellationToken ct)
|
||||
=> Ok(await _invoices.ListAsync(query, status, vendorId, ct));
|
||||
|
||||
/// <summary>Confirmed, not-yet-invoiced GRNs for a vendor — candidates for a new/edited invoice.</summary>
|
||||
[HttpGet("eligible-grns")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<GrnEligibleForInvoiceDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IReadOnlyList<GrnEligibleForInvoiceDto>>> EligibleGrns([FromQuery] int vendorId, CancellationToken ct)
|
||||
=> Ok(await _invoices.ListEligibleGrnsAsync(vendorId, ct));
|
||||
|
||||
[HttpGet("{purchaseInvoiceId:int}")]
|
||||
[ProducesResponseType(typeof(PurchaseInvoiceDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<PurchaseInvoiceDto>> GetById(int purchaseInvoiceId, CancellationToken ct)
|
||||
{
|
||||
var result = await _invoices.GetAsync(purchaseInvoiceId, ct);
|
||||
if (result is null) return NotFound();
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(PurchaseInvoiceDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<PurchaseInvoiceDto>> Create([FromBody] CreatePurchaseInvoiceRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _invoices.CreateAsync(request, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Created($"/api/v1/purchase-invoices/{result.Value.PurchaseInvoiceId}", result.Value);
|
||||
}
|
||||
|
||||
[HttpPut("{purchaseInvoiceId:int}")]
|
||||
[ProducesResponseType(typeof(PurchaseInvoiceDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<PurchaseInvoiceDto>> Update(int purchaseInvoiceId, [FromBody] UpdatePurchaseInvoiceRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _invoices.UpdateAsync(purchaseInvoiceId, request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost("{purchaseInvoiceId:int}/post")]
|
||||
[ProducesResponseType(typeof(PurchaseInvoiceDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<PurchaseInvoiceDto>> Post(int purchaseInvoiceId, CancellationToken ct)
|
||||
=> Ok(await _invoices.PostAsync(purchaseInvoiceId, ct));
|
||||
|
||||
[HttpPost("{purchaseInvoiceId:int}/cancel")]
|
||||
[ProducesResponseType(typeof(PurchaseInvoiceDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<PurchaseInvoiceDto>> Cancel(int purchaseInvoiceId, CancellationToken ct)
|
||||
=> Ok(await _invoices.CancelAsync(purchaseInvoiceId, ct));
|
||||
|
||||
/// <summary>Pay the vendor against this invoice's balance, in full or in installments; posts a real GL journal entry.</summary>
|
||||
[HttpPost("{purchaseInvoiceId:int}/payments")]
|
||||
[ProducesResponseType(typeof(PurchaseInvoicePaymentDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<PurchaseInvoicePaymentDto>> Pay(int purchaseInvoiceId, [FromBody] CreatePurchaseInvoicePaymentRequest request, CancellationToken ct)
|
||||
{
|
||||
var dto = await _payments.PayAsync(purchaseInvoiceId, request, ct);
|
||||
return Created($"/api/v1/purchase-invoices/{purchaseInvoiceId}/payments/{dto.PurchaseInvoicePaymentId}", dto);
|
||||
}
|
||||
|
||||
/// <summary>Payment history for this invoice, newest first.</summary>
|
||||
[HttpGet("{purchaseInvoiceId:int}/payments")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<PurchaseInvoicePaymentDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<IReadOnlyList<PurchaseInvoicePaymentDto>>> ListPayments(int purchaseInvoiceId, CancellationToken ct)
|
||||
=> Ok(await _payments.ListAsync(purchaseInvoiceId, ct));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Sales;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Sales-return endpoints — customer returns of previously sold goods.</summary>
|
||||
[Route("api/v1/sales-returns")]
|
||||
public sealed class SalesReturnsController : ApiControllerBase
|
||||
{
|
||||
private readonly ISalesReturnService _returns;
|
||||
|
||||
public SalesReturnsController(ISalesReturnService returns) => _returns = returns;
|
||||
|
||||
/// <summary>List posted returns, newest first.</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<SalesReturnSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<SalesReturnSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] int? customerId, [FromQuery] int? warehouseId, CancellationToken ct)
|
||||
=> Ok(await _returns.ListAsync(query, customerId, warehouseId, ct));
|
||||
|
||||
/// <summary>Remaining returnable qty per line of one sales invoice (invoiced qty minus already-returned).</summary>
|
||||
[HttpGet("remaining")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<SalesInvoiceLineRemainingDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IReadOnlyList<SalesInvoiceLineRemainingDto>>> GetRemaining([FromQuery] int salesInvoiceId, CancellationToken ct)
|
||||
=> Ok(await _returns.GetRemainingByInvoiceAsync(salesInvoiceId, ct));
|
||||
|
||||
/// <summary>Get one return with its lines and the ledger entries it posted.</summary>
|
||||
[HttpGet("{returnId:int}")]
|
||||
[ProducesResponseType(typeof(SalesReturnDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<SalesReturnDto>> GetById(int returnId, CancellationToken ct)
|
||||
{
|
||||
var dto = await _returns.GetAsync(returnId, ct);
|
||||
return dto is null ? NotFound() : Ok(dto);
|
||||
}
|
||||
|
||||
/// <summary>Create + auto-post a return (inbound movement).</summary>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(SalesReturnDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<SalesReturnDto>> Create([FromBody] CreateSalesReturnRequest request, CancellationToken ct)
|
||||
{
|
||||
var dto = await _returns.CreateAsync(request, ct);
|
||||
return Created($"/api/v1/sales-returns/{dto.ReturnId}", dto);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ public static class DocumentTypes
|
||||
public const string Rfq = "RFQ";
|
||||
public const string PurchaseOrder = "PO";
|
||||
public const string Grn = "GRN";
|
||||
public const string PurchaseInvoice = "PINV";
|
||||
public const string Transfer = "TRF";
|
||||
public const string Adjustment = "ADJ";
|
||||
public const string Count = "CNT";
|
||||
@@ -20,4 +21,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>();
|
||||
}
|
||||
|
||||
@@ -12,8 +12,6 @@ public class BundleSaleLine
|
||||
public Item? Item { get; set; }
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public decimal Qty { get; set; }
|
||||
public int UomId { get; set; }
|
||||
public Uom? Uom { get; set; }
|
||||
public int WarehouseId { get; set; }
|
||||
public Warehouse? Warehouse { get; set; }
|
||||
public decimal UnitPrice { get; set; }
|
||||
|
||||
@@ -8,8 +8,6 @@ public class BundleSaleTemplateLine
|
||||
|
||||
public int ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
public int UomId { get; set; }
|
||||
public Uom? Uom { get; set; }
|
||||
public int WarehouseId { get; set; }
|
||||
public Warehouse? Warehouse { get; set; }
|
||||
|
||||
|
||||
@@ -30,6 +30,16 @@ 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>The Purchase Invoice this GRN has been consolidated into, if any (null until
|
||||
/// invoiced). Vendor settlement is tracked on the invoice, not per-GRN — see
|
||||
/// <see cref="PurchaseInvoice"/>.</summary>
|
||||
public int? PurchaseInvoiceId { get; set; }
|
||||
public PurchaseInvoice? PurchaseInvoice { get; set; }
|
||||
|
||||
/// <summary>PostgreSQL xmin-backed optimistic concurrency token.</summary>
|
||||
public uint RowVersion { get; set; }
|
||||
|
||||
|
||||
@@ -25,9 +25,6 @@ public class GrnLine
|
||||
public int ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
|
||||
public int UomId { get; set; }
|
||||
public Uom? Uom { get; set; }
|
||||
|
||||
public int? BinId { get; set; }
|
||||
public Bin? Bin { get; set; }
|
||||
|
||||
@@ -61,4 +58,11 @@ public class GrnLine
|
||||
public decimal LineTotal { get; set; }
|
||||
|
||||
public HoldStatus HoldStatus { get; set; } = HoldStatus.Available;
|
||||
|
||||
/// <summary>
|
||||
/// One row per received unit when <see cref="Item.Warranty"/> is
|
||||
/// <see cref="Enums.Warranty.Warranty"/> — count must equal <see cref="Qty"/>.
|
||||
/// Empty for a non-warranty item.
|
||||
/// </summary>
|
||||
public ICollection<GrnLineWarrantyNumber> WarrantyNumbers { get; set; } = new List<GrnLineWarrantyNumber>();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One warranty number captured against a single received unit of a warranty-tracked
|
||||
/// item (<see cref="Item.Warranty"/> = <see cref="Enums.Warranty.Warranty"/>). A GRN line
|
||||
/// for such an item must carry exactly <see cref="GrnLine.Qty"/> of these — one per unit —
|
||||
/// mirroring how a Serial-tracked item requires one serial per unit (docs/10 Part C.3).
|
||||
/// </summary>
|
||||
public class GrnLineWarrantyNumber
|
||||
{
|
||||
public int GrnLineWarrantyNumberId { get; set; }
|
||||
|
||||
public int GrnLineId { get; set; }
|
||||
public GrnLine? GrnLine { get; set; }
|
||||
|
||||
public string WarrantyNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Warranty coverage length in months, selected at receipt (e.g. 3/6/12/18).</summary>
|
||||
public int WarrantyPeriodMonths { get; set; }
|
||||
}
|
||||
@@ -24,6 +24,12 @@ public class Item
|
||||
public int? BrandId { get; set; }
|
||||
public Brand? Brand { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The stocking unit — the pack the item is counted in (BOTTLE, PACKET, BOX, PCS).
|
||||
/// <b>Every</b> quantity in the system is a count of these: stock layers, the ledger,
|
||||
/// and every document line. Nothing converts, so this is the sole meaning of a
|
||||
/// quantity and cannot be changed once the item has stock history.
|
||||
/// </summary>
|
||||
public int BaseUomId { get; set; }
|
||||
public Uom? BaseUom { get; set; }
|
||||
|
||||
@@ -32,14 +38,46 @@ public class Item
|
||||
|
||||
public StockNature StockNature { get; set; }
|
||||
public TrackingMode TrackingMode { get; set; }
|
||||
public Warranty Warranty { get; set; } = Warranty.NonWarranty;
|
||||
/// <summary>Coverage length in months (see <see cref="Enums.WarrantyPeriods.AllowedMonths"/>) when <see cref="Warranty"/> is Warranty; null otherwise.</summary>
|
||||
public int? WarrantyPeriodMonths { get; set; }
|
||||
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
|
||||
/// <see cref="ContentUnit"/> <c>Ml</c> for a 500 ml bottle, <c>1.5</c> with <c>L</c>
|
||||
/// for a 1.5 L one. Null (together with the other three) when the item has no
|
||||
/// measurable content: a screw, a label, a service.
|
||||
/// <para>
|
||||
/// Content never affects stock — that is always a pack count. It exists so production
|
||||
/// can express a formula in millilitres or grams and resolve it to packs
|
||||
/// (see <c>IItemMeasure</c>).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A loose bulk item bought by weight is modelled the same way:
|
||||
/// <c>BaseUom = KG, ContentQty = 1, ContentUnit = Kg</c> ⇒ 1000 g per stocked unit.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public decimal? ContentQty { get; set; }
|
||||
public MeasureUnit? ContentUnit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ContentQty"/>/<see cref="ContentUnit"/> normalised to a base unit
|
||||
/// (L→Ml, Kg→G, both ×1000) at write time by <c>ItemContent.Normalize</c>. Server-derived
|
||||
/// and never accepted from a client. <see cref="ContentBaseUnit"/> is therefore only ever
|
||||
/// <see cref="MeasureUnit.Ml"/> or <see cref="MeasureUnit.G"/>.
|
||||
/// <para>Stored rather than recomputed so every consumer reads one settled number.</para>
|
||||
/// </summary>
|
||||
public decimal? ContentBaseQty { get; set; }
|
||||
public MeasureUnit? ContentBaseUnit { get; set; }
|
||||
|
||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||
|
||||
@@ -50,5 +88,4 @@ public class Item
|
||||
public uint RowVersion { get; set; }
|
||||
|
||||
public ICollection<ItemReorder> ReorderSettings { get; set; } = new List<ItemReorder>();
|
||||
public ICollection<UomConversion> UomConversions { get; set; } = new List<UomConversion>();
|
||||
}
|
||||
|
||||
@@ -7,12 +7,15 @@ namespace ERPCore.Domain.Entities;
|
||||
/// Material.
|
||||
/// <para>
|
||||
/// <b>Deliberately unlinked.</b> Nothing references this entity and it references
|
||||
/// nothing: there is no value table and no join to <see cref="Item"/>. Its only job is
|
||||
/// to feed the frontend's item-builder dropdown via <c>GET /item-types</c>. The chosen
|
||||
/// nothing: there is no value table and no join to <see cref="Item"/>. The chosen
|
||||
/// values (Red, S, M) are encoded by the client into the generated SKU
|
||||
/// (e.g. <c>BL-100-0003</c>) and are never stored or parsed server-side — the item list
|
||||
/// is the record of what was built. See the accepted trade-off in docs/10 Part C.9.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It does, however, carry one piece of meaning the client acts on:
|
||||
/// <see cref="IsMeasurable"/>. So this is no longer purely a dropdown source.
|
||||
/// </para>
|
||||
/// Not to be confused with <see cref="Enums.StockNature"/> (Stocked/NonStocked/Service),
|
||||
/// which is what the old <c>ItemType</c> enum became.
|
||||
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
||||
@@ -21,6 +24,24 @@ public class ItemType
|
||||
{
|
||||
public int ItemTypeId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// When true, this dimension's values are content <b>measurements</b> (500 ml, 1 L) rather
|
||||
/// than plain labels (Red, S). The item builder then captures a number + unit per value and
|
||||
/// stamps that pair onto each generated item's <see cref="Item.ContentQty"/> /
|
||||
/// <see cref="Item.ContentUnit"/>, instead of copying one form-level pair into every variant
|
||||
/// — which is what makes "Coca-Cola in 500 ml / 1 L / 250 ml" three correctly sized items.
|
||||
/// <para>
|
||||
/// This is what lets an apparel <c>Size</c> (S/M/L) stay plain text while a
|
||||
/// <c>Pack Size</c>/<c>Volume</c> dimension carries ml/g/L/kg.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A client hint only: the server never reads it when writing an item. Each item's pair is
|
||||
/// still validated and normalised on its own by <c>ItemContent</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public bool IsMeasurable { get; set; }
|
||||
|
||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -15,9 +15,6 @@ public class PoLine
|
||||
public int ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
|
||||
public int UomId { get; set; }
|
||||
public Uom? Uom { get; set; }
|
||||
|
||||
public int WarehouseId { get; set; }
|
||||
public Warehouse? Warehouse { 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; }
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Vendor invoice consolidating one or more Confirmed GRNs for the same vendor
|
||||
/// (each linked GRN becomes one <see cref="PurchaseInvoiceLine"/>, not re-itemized).
|
||||
/// Draft is editable (GRNs can be re-selected); Posted reclassifies the GRN clearing
|
||||
/// balance to Accounts Payable (see PurchaseInvoiceService.PostAsync); vendor payments
|
||||
/// are then recorded against this aggregate rather than against the individual GRNs.
|
||||
/// </summary>
|
||||
public class PurchaseInvoice
|
||||
{
|
||||
public int PurchaseInvoiceId { get; set; }
|
||||
public string DocNo { get; set; } = string.Empty;
|
||||
|
||||
public string VendorInvoiceNo { get; set; } = string.Empty;
|
||||
public DateTime VendorInvoiceDate { get; set; }
|
||||
|
||||
public int VendorId { get; set; }
|
||||
public Vendor? Vendor { get; set; }
|
||||
|
||||
public string VendorSnapshotName { get; set; } = string.Empty;
|
||||
public string? VendorSnapshotTaxNo { get; set; }
|
||||
|
||||
public PurchaseInvoiceStatus Status { get; set; } = PurchaseInvoiceStatus.Draft;
|
||||
|
||||
public decimal Subtotal { get; set; }
|
||||
public decimal VatTotal { get; set; }
|
||||
public decimal GrandTotal { get; set; }
|
||||
public decimal PaidAmount { get; set; }
|
||||
public decimal BalanceAmount { get; set; }
|
||||
|
||||
/// <summary>Journal number of the AP-recognition GL journal entry posted when this
|
||||
/// invoice is Posted (Debit GRN Clearing / Credit Accounts Payable) — see
|
||||
/// PurchaseInvoiceService.PostAsync.</summary>
|
||||
public string? GlJournalNo { get; set; }
|
||||
public DateTime? GlPostedAt { get; set; }
|
||||
|
||||
public int CreatedBy { get; set; }
|
||||
public User? Creator { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
public uint RowVersion { get; set; }
|
||||
|
||||
public ICollection<PurchaseInvoiceLine> Lines { get; set; } = new List<PurchaseInvoiceLine>();
|
||||
public ICollection<PurchaseInvoicePayment> Payments { get; set; } = new List<PurchaseInvoicePayment>();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One row per consolidated GRN on a <see cref="PurchaseInvoice"/> — a straight
|
||||
/// consolidation of that GRN's line totals, not a re-itemization (docs: Purchase
|
||||
/// Invoice consolidates GRNs, it does not re-enter item lines).
|
||||
/// </summary>
|
||||
public class PurchaseInvoiceLine
|
||||
{
|
||||
public int PurchaseInvoiceLineId { get; set; }
|
||||
|
||||
public int PurchaseInvoiceId { get; set; }
|
||||
public PurchaseInvoice? PurchaseInvoice { get; set; }
|
||||
|
||||
public int GrnId { get; set; }
|
||||
public Grn? Grn { get; set; }
|
||||
|
||||
public decimal Subtotal { get; set; }
|
||||
public decimal VatTotal { get; set; }
|
||||
public decimal LineTotal { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A vendor payment made against a Posted purchase invoice's balance (installments
|
||||
/// allowed — see PurchaseInvoicePaymentService.PayAsync). Posts its own real GL
|
||||
/// journal entry (Debit Accounts Payable / Credit the selected bank-or-cash account)
|
||||
/// before being recorded here.
|
||||
/// </summary>
|
||||
public class PurchaseInvoicePayment
|
||||
{
|
||||
public int PurchaseInvoicePaymentId { get; set; }
|
||||
|
||||
public int PurchaseInvoiceId { get; set; }
|
||||
public PurchaseInvoice? PurchaseInvoice { 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; }
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
|
||||
|
||||
@@ -31,10 +31,14 @@ public class RunStageInput
|
||||
public int? FromRunOutputId { get; set; }
|
||||
public RunStageOutput? FromRunOutput { get; set; }
|
||||
|
||||
public int UomId { get; set; }
|
||||
public Uom? Uom { get; set; }
|
||||
/// <summary>Copied from the template input: what <see cref="PlannedQty"/> is expressed in.</summary>
|
||||
public StageQtyUnit QtyUnit { get; set; } = StageQtyUnit.Pack;
|
||||
|
||||
/// <summary>Scaled at creation; per-run editable until the stage starts (FR-MFG-08, <c>409 STAGE_NOT_EDITABLE</c>).</summary>
|
||||
/// <summary>
|
||||
/// Scaled at creation; per-run editable until the stage starts (FR-MFG-08,
|
||||
/// <c>409 STAGE_NOT_EDITABLE</c>). Expressed in <see cref="QtyUnit"/> — so unlike the
|
||||
/// consumption figures below it is <b>not</b> necessarily a pack count.
|
||||
/// </summary>
|
||||
public decimal PlannedQty { get; set; }
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -23,10 +23,17 @@ public class RunStageOutput
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public int UomId { get; set; }
|
||||
/// <summary>
|
||||
/// Display label for intermediate WIP; null on the terminal output, whose unit is the
|
||||
/// finished item's base UOM. Never converted — see <see cref="StageOutput.UomId"/>.
|
||||
/// </summary>
|
||||
public int? UomId { get; set; }
|
||||
public Uom? Uom { get; set; }
|
||||
|
||||
/// <summary>Scaled at creation; per-run editable until the stage starts.</summary>
|
||||
/// <summary>
|
||||
/// Scaled at creation; per-run editable until the stage starts. Every quantity on an
|
||||
/// output is a pack count, so scrap is recorded in whole broken bottles rather than ml.
|
||||
/// </summary>
|
||||
public decimal PlannedQty { get; set; }
|
||||
|
||||
/// <summary>Recorded at complete. A re-complete after a rework <b>overwrites</b> this, never adds to it.</summary>
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
|
||||
@@ -16,8 +16,6 @@ public class SalesInvoiceLine
|
||||
|
||||
public decimal Qty { get; set; }
|
||||
public decimal FreeQty { get; set; }
|
||||
public int UomId { get; set; }
|
||||
public Uom? Uom { get; set; }
|
||||
public int WarehouseId { get; set; }
|
||||
public Warehouse? Warehouse { get; set; }
|
||||
|
||||
|
||||
@@ -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="PurchaseInvoicePayment"/>.
|
||||
/// </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; }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Sales return header — a customer returns previously sold goods, generating an
|
||||
/// inbound stock movement. Auto-posts with a mandatory reason code, mirroring
|
||||
/// <see cref="PurchaseReturn"/> with the direction reversed.
|
||||
/// </summary>
|
||||
public class SalesReturn
|
||||
{
|
||||
public int ReturnId { get; set; }
|
||||
public string DocNo { get; set; } = string.Empty;
|
||||
|
||||
public int CustomerId { get; set; }
|
||||
public Customer? Customer { get; set; }
|
||||
|
||||
public int WarehouseId { get; set; }
|
||||
public Warehouse? Warehouse { get; set; }
|
||||
|
||||
public int ReasonCodeId { get; set; }
|
||||
public ReasonCode? ReasonCode { get; set; }
|
||||
|
||||
public ReturnStatus Status { get; set; } = ReturnStatus.Posted;
|
||||
|
||||
public int CreatedBy { get; set; }
|
||||
public User? Creator { get; set; }
|
||||
|
||||
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>();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Sales-return line referencing the original sales invoice line for traceability.
|
||||
/// <see cref="Qty"/> is in base UOM.
|
||||
/// </summary>
|
||||
public class SalesReturnLine
|
||||
{
|
||||
public int ReturnLineId { get; set; }
|
||||
|
||||
public int ReturnId { get; set; }
|
||||
public SalesReturn? Return { get; set; }
|
||||
|
||||
public int? SalesInvoiceLineId { get; set; }
|
||||
public SalesInvoiceLine? SalesInvoiceLine { get; set; }
|
||||
|
||||
public int ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
|
||||
public decimal Qty { get; set; }
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -16,8 +16,6 @@ public class SalesSlipLine
|
||||
|
||||
public decimal Qty { get; set; }
|
||||
public decimal FreeQty { get; set; }
|
||||
public int UomId { get; set; }
|
||||
public Uom? Uom { get; set; }
|
||||
public int WarehouseId { get; set; }
|
||||
public Warehouse? Warehouse { get; set; }
|
||||
|
||||
|
||||
@@ -32,8 +32,13 @@ public class StageInput
|
||||
public int? FromOutputId { get; set; }
|
||||
public StageOutput? FromOutput { get; set; }
|
||||
|
||||
public int UomId { get; set; }
|
||||
public Uom? Uom { get; set; }
|
||||
/// <summary>
|
||||
/// What <see cref="QtyPerBatch"/> is expressed in. Stock inputs may use
|
||||
/// <see cref="StageQtyUnit.Content"/> (ml/g) when the item has a content size; Upstream
|
||||
/// inputs are always <see cref="StageQtyUnit.Pack"/> — WIP is counted in the unit its
|
||||
/// source output declares.
|
||||
/// </summary>
|
||||
public StageQtyUnit QtyUnit { get; set; } = StageQtyUnit.Pack;
|
||||
|
||||
public decimal QtyPerBatch { get; set; }
|
||||
}
|
||||
|
||||
@@ -20,8 +20,14 @@ public class StageOutput
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public int UomId { get; set; }
|
||||
/// <summary>
|
||||
/// Display label for intermediate work-in-progress. Required when <see cref="ItemId"/>
|
||||
/// is null and must be null when it is set — a real item's unit is its own base UOM.
|
||||
/// WIP never touches stock or the ledger, so this is never converted, only shown.
|
||||
/// </summary>
|
||||
public int? UomId { get; set; }
|
||||
public Uom? Uom { get; set; }
|
||||
|
||||
/// <summary>Always a pack count: of the WIP unit above, or of the item's base UOM.</summary>
|
||||
public decimal QtyPerBatch { 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>();
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Unit of Measure (FR-MD-02). Referenced as an item's base UOM and as the
|
||||
/// endpoints of a <see cref="UomConversion"/>. Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
||||
/// Unit of Measure (FR-MD-02). A flat lookup, used as an item's base UOM — the pack every
|
||||
/// quantity in the system counts — and as the display label on an intermediate production
|
||||
/// output. There are no conversions between UOMs: an item is stocked in exactly one, and a
|
||||
/// differently sized pack is a different item. Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
||||
/// </summary>
|
||||
public class Uom
|
||||
{
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Per-item conversion factor between two UOMs (FR-MD-02/03): quantity in
|
||||
/// <see cref="FromUomId"/> × <see cref="Factor"/> = quantity in <see cref="ToUomId"/>.
|
||||
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
||||
/// </summary>
|
||||
public class UomConversion
|
||||
{
|
||||
public int ConversionId { get; set; }
|
||||
|
||||
public int ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
|
||||
public int FromUomId { get; set; }
|
||||
public Uom? FromUom { get; set; }
|
||||
|
||||
public int ToUomId { get; set; }
|
||||
public Uom? ToUom { get; set; }
|
||||
|
||||
public decimal Factor { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Unit of an item's <b>content size</b> — how much a single stocked pack holds
|
||||
/// (a 500 ml bottle, a 50 kg sack). Stored as a string in the database.
|
||||
/// <para>
|
||||
/// This is <b>not</b> a stocking unit. Stock is always counted in packs
|
||||
/// (<c>Item.BaseUomId</c>); content is a separate, optional attribute used by
|
||||
/// production to turn "2000 ml of syrup" into a pack count.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Only <see cref="Ml"/> and <see cref="G"/> are ever stored as a <i>base</i> content
|
||||
/// unit. <see cref="L"/> and <see cref="Kg"/> are entry conveniences normalised ×1000
|
||||
/// on write by <c>ItemContent.Normalize</c>, so nothing downstream has to convert.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public enum MeasureUnit
|
||||
{
|
||||
Ml,
|
||||
L,
|
||||
G,
|
||||
Kg
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
public enum PurchaseInvoiceStatus
|
||||
{
|
||||
Draft,
|
||||
Posted,
|
||||
Cancelled
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// What a stage input's quantity is expressed in (FR-MFG-04). Stored as a string.
|
||||
/// <para>
|
||||
/// Deliberately explicit rather than inferred from whether the item happens to have a
|
||||
/// content size: templates outlive item edits, so an inferred unit would let adding a
|
||||
/// content size to an existing item silently reinterpret every saved formula — "300"
|
||||
/// meaning 300 packs would become 300 ml.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public enum StageQtyUnit
|
||||
{
|
||||
/// <summary>A count of the item's base UOM — bottles, packets, pieces.</summary>
|
||||
Pack,
|
||||
|
||||
/// <summary>
|
||||
/// An amount of the item's content in its base content unit (ml or g), resolved to
|
||||
/// packs by <c>IItemMeasure</c> at stage start. Requires the item to have a content size.
|
||||
/// </summary>
|
||||
Content
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Whether an item is sold under warranty (FR-MD-01). Stored as a string, same
|
||||
/// convention as <see cref="StockNature"/> and <see cref="TrackingMode"/>.
|
||||
/// </summary>
|
||||
public enum Warranty
|
||||
{
|
||||
NonWarranty,
|
||||
Warranty
|
||||
}
|
||||
|
||||
/// <summary>Allowed warranty coverage lengths, in months — set once on the item (FR-MD-01).</summary>
|
||||
public static class WarrantyPeriods
|
||||
{
|
||||
public static readonly int[] AllowedMonths = { 3, 6, 12, 18 };
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -5,32 +5,41 @@ namespace ERPCore.Dtos.Grn;
|
||||
|
||||
// Responses (docs/11 §4) --------------------------------------------------------
|
||||
|
||||
public sealed record GrnLineWarrantyNumberDto(string WarrantyNo, int WarrantyPeriodMonths);
|
||||
|
||||
public sealed record GrnLineDto(
|
||||
int GrnLineId, int? PoLineId, int ItemId, int UomId, int? BinId,
|
||||
int GrnLineId, int? PoLineId, int ItemId, int? BinId,
|
||||
decimal Qty, decimal UnitCost, decimal? PoUnitPrice,
|
||||
decimal DiscountPct, decimal NetUnitCost, decimal VatPct, decimal VatAmount,
|
||||
decimal ReceivedValue, decimal LineTotal, decimal PriceVariance,
|
||||
HoldStatus HoldStatus, int? BatchId);
|
||||
HoldStatus HoldStatus, int? BatchId, IReadOnlyList<GrnLineWarrantyNumberDto> WarrantyNumbers);
|
||||
|
||||
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,
|
||||
int? PurchaseInvoiceId, string? PurchaseInvoiceNo, 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,
|
||||
int? PurchaseInvoiceId, string? PurchaseInvoiceNo);
|
||||
|
||||
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,
|
||||
IReadOnlyList<CreatedLayerDto> CreatedLayers, IReadOnlyList<int> LedgerRefs, PurchaseOrderStatus? PoStatus);
|
||||
|
||||
public sealed record ReleaseLineResultDto(int GrnLineId, HoldStatus HoldStatus);
|
||||
|
||||
/// <summary>A Confirmed, not-yet-invoiced GRN eligible to be consolidated onto a Purchase Invoice.</summary>
|
||||
public sealed record GrnEligibleForInvoiceDto(
|
||||
int GrnId, string DocNo, DateTime PostedAt, int WarehouseId,
|
||||
int LineCount, decimal Subtotal, decimal VatTotal, decimal GrandTotal);
|
||||
|
||||
// Requests ----------------------------------------------------------------------
|
||||
|
||||
public sealed class BatchInput
|
||||
@@ -44,7 +53,6 @@ public sealed class CreateGrnLineInput
|
||||
/// <summary>Set for a PO-based receipt; cost is then derived from the PO line (02-SECURITY C.3).</summary>
|
||||
public int? PoLineId { get; set; }
|
||||
[Required] public int ItemId { get; set; }
|
||||
[Required] public int UomId { get; set; }
|
||||
public int? BinId { get; set; }
|
||||
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
|
||||
/// <summary>
|
||||
@@ -59,6 +67,11 @@ public sealed class CreateGrnLineInput
|
||||
[Range(0, 100)] public decimal VatPct { get; set; }
|
||||
[EnumDataType(typeof(HoldStatus))] public HoldStatus HoldStatus { get; set; } = HoldStatus.Available;
|
||||
public BatchInput? Batch { get; set; }
|
||||
/// <summary>
|
||||
/// Required, one per unit (count must equal <see cref="Qty"/>), when the item is
|
||||
/// warranty-tracked (<c>Item.Warranty == Warranty.Warranty</c>). Ignored otherwise.
|
||||
/// </summary>
|
||||
public List<string>? WarrantyNumbers { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CreateGrnRequest
|
||||
|
||||
@@ -5,12 +5,16 @@ namespace ERPCore.Dtos.ItemTypes;
|
||||
|
||||
/// <summary>
|
||||
/// Item type resource (docs/11-BACKEND-PHASE1.md §2.7) — a dimension name such as Color
|
||||
/// or Size. Carries no values and no item linkage: <c>GET /item-types</c> exists to
|
||||
/// populate the frontend builder's dropdown, and the chosen values are encoded into the
|
||||
/// or Size. Carries no values and no item linkage: the chosen values are encoded into the
|
||||
/// client-generated SKU rather than stored (docs/10 Part C.9).
|
||||
/// <para>
|
||||
/// <c>IsMeasurable</c> marks a dimension whose values are content measurements (500 ml, 1 L)
|
||||
/// rather than plain labels; the builder captures a number + unit per value and writes it to
|
||||
/// each generated item's content size.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed record ItemTypeDto(
|
||||
int ItemTypeId, string Name, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
|
||||
int ItemTypeId, string Name, bool IsMeasurable, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
|
||||
|
||||
// Request DTOs — narrow: server-controlled fields (status, ids, timestamps)
|
||||
// are intentionally excluded to prevent over-posting (02-SECURITY B.6 / C.1). ----
|
||||
@@ -18,11 +22,21 @@ public sealed record ItemTypeDto(
|
||||
public sealed class CreateItemTypeRequest
|
||||
{
|
||||
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Omitted ⇒ false, i.e. plain-text values. See <see cref="ItemTypeDto"/>.</summary>
|
||||
public bool IsMeasurable { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateItemTypeRequest
|
||||
{
|
||||
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Nullable on purpose: a plain <c>bool</c> binds an absent property as <c>false</c>, so any
|
||||
/// client that PUT only a name — as the item-types screen used to — would silently clear the
|
||||
/// flag on every rename. Omitting this field <b>preserves</b> the stored value.
|
||||
/// </summary>
|
||||
public bool? IsMeasurable { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateItemTypeStatusRequest
|
||||
|
||||
@@ -9,7 +9,11 @@ namespace ERPCore.Dtos.Items;
|
||||
public sealed record ItemListItemDto(
|
||||
int ItemId, string Sku, string Name, int CategoryId, int? SubCategoryId, int? BrandId,
|
||||
int BaseUomId, int? DefaultVendorId, StockNature StockNature, TrackingMode TrackingMode,
|
||||
string? TaxClass, decimal? SalePrice, EntityStatus Status);
|
||||
Warranty Warranty, int? WarrantyPeriodMonths,
|
||||
string? TaxClass, decimal? SellingMinPrice, decimal? SellingMaxPrice, decimal? SellingFlatPrice,
|
||||
decimal? ContentQty, MeasureUnit? ContentUnit,
|
||||
decimal? ContentBaseQty, MeasureUnit? ContentBaseUnit,
|
||||
EntityStatus Status);
|
||||
|
||||
/// <summary>A single per-warehouse reorder policy row.</summary>
|
||||
public sealed record ItemReorderDto(int WarehouseId, decimal ReorderPoint, decimal ReorderQty);
|
||||
@@ -17,26 +21,22 @@ public sealed record ItemReorderDto(int WarehouseId, decimal ReorderPoint, decim
|
||||
/// <summary>
|
||||
/// Full item resource for <c>GET /items/{id}</c> and create/update responses.
|
||||
/// <para>
|
||||
/// <see cref="Conversions"/> is embedded because they are otherwise unreadable: they can
|
||||
/// only be written via <c>PUT /items/{id}/uom-conversions</c>, which returns them, but no
|
||||
/// endpoint reads them back — so a detail screen could never show current state before
|
||||
/// editing. Mirrors how <see cref="Reorder"/> is already inlined.
|
||||
/// <c>ContentBaseQty</c>/<c>ContentBaseUnit</c> are echoed back so a detail screen can show
|
||||
/// what the entered size normalised to (1.5 L ⇒ 1500 ml) — they are server-derived and are
|
||||
/// not accepted on write.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed record ItemDetailDto(
|
||||
int ItemId, string Sku, string Name, string? Description, int CategoryId,
|
||||
int? SubCategoryId, int? BrandId, int BaseUomId, int? DefaultVendorId,
|
||||
StockNature StockNature, TrackingMode TrackingMode,
|
||||
string? TaxClass, decimal? SalePrice, EntityStatus Status, IReadOnlyList<ItemReorderDto> Reorder,
|
||||
IReadOnlyList<UomConversionDto> Conversions,
|
||||
Warranty Warranty, int? WarrantyPeriodMonths,
|
||||
string? TaxClass, decimal? SellingMinPrice, decimal? SellingMaxPrice, decimal? SellingFlatPrice,
|
||||
decimal? ContentQty, MeasureUnit? ContentUnit,
|
||||
decimal? ContentBaseQty, MeasureUnit? ContentBaseUnit,
|
||||
EntityStatus Status, IReadOnlyList<ItemReorderDto> Reorder,
|
||||
DateTime CreatedAt, DateTime? UpdatedAt);
|
||||
|
||||
/// <summary>UOM conversion row (docs/11 §2.2).</summary>
|
||||
public sealed record UomConversionDto(int ConversionId, int FromUom, int ToUom, decimal Factor);
|
||||
|
||||
/// <summary>Response body for <c>PUT /items/{id}/uom-conversions</c>.</summary>
|
||||
public sealed record ItemUomConversionsDto(int ItemId, int BaseUomId, IReadOnlyList<UomConversionDto> Conversions);
|
||||
|
||||
/// <summary>Response body for <c>PUT /items/{id}/reorder</c>.</summary>
|
||||
public sealed record ItemReorderSettingsDto(IReadOnlyList<ItemReorderDto> Settings);
|
||||
|
||||
@@ -61,9 +61,22 @@ public sealed class CreateItemRequest
|
||||
public int? DefaultVendorId { get; set; }
|
||||
[Required, EnumDataType(typeof(StockNature))] public StockNature StockNature { get; set; }
|
||||
[EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None;
|
||||
[EnumDataType(typeof(Warranty))] public Warranty Warranty { get; set; } = Warranty.NonWarranty;
|
||||
/// <summary>Required (one of <see cref="Enums.WarrantyPeriods.AllowedMonths"/>) when <see cref="Warranty"/> is Warranty; ignored otherwise.</summary>
|
||||
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
|
||||
/// for items with no measurable content. The normalised base pair is derived by the
|
||||
/// server and is deliberately not accepted here.
|
||||
/// </summary>
|
||||
[Range(0.0001, double.MaxValue)] public decimal? ContentQty { get; set; }
|
||||
[EnumDataType(typeof(MeasureUnit))] public MeasureUnit? ContentUnit { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateItemRequest
|
||||
@@ -80,9 +93,22 @@ public sealed class UpdateItemRequest
|
||||
public int? DefaultVendorId { get; set; }
|
||||
[Required, EnumDataType(typeof(StockNature))] public StockNature StockNature { get; set; }
|
||||
[EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None;
|
||||
[EnumDataType(typeof(Warranty))] public Warranty Warranty { get; set; } = Warranty.NonWarranty;
|
||||
/// <summary>Required (one of <see cref="Enums.WarrantyPeriods.AllowedMonths"/>) when <see cref="Warranty"/> is Warranty; ignored otherwise.</summary>
|
||||
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
|
||||
/// for items with no measurable content. The normalised base pair is derived by the
|
||||
/// server and is deliberately not accepted here.
|
||||
/// </summary>
|
||||
[Range(0.0001, double.MaxValue)] public decimal? ContentQty { get; set; }
|
||||
[EnumDataType(typeof(MeasureUnit))] public MeasureUnit? ContentUnit { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateItemStatusRequest
|
||||
@@ -101,15 +127,3 @@ public sealed class UpdateReorderRequest
|
||||
{
|
||||
[Required, MinLength(1)] public List<ReorderSettingInput> Settings { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class UomConversionInput
|
||||
{
|
||||
[Required] public int FromUom { get; set; }
|
||||
[Required] public int ToUom { get; set; }
|
||||
[Range(0.000001, double.MaxValue)] public decimal Factor { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateUomConversionsRequest
|
||||
{
|
||||
[Required, MinLength(1)] public List<UomConversionInput> Conversions { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Procurement;
|
||||
|
||||
public sealed record PurchaseInvoiceLineDto(
|
||||
int PurchaseInvoiceLineId, int GrnId, string GrnDocNo, DateTime GrnPostedAt,
|
||||
decimal Subtotal, decimal VatTotal, decimal LineTotal);
|
||||
|
||||
public sealed record PurchaseInvoiceTotalsDto(
|
||||
decimal Subtotal, decimal VatTotal, decimal GrandTotal, decimal PaidAmount, decimal BalanceAmount);
|
||||
|
||||
public sealed record PurchaseInvoiceDto(
|
||||
int PurchaseInvoiceId, string DocNo, string VendorInvoiceNo, DateTime VendorInvoiceDate,
|
||||
int VendorId, string VendorSnapshotName, string? VendorSnapshotTaxNo,
|
||||
PurchaseInvoiceStatus Status, int GrnCount, int CreatedBy, DateTime CreatedAt, DateTime? UpdatedAt,
|
||||
string? GlJournalNo, DateTime? GlPostedAt,
|
||||
PurchaseInvoiceTotalsDto Totals, IReadOnlyList<PurchaseInvoiceLineDto> Lines);
|
||||
|
||||
public sealed record PurchaseInvoiceSummaryDto(
|
||||
int PurchaseInvoiceId, string DocNo, string VendorInvoiceNo, DateTime VendorInvoiceDate,
|
||||
int VendorId, string VendorSnapshotName, PurchaseInvoiceStatus Status, int GrnCount,
|
||||
PurchaseInvoiceTotalsDto Totals, DateTime CreatedAt, string? GlJournalNo);
|
||||
|
||||
public sealed record PurchaseInvoicePaymentDto(
|
||||
int PurchaseInvoicePaymentId, int PurchaseInvoiceId, decimal Amount, DateTime PaymentDate,
|
||||
long GlBankAccountId, string BankAccountName, string? Reference, string? GlJournalNo, DateTime CreatedAt);
|
||||
|
||||
public sealed class CreatePurchaseInvoicePaymentRequest
|
||||
{
|
||||
[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 class CreatePurchaseInvoiceRequest
|
||||
{
|
||||
[Required] public int VendorId { get; set; }
|
||||
[Required, StringLength(50)] public string VendorInvoiceNo { get; set; } = string.Empty;
|
||||
[Required] public DateTime VendorInvoiceDate { get; set; }
|
||||
[Required, MinLength(1)] public List<int> GrnIds { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class UpdatePurchaseInvoiceRequest
|
||||
{
|
||||
[Required] public int VendorId { get; set; }
|
||||
[Required, StringLength(50)] public string VendorInvoiceNo { get; set; } = string.Empty;
|
||||
[Required] public DateTime VendorInvoiceDate { get; set; }
|
||||
[Required, MinLength(1)] public List<int> GrnIds { get; set; } = new();
|
||||
}
|
||||
@@ -6,7 +6,7 @@ namespace ERPCore.Dtos.Procurement;
|
||||
// Responses (docs/11 §3.3) ------------------------------------------------------
|
||||
|
||||
public sealed record PoLineDto(
|
||||
int PoLineId, int ItemId, int UomId, int WarehouseId,
|
||||
int PoLineId, int ItemId, int WarehouseId,
|
||||
decimal Qty, decimal UnitPrice, decimal Tax, decimal QtyReceived);
|
||||
|
||||
public sealed record PoTotalsDto(decimal SubTotal, decimal Tax, decimal GrandTotal, string Currency);
|
||||
@@ -25,7 +25,6 @@ public sealed record PurchaseOrderSummaryDto(
|
||||
public sealed class CreatePoLineInput
|
||||
{
|
||||
[Required] public int ItemId { get; set; }
|
||||
[Required] public int UomId { get; set; }
|
||||
[Required] public int WarehouseId { get; set; }
|
||||
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
|
||||
[Range(0, double.MaxValue)] public decimal UnitPrice { get; set; }
|
||||
|
||||
@@ -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 ----------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -29,17 +29,23 @@ public sealed record RunSummaryDto(
|
||||
/// </summary>
|
||||
public sealed record CostPoolDto(decimal Consumed, decimal Returned, decimal Net);
|
||||
|
||||
/// <summary>
|
||||
/// One input of a run stage. <c>PlannedQty</c> is expressed in <c>QtyUnit</c> — content
|
||||
/// (ml/g) or packs — while every consumption figure is always a pack count, so the two are
|
||||
/// not directly comparable for a Content input.
|
||||
/// </summary>
|
||||
public sealed record RunStageInputDto(
|
||||
int RunInputId, StageInputSource Source, int? ItemId, int? FromRunOutputId, int UomId,
|
||||
int RunInputId, StageInputSource Source, int? ItemId, int? FromRunOutputId, StageQtyUnit QtyUnit,
|
||||
decimal PlannedQty, decimal ConsumedQty, decimal ConsumedValue,
|
||||
decimal DeliveredQty, decimal ReturnedQty, decimal ReturnedValue);
|
||||
|
||||
/// <summary>
|
||||
/// One output of a run stage. <c>AvailableToTransfer</c> is derived — produced − scrapped −
|
||||
/// transferred (FR-MFG-12) — and never stored.
|
||||
/// transferred (FR-MFG-12) — and never stored. <c>UomId</c> is the WIP label and is null on
|
||||
/// the terminal output, whose unit is the finished item's base UOM.
|
||||
/// </summary>
|
||||
public sealed record RunStageOutputDto(
|
||||
int RunOutputId, int? ItemId, string Name, int UomId,
|
||||
int RunOutputId, int? ItemId, string Name, int? UomId,
|
||||
decimal PlannedQty, decimal ProducedQty, decimal ScrappedQty, int? ScrapReasonCodeId,
|
||||
decimal TransferredQty, decimal AvailableToTransfer);
|
||||
|
||||
@@ -64,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,
|
||||
@@ -93,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
|
||||
{
|
||||
@@ -110,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; }
|
||||
}
|
||||
|
||||
@@ -35,10 +35,11 @@ public sealed record FieldDefDto(
|
||||
|
||||
public sealed record StageInputDto(
|
||||
int InputId, StageInputSource Source, int? ItemId,
|
||||
int? FromOutputId, string? FromOutputKey, int UomId, decimal QtyPerBatch);
|
||||
int? FromOutputId, string? FromOutputKey, StageQtyUnit QtyUnit, decimal QtyPerBatch);
|
||||
|
||||
/// <summary><c>UomId</c> is the WIP label and is null exactly when <c>ItemId</c> is set.</summary>
|
||||
public sealed record StageOutputDto(
|
||||
int OutputId, string Key, int? ItemId, string Name, int UomId, decimal QtyPerBatch);
|
||||
int OutputId, string Key, int? ItemId, string Name, int? UomId, decimal QtyPerBatch);
|
||||
|
||||
public sealed record TemplateStageDto(
|
||||
int StageId, string Key, string Name, string? RoleLabel, int EstimatedMinutes,
|
||||
@@ -134,8 +135,12 @@ public sealed class SaveInputRequest
|
||||
[StringLength(60)]
|
||||
public string? FromOutputKey { get; set; }
|
||||
|
||||
[Range(1, int.MaxValue)]
|
||||
public int UomId { get; set; }
|
||||
/// <summary>
|
||||
/// What <see cref="QtyPerBatch"/> means. <c>Content</c> (ml/g) is allowed only on a Stock
|
||||
/// input whose item has a content size; Upstream inputs must be <c>Pack</c>.
|
||||
/// </summary>
|
||||
[EnumDataType(typeof(StageQtyUnit))]
|
||||
public StageQtyUnit QtyUnit { get; set; } = StageQtyUnit.Pack;
|
||||
|
||||
[Range(0.0001, double.MaxValue)]
|
||||
public decimal QtyPerBatch { get; set; }
|
||||
@@ -153,8 +158,12 @@ public sealed class SaveOutputRequest
|
||||
[Required, StringLength(150, MinimumLength = 1)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The WIP display unit. Required when <see cref="ItemId"/> is null; must be null when it
|
||||
/// is set, because a real item's unit is its own base UOM.
|
||||
/// </summary>
|
||||
[Range(1, int.MaxValue)]
|
||||
public int UomId { get; set; }
|
||||
public int? UomId { get; set; }
|
||||
|
||||
[Range(0.0001, double.MaxValue)]
|
||||
public decimal QtyPerBatch { get; set; }
|
||||
|
||||
@@ -4,7 +4,7 @@ using ERPCore.Domain.Enums;
|
||||
namespace ERPCore.Dtos.Sales;
|
||||
|
||||
public sealed record BundleSaleLineDto(
|
||||
int BundleSaleLineId, int ItemId, string Description, decimal Qty, int UomId, int WarehouseId,
|
||||
int BundleSaleLineId, int ItemId, string Description, decimal Qty, int WarehouseId,
|
||||
decimal UnitPrice, decimal LineTotal, bool IncludeInBundle, bool IsComponent, int? ParentLineId);
|
||||
|
||||
public sealed record BundleSaleDto(
|
||||
@@ -20,7 +20,7 @@ public sealed record BundleSaleSummaryDto(
|
||||
decimal ComponentSubtotal, decimal BundlePrice, decimal GrandTotal, DateTime CreatedAt);
|
||||
|
||||
public sealed record BundleSaleTemplateLineDto(
|
||||
int BundleSaleTemplateLineId, int ItemId, int UomId, int WarehouseId, decimal Qty,
|
||||
int BundleSaleTemplateLineId, int ItemId, int WarehouseId, decimal Qty,
|
||||
decimal UnitPrice, bool IncludeInBundle, int SortOrder);
|
||||
|
||||
public sealed record BundleSaleTemplateDto(
|
||||
@@ -42,7 +42,6 @@ public sealed record BundleSalePostingCheckDto(
|
||||
public sealed class CreateBundleSaleTemplateLineRequest
|
||||
{
|
||||
[Required] public int ItemId { get; set; }
|
||||
[Required] public int UomId { get; set; }
|
||||
[Required] public int WarehouseId { get; set; }
|
||||
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
|
||||
[Range(0, double.MaxValue)] public decimal UnitPrice { get; set; }
|
||||
@@ -52,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; }
|
||||
}
|
||||
@@ -4,7 +4,7 @@ using ERPCore.Domain.Enums;
|
||||
namespace ERPCore.Dtos.Sales;
|
||||
|
||||
public sealed record SalesInvoiceLineDto(
|
||||
int SalesInvoiceLineId, int ItemId, string Description, decimal Qty, decimal FreeQty, int UomId,
|
||||
int SalesInvoiceLineId, int ItemId, string Description, decimal Qty, decimal FreeQty,
|
||||
int WarehouseId, decimal UnitPrice, decimal BaseCost, string PriceSource, decimal DiscountPct,
|
||||
decimal DiscountAmount, SalesDiscountMode DiscountMode, decimal NetUnitPrice, decimal LineTotal,
|
||||
decimal TaxPct, decimal TaxAmount, bool IsFreeIssue, int? ParentLineId);
|
||||
@@ -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,
|
||||
@@ -35,7 +47,6 @@ public sealed record SalesInvoicePostingCheckDto(
|
||||
public sealed class CreateSalesInvoiceLineRequest
|
||||
{
|
||||
[Required] public int ItemId { get; set; }
|
||||
[Required] public int UomId { get; set; }
|
||||
[Required] public int WarehouseId { get; set; }
|
||||
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
|
||||
[Range(0, double.MaxValue)] public decimal FreeQty { get; set; }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Sales;
|
||||
|
||||
// Responses -----------------------------------------------------------------
|
||||
|
||||
public sealed record SalesReturnLineDto(int ReturnLineId, int? SalesInvoiceLineId, int ItemId, decimal Qty);
|
||||
|
||||
public sealed record SalesReturnDto(
|
||||
int ReturnId, string DocNo, int CustomerId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
|
||||
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, string? GlJournalNo);
|
||||
|
||||
/// <summary>
|
||||
/// Remaining returnable qty for one sales invoice line — the invoiced qty minus
|
||||
/// whatever has already been returned against it. The invoice line's own <c>Qty</c>
|
||||
/// is never mutated by a return, so this is computed on read from return history.
|
||||
/// </summary>
|
||||
public sealed record SalesInvoiceLineRemainingDto(int SalesInvoiceLineId, decimal RemainingQty);
|
||||
|
||||
// Requests --------------------------------------------------------------------
|
||||
|
||||
public sealed class CreateSalesReturnLineInput
|
||||
{
|
||||
/// <summary>Original sales invoice line, for traceability against the sale.</summary>
|
||||
public int? SalesInvoiceLineId { get; set; }
|
||||
[Required] public int ItemId { get; set; }
|
||||
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CreateSalesReturnRequest
|
||||
{
|
||||
[Required] public int CustomerId { get; set; }
|
||||
[Required] public int WarehouseId { get; set; }
|
||||
/// <summary>Nullable so an omitted value is a distinct <c>REASON_CODE_REQUIRED</c> error.</summary>
|
||||
public int? ReasonCodeId { get; set; }
|
||||
[Required, MinLength(1)] public List<CreateSalesReturnLineInput> Lines { get; set; } = new();
|
||||
}
|
||||
@@ -4,7 +4,7 @@ using ERPCore.Domain.Enums;
|
||||
namespace ERPCore.Dtos.Sales;
|
||||
|
||||
public sealed record SalesSlipLineDto(
|
||||
int SalesSlipLineId, int ItemId, string Description, decimal Qty, decimal FreeQty, int UomId,
|
||||
int SalesSlipLineId, int ItemId, string Description, decimal Qty, decimal FreeQty,
|
||||
int WarehouseId, decimal UnitPrice, decimal BaseCost, string PriceSource, decimal DiscountPct,
|
||||
decimal DiscountAmount, SalesDiscountMode DiscountMode, decimal NetUnitPrice, decimal LineTotal,
|
||||
decimal TaxPct, decimal TaxAmount, bool IsFreeIssue, int? ParentLineId);
|
||||
@@ -34,7 +34,7 @@ public sealed record SalesSlipPostingCheckDto(
|
||||
public sealed record FreeIssueSummaryDto(
|
||||
int SalesSlipId, string SlipNo, SalesSlipStatus Status, DateTime CreatedAt,
|
||||
int WarehouseId, string WarehouseName, int ItemId, string ItemSku, string ItemName,
|
||||
int UomId, string UomName, decimal Qty, decimal FreeQty, string SchemeLabel);
|
||||
string UomName, decimal Qty, decimal FreeQty, string SchemeLabel);
|
||||
|
||||
public sealed record FreeIssueDto(
|
||||
int SalesSlipId, string SlipNo, DateTime SlipDate, SalesSlipStatus Status,
|
||||
@@ -45,7 +45,6 @@ public sealed record FreeIssueDto(
|
||||
public sealed class CreateSalesSlipLineRequest
|
||||
{
|
||||
[Required] public int ItemId { get; set; }
|
||||
[Required] public int UomId { get; set; }
|
||||
[Required] public int WarehouseId { get; set; }
|
||||
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
|
||||
[Range(0, double.MaxValue)] public decimal FreeQty { get; set; }
|
||||
|
||||
@@ -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 ----------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ public sealed class BundleSaleLineConfiguration : IEntityTypeConfiguration<Bundl
|
||||
builder.Property(x => x.IsComponent).HasDefaultValue(true);
|
||||
|
||||
builder.HasOne(x => x.Item).WithMany().HasForeignKey(x => x.ItemId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(x => x.Uom).WithMany().HasForeignKey(x => x.UomId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(x => x.Warehouse).WithMany().HasForeignKey(x => x.WarehouseId).OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -15,7 +15,6 @@ public sealed class BundleSaleTemplateLineConfiguration : IEntityTypeConfigurati
|
||||
builder.Property(x => x.SortOrder).HasDefaultValue(0);
|
||||
|
||||
builder.HasOne(x => x.Item).WithMany().HasForeignKey(x => x.ItemId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(x => x.Uom).WithMany().HasForeignKey(x => x.UomId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(x => x.Warehouse).WithMany().HasForeignKey(x => x.WarehouseId).OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,12 +16,14 @@ 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.RowVersion).IsRowVersion();
|
||||
|
||||
builder.HasOne(g => g.PurchaseOrder).WithMany().HasForeignKey(g => g.PoId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(g => g.Vendor).WithMany().HasForeignKey(g => g.VendorId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(g => g.Warehouse).WithMany().HasForeignKey(g => g.WarehouseId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(g => g.Creator).WithMany().HasForeignKey(g => g.CreatedBy).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(g => g.PurchaseInvoice).WithMany().HasForeignKey(g => g.PurchaseInvoiceId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasIndex(g => g.Status);
|
||||
builder.HasIndex(g => g.PoId);
|
||||
@@ -49,8 +51,25 @@ public sealed class GrnLineConfiguration : IEntityTypeConfiguration<GrnLine>
|
||||
builder.HasOne(l => l.Grn).WithMany(g => g.Lines).HasForeignKey(l => l.GrnId).OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne(l => l.PoLine).WithMany().HasForeignKey(l => l.PoLineId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(l => l.Uom).WithMany().HasForeignKey(l => l.UomId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(l => l.Bin).WithMany().HasForeignKey(l => l.BinId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(l => l.Batch).WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class GrnLineWarrantyNumberConfiguration : IEntityTypeConfiguration<GrnLineWarrantyNumber>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<GrnLineWarrantyNumber> builder)
|
||||
{
|
||||
builder.ToTable("grn_line_warranty_numbers");
|
||||
builder.HasKey(w => w.GrnLineWarrantyNumberId);
|
||||
|
||||
builder.Property(w => w.WarrantyNo).IsRequired().HasMaxLength(100);
|
||||
|
||||
builder.HasOne(w => w.GrnLine).WithMany(l => l.WarrantyNumbers)
|
||||
.HasForeignKey(w => w.GrnLineId).OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// A warranty number entered twice on the same line is almost certainly a typo —
|
||||
// catch it at the DB, not just client-side.
|
||||
builder.HasIndex(w => new { w.GrnLineId, w.WarrantyNo }).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,12 +20,27 @@ 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
|
||||
// server-side by ItemContent.Normalize and is only ever Ml or G.
|
||||
builder.Property(i => i.ContentQty).HasPrecision(18, 4);
|
||||
builder.Property(i => i.ContentBaseQty).HasPrecision(18, 4);
|
||||
builder.Property(i => i.ContentUnit)
|
||||
.HasConversion<string>().HasMaxLength(20);
|
||||
builder.Property(i => i.ContentBaseUnit)
|
||||
.HasConversion<string>().HasMaxLength(20);
|
||||
|
||||
builder.Property(i => i.StockNature)
|
||||
.HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||
builder.Property(i => i.TrackingMode)
|
||||
.HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||
builder.Property(i => i.Warranty)
|
||||
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
||||
.HasDefaultValue(Warranty.NonWarranty);
|
||||
builder.Property(i => i.Status)
|
||||
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
||||
.HasDefaultValue(EntityStatus.Active);
|
||||
|
||||
@@ -19,6 +19,11 @@ public sealed class ItemTypeConfiguration : IEntityTypeConfiguration<ItemType>
|
||||
builder.Property(t => t.Name).IsRequired().HasMaxLength(200);
|
||||
builder.HasIndex(t => t.Name).IsUnique();
|
||||
|
||||
// false is the only safe default here: EF uses the CLR default as its "unset" sentinel,
|
||||
// so if the store default were true, inserting an explicit false would be mistaken for
|
||||
// "not set" and silently written as true. Sentinel and store default must agree.
|
||||
builder.Property(t => t.IsMeasurable).IsRequired().HasDefaultValue(false);
|
||||
|
||||
builder.Property(t => t.Status)
|
||||
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
||||
.HasDefaultValue(EntityStatus.Active);
|
||||
|
||||
@@ -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,12 @@ 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 = 27 },
|
||||
new Permission { PermissionId = 37, Code = "NAV:production.runs", SubNavItemId = 25 },
|
||||
new Permission { PermissionId = 38, Code = "NAV:production.produced", SubNavItemId = 26 },
|
||||
new Permission { PermissionId = 39, Code = "NAV:procurement.purchase-invoices", SubNavItemId = 28 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -91,13 +91,13 @@ public sealed class StageInputConfiguration : IEntityTypeConfiguration<StageInpu
|
||||
builder.HasKey(i => i.InputId);
|
||||
|
||||
builder.Property(i => i.Source).HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||
builder.Property(i => i.QtyUnit).HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||
builder.Property(i => i.QtyPerBatch).HasPrecision(18, 4);
|
||||
|
||||
builder.HasOne(i => i.Stage).WithMany(s => s.Inputs)
|
||||
.HasForeignKey(i => i.StageId).OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasOne(i => i.Item).WithMany().HasForeignKey(i => i.ItemId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(i => i.Uom).WithMany().HasForeignKey(i => i.UomId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(i => i.FromOutput).WithMany()
|
||||
.HasForeignKey(i => i.FromOutputId).OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
@@ -206,6 +206,7 @@ public sealed class RunStageInputConfiguration : IEntityTypeConfiguration<RunSta
|
||||
builder.HasKey(i => i.RunInputId);
|
||||
|
||||
builder.Property(i => i.Source).HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||
builder.Property(i => i.QtyUnit).HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||
builder.Property(i => i.PlannedQty).HasPrecision(18, 4);
|
||||
builder.Property(i => i.ConsumedQty).HasPrecision(18, 4);
|
||||
builder.Property(i => i.ConsumedValue).HasPrecision(18, 4);
|
||||
@@ -217,7 +218,6 @@ public sealed class RunStageInputConfiguration : IEntityTypeConfiguration<RunSta
|
||||
.HasForeignKey(i => i.RunStageId).OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasOne(i => i.Item).WithMany().HasForeignKey(i => i.ItemId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(i => i.Uom).WithMany().HasForeignKey(i => i.UomId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(i => i.FromRunOutput).WithMany()
|
||||
.HasForeignKey(i => i.FromRunOutputId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Configurations;
|
||||
|
||||
public sealed class PurchaseInvoiceConfiguration : IEntityTypeConfiguration<PurchaseInvoice>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<PurchaseInvoice> builder)
|
||||
{
|
||||
builder.ToTable("purchase_invoices");
|
||||
builder.HasKey(x => x.PurchaseInvoiceId);
|
||||
|
||||
builder.Property(x => x.DocNo).IsRequired().HasMaxLength(30);
|
||||
builder.HasIndex(x => x.DocNo).IsUnique();
|
||||
|
||||
builder.Property(x => x.VendorInvoiceNo).IsRequired().HasMaxLength(50);
|
||||
builder.Property(x => x.VendorInvoiceDate).IsRequired();
|
||||
// A vendor invoice number is only meaningful per-vendor; the service also excludes
|
||||
// Cancelled invoices from the duplicate check, so this composite index is a coarse
|
||||
// backstop (it does not distinguish Cancelled rows) rather than the sole enforcement.
|
||||
builder.HasIndex(x => new { x.VendorId, x.VendorInvoiceNo });
|
||||
|
||||
builder.HasOne(x => x.Vendor)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.VendorId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.Property(x => x.VendorSnapshotName).IsRequired().HasMaxLength(200);
|
||||
builder.Property(x => x.VendorSnapshotTaxNo).HasMaxLength(50);
|
||||
|
||||
builder.Property(x => x.Status)
|
||||
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
||||
.HasDefaultValue(PurchaseInvoiceStatus.Draft);
|
||||
|
||||
foreach (var p in new[] { nameof(PurchaseInvoice.Subtotal), nameof(PurchaseInvoice.VatTotal), nameof(PurchaseInvoice.GrandTotal), nameof(PurchaseInvoice.PaidAmount), nameof(PurchaseInvoice.BalanceAmount) })
|
||||
builder.Property<decimal>(p).HasPrecision(18, 4);
|
||||
builder.Property(x => x.GlJournalNo).HasMaxLength(30);
|
||||
|
||||
builder.HasOne(x => x.Creator).WithMany().HasForeignKey(x => x.CreatedBy).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.Property(x => x.CreatedAt).IsRequired();
|
||||
builder.Property(x => x.RowVersion).IsRowVersion();
|
||||
|
||||
builder.HasIndex(x => x.Status);
|
||||
builder.HasIndex(x => x.VendorId);
|
||||
|
||||
builder.HasMany(x => x.Lines)
|
||||
.WithOne(x => x.PurchaseInvoice)
|
||||
.HasForeignKey(x => x.PurchaseInvoiceId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PurchaseInvoiceLineConfiguration : IEntityTypeConfiguration<PurchaseInvoiceLine>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<PurchaseInvoiceLine> builder)
|
||||
{
|
||||
builder.ToTable("purchase_invoice_lines");
|
||||
builder.HasKey(x => x.PurchaseInvoiceLineId);
|
||||
|
||||
builder.Property(x => x.Subtotal).HasPrecision(18, 4);
|
||||
builder.Property(x => x.VatTotal).HasPrecision(18, 4);
|
||||
builder.Property(x => x.LineTotal).HasPrecision(18, 4);
|
||||
|
||||
builder.HasOne(x => x.Grn)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.GrnId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// A GRN can be consolidated onto at most one active line at a time — cancelling the
|
||||
// invoice removes its lines and frees the GRN (PurchaseInvoiceService.CancelAsync), so
|
||||
// this stays enforceable rather than blocking a later re-invoice of the same GRN.
|
||||
builder.HasIndex(x => x.GrnId).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PurchaseInvoicePaymentConfiguration : IEntityTypeConfiguration<PurchaseInvoicePayment>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<PurchaseInvoicePayment> builder)
|
||||
{
|
||||
builder.ToTable("purchase_invoice_payments");
|
||||
builder.HasKey(p => p.PurchaseInvoicePaymentId);
|
||||
|
||||
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.PurchaseInvoice).WithMany(x => x.Payments).HasForeignKey(p => p.PurchaseInvoiceId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(p => p.Creator).WithMany().HasForeignKey(p => p.CreatedBy).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasIndex(p => p.PurchaseInvoiceId);
|
||||
}
|
||||
}
|
||||
@@ -63,11 +63,6 @@ public sealed class PoLineConfiguration : IEntityTypeConfiguration<PoLine>
|
||||
.HasForeignKey(l => l.ItemId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasOne(l => l.Uom)
|
||||
.WithMany()
|
||||
.HasForeignKey(l => l.UomId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasOne(l => l.Warehouse)
|
||||
.WithMany()
|
||||
.HasForeignKey(l => l.WarehouseId)
|
||||
|
||||
@@ -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)
|
||||
@@ -79,11 +101,6 @@ public sealed class SalesInvoiceLineConfiguration : IEntityTypeConfiguration<Sal
|
||||
builder.Property(x => x.TaxPct).HasPrecision(9, 4);
|
||||
builder.Property(x => x.TaxAmount).HasPrecision(18, 4);
|
||||
|
||||
builder.HasOne(x => x.Uom)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.UomId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasOne(x => x.Warehouse)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.WarehouseId)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Configurations;
|
||||
|
||||
public sealed class SalesReturnConfiguration : IEntityTypeConfiguration<SalesReturn>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SalesReturn> builder)
|
||||
{
|
||||
builder.ToTable("sales_returns");
|
||||
builder.HasKey(r => r.ReturnId);
|
||||
|
||||
builder.Property(r => r.DocNo).IsRequired().HasMaxLength(30);
|
||||
builder.HasIndex(r => r.DocNo).IsUnique();
|
||||
|
||||
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);
|
||||
builder.HasOne(r => r.ReasonCode).WithMany().HasForeignKey(r => r.ReasonCodeId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(r => r.Creator).WithMany().HasForeignKey(r => r.CreatedBy).OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SalesReturnLineConfiguration : IEntityTypeConfiguration<SalesReturnLine>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SalesReturnLine> builder)
|
||||
{
|
||||
builder.ToTable("sales_return_lines");
|
||||
builder.HasKey(l => l.ReturnLineId);
|
||||
|
||||
builder.Property(l => l.Qty).HasPrecision(18, 4);
|
||||
|
||||
builder.HasOne(l => l.Return).WithMany(r => r.Lines).HasForeignKey(l => l.ReturnId).OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne(l => l.SalesInvoiceLine).WithMany().HasForeignKey(l => l.SalesInvoiceLineId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
@@ -79,11 +79,6 @@ public sealed class SalesSlipLineConfiguration : IEntityTypeConfiguration<SalesS
|
||||
builder.Property(x => x.TaxPct).HasPrecision(9, 4);
|
||||
builder.Property(x => x.TaxAmount).HasPrecision(18, 4);
|
||||
|
||||
builder.HasOne(x => x.Uom)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.UomId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasOne(x => x.Warehouse)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.WarehouseId)
|
||||
|
||||
@@ -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;
|
||||
@@ -41,6 +42,7 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
|
||||
new SubNavItem { SubNavItemId = 18, NavItemId = 4, Code = "procurement.rfqs", Label = "RFQs", Href = "/dashboard/procurement/rfqs", SortOrder = 2 },
|
||||
new SubNavItem { SubNavItemId = 19, NavItemId = 4, Code = "procurement.purchase-orders", Label = "Purchase Orders", Href = "/dashboard/procurement/purchase-orders", SortOrder = 3 },
|
||||
new SubNavItem { SubNavItemId = 20, NavItemId = 4, Code = "procurement.purchase-returns", Label = "Purchase Returns", Href = "/dashboard/procurement/purchase-returns", SortOrder = 4 },
|
||||
new SubNavItem { SubNavItemId = 28, NavItemId = 4, Code = "procurement.purchase-invoices", Label = "Purchase Invoices", Href = "/dashboard/procurement/purchase-invoices", SortOrder = 5 },
|
||||
new SubNavItem { SubNavItemId = 9, NavItemId = 11, Code = "ledgers.trial-balance", Label = "Trial Balance", Href = "/dashboard/ledgers/trial-balance", SortOrder = 1 },
|
||||
new SubNavItem { SubNavItemId = 10, NavItemId = 11, Code = "ledgers.balance-sheet", Label = "Balance Sheet", Href = "/dashboard/ledgers/balance-sheet", SortOrder = 2 },
|
||||
new SubNavItem { SubNavItemId = 11, NavItemId = 11, Code = "ledgers.general-ledger", Label = "General Ledger", Href = "/dashboard/ledgers/general-ledger", SortOrder = 3 },
|
||||
@@ -53,7 +55,15 @@ 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).
|
||||
// ID 27 (not 24): 24 collides with settings.notification-config above — that ID was
|
||||
// reused by mistake in migration 20260820100345, which upserts row 24 to become the
|
||||
// notification-config item and silently overwrites this one. See migration
|
||||
// FixProductionTemplatesSubNavItemIdConflict for the data repair.
|
||||
new SubNavItem { SubNavItemId = 27, 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Configurations;
|
||||
|
||||
public sealed class UomConversionConfiguration : IEntityTypeConfiguration<UomConversion>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<UomConversion> builder)
|
||||
{
|
||||
builder.ToTable("uom_conversions");
|
||||
builder.HasKey(c => c.ConversionId);
|
||||
|
||||
builder.Property(c => c.Factor).HasPrecision(18, 6);
|
||||
|
||||
builder.HasOne(c => c.Item)
|
||||
.WithMany(i => i.UomConversions)
|
||||
.HasForeignKey(c => c.ItemId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasOne(c => c.FromUom)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.FromUomId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasOne(c => c.ToUom)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.ToUomId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// One conversion per (item, from, to) triple.
|
||||
builder.HasIndex(c => new { c.ItemId, c.FromUomId, c.ToUomId }).IsUnique();
|
||||
}
|
||||
}
|
||||
@@ -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,20 +294,25 @@ public static class DataSeeder
|
||||
BaseUomId = uom.UomId,
|
||||
StockNature = StockNature.Stocked,
|
||||
TrackingMode = TrackingMode.None,
|
||||
SalePrice = 100m,
|
||||
SellingFlatPrice = 100m,
|
||||
Status = EntityStatus.Active,
|
||||
CreatedAt = now
|
||||
},
|
||||
new Item
|
||||
{
|
||||
Sku = "SKU-DEMO-002",
|
||||
Name = "Demo Item 2",
|
||||
Description = "Secondary seeded sample item for sales documents",
|
||||
Name = "Demo Item 2 (500 ml)",
|
||||
Description = "Secondary seeded sample item; carries a content size so the "
|
||||
+ "production content-unit path has a fixture",
|
||||
CategoryId = category.CategoryId,
|
||||
BaseUomId = uom.UomId,
|
||||
StockNature = StockNature.Stocked,
|
||||
TrackingMode = TrackingMode.None,
|
||||
SalePrice = 50m,
|
||||
SellingFlatPrice = 50m,
|
||||
ContentQty = 500m,
|
||||
ContentUnit = MeasureUnit.Ml,
|
||||
ContentBaseQty = 500m,
|
||||
ContentBaseUnit = MeasureUnit.Ml,
|
||||
Status = EntityStatus.Active,
|
||||
CreatedAt = now
|
||||
}
|
||||
@@ -438,20 +459,18 @@ public static class DataSeeder
|
||||
new BundleSaleTemplateLine
|
||||
{
|
||||
ItemId = items[0].ItemId,
|
||||
UomId = uom.UomId,
|
||||
WarehouseId = warehouse.WarehouseId,
|
||||
Qty = 1m,
|
||||
UnitPrice = items[0].SalePrice.GetValueOrDefault(),
|
||||
UnitPrice = items[0].SellingFlatPrice.GetValueOrDefault(),
|
||||
IncludeInBundle = true,
|
||||
SortOrder = 1
|
||||
},
|
||||
new BundleSaleTemplateLine
|
||||
{
|
||||
ItemId = items[1].ItemId,
|
||||
UomId = uom.UomId,
|
||||
WarehouseId = warehouse.WarehouseId,
|
||||
Qty = 1m,
|
||||
UnitPrice = items[1].SalePrice.GetValueOrDefault(),
|
||||
UnitPrice = items[1].SellingFlatPrice.GetValueOrDefault(),
|
||||
IncludeInBundle = true,
|
||||
SortOrder = 2
|
||||
}
|
||||
@@ -475,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),
|
||||
@@ -489,10 +508,9 @@ public static class DataSeeder
|
||||
ItemId = items[0].ItemId,
|
||||
Description = items[0].Name,
|
||||
Qty = 1m,
|
||||
UomId = uom.UomId,
|
||||
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
|
||||
},
|
||||
@@ -501,10 +519,9 @@ public static class DataSeeder
|
||||
ItemId = items[1].ItemId,
|
||||
Description = items[1].Name,
|
||||
Qty = 1m,
|
||||
UomId = uom.UomId,
|
||||
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
|
||||
}
|
||||
@@ -522,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 =
|
||||
@@ -537,10 +554,9 @@ public static class DataSeeder
|
||||
ItemId = items[0].ItemId,
|
||||
Description = items[0].Name,
|
||||
Qty = 1m,
|
||||
UomId = uom.UomId,
|
||||
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
|
||||
},
|
||||
@@ -549,10 +565,9 @@ public static class DataSeeder
|
||||
ItemId = items[1].ItemId,
|
||||
Description = items[1].Name,
|
||||
Qty = 1m,
|
||||
UomId = uom.UomId,
|
||||
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
|
||||
}
|
||||
@@ -570,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,10 +600,9 @@ public static class DataSeeder
|
||||
ItemId = items[0].ItemId,
|
||||
Description = items[0].Name,
|
||||
Qty = 1m,
|
||||
UomId = uom.UomId,
|
||||
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
|
||||
}
|
||||
@@ -662,7 +676,6 @@ public static class DataSeeder
|
||||
Description = postableItem.Name,
|
||||
Qty = 2m,
|
||||
FreeQty = 0m,
|
||||
UomId = uom.UomId,
|
||||
WarehouseId = warehouse.WarehouseId,
|
||||
UnitPrice = 100m,
|
||||
BaseCost = 0m,
|
||||
@@ -706,7 +719,6 @@ public static class DataSeeder
|
||||
Description = shortageItem.Name,
|
||||
Qty = 6m,
|
||||
FreeQty = 0m,
|
||||
UomId = uom.UomId,
|
||||
WarehouseId = secondaryWarehouse.WarehouseId,
|
||||
UnitPrice = 50m,
|
||||
BaseCost = 0m,
|
||||
@@ -751,7 +763,6 @@ public static class DataSeeder
|
||||
Description = postableItem.Name,
|
||||
Qty = 1m,
|
||||
FreeQty = 0m,
|
||||
UomId = uom.UomId,
|
||||
WarehouseId = warehouse.WarehouseId,
|
||||
UnitPrice = 100m,
|
||||
BaseCost = 0m,
|
||||
@@ -794,7 +805,6 @@ public static class DataSeeder
|
||||
Description = postableItem.Name,
|
||||
Qty = 1m,
|
||||
FreeQty = 0m,
|
||||
UomId = uom.UomId,
|
||||
WarehouseId = warehouse.WarehouseId,
|
||||
UnitPrice = 50m,
|
||||
BaseCost = 0m,
|
||||
@@ -834,7 +844,6 @@ public static class DataSeeder
|
||||
Description = shortageItem.Name,
|
||||
Qty = 3m,
|
||||
FreeQty = 0m,
|
||||
UomId = uom.UomId,
|
||||
WarehouseId = secondaryWarehouse.WarehouseId,
|
||||
UnitPrice = 50m,
|
||||
BaseCost = 0m,
|
||||
|
||||
@@ -30,7 +30,6 @@ public class ErpDbContext : DbContext
|
||||
/// <summary>Color/Size/Material dimension names. Unlinked to Item by design (docs/10 C.9).</summary>
|
||||
public DbSet<ItemType> ItemTypes => Set<ItemType>();
|
||||
public DbSet<Uom> Uoms => Set<Uom>();
|
||||
public DbSet<UomConversion> UomConversions => Set<UomConversion>();
|
||||
public DbSet<Item> Items => Set<Item>();
|
||||
public DbSet<ItemReorder> ItemReorders => Set<ItemReorder>();
|
||||
public DbSet<Vendor> Vendors => Set<Vendor>();
|
||||
@@ -39,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>();
|
||||
@@ -65,6 +67,11 @@ public class ErpDbContext : DbContext
|
||||
public DbSet<Grn> Grns => Set<Grn>();
|
||||
public DbSet<GrnLine> GrnLines => Set<GrnLine>();
|
||||
|
||||
// --- Purchase Invoices (vendor invoices consolidating Confirmed GRNs) ---
|
||||
public DbSet<PurchaseInvoice> PurchaseInvoices => Set<PurchaseInvoice>();
|
||||
public DbSet<PurchaseInvoiceLine> PurchaseInvoiceLines => Set<PurchaseInvoiceLine>();
|
||||
public DbSet<PurchaseInvoicePayment> PurchaseInvoicePayments => Set<PurchaseInvoicePayment>();
|
||||
|
||||
// --- Batch / Serial (docs/10 Part C.4) ---
|
||||
public DbSet<Batch> Batches => Set<Batch>();
|
||||
public DbSet<Serial> Serials => Set<Serial>();
|
||||
@@ -88,12 +95,16 @@ 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>();
|
||||
public DbSet<BundleSaleLine> BundleSaleLines => Set<BundleSaleLine>();
|
||||
public DbSet<SalesReturn> SalesReturns => Set<SalesReturn>();
|
||||
public DbSet<SalesReturnLine> SalesReturnLines => Set<SalesReturnLine>();
|
||||
|
||||
// --- Reference data (docs/10 Part C.7) ---
|
||||
public DbSet<ReasonCode> ReasonCodes => Set<ReasonCode>();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+6956
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddItemTypeIsMeasurable : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsMeasurable",
|
||||
table: "item_types",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsMeasurable",
|
||||
table: "item_types");
|
||||
}
|
||||
}
|
||||
}
|
||||
+7188
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user