Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c2b92f3086 | |||
| eb36e0acfa | |||
| cbb610fc8b | |||
| afa9928dcc | |||
| 99aa395fb1 | |||
| cb41d503c4 | |||
| 629f3d1f44 | |||
| b3b6a25ce8 | |||
| f664961fce | |||
| bebec10eb3 | |||
| 160c687db3 | |||
| d413ad4064 | |||
| 9b8c2493ad | |||
| 99b669395e | |||
| 4cc6a546c7 | |||
| 8835ba9500 | |||
| 251b23f4b5 | |||
| 283a56723f | |||
| 9a38cc1131 | |||
| 1c5b05e315 | |||
| efa4ad3770 | |||
| 6528fc8d9d | |||
| ae6a87022d | |||
| ee9fa40edf | |||
| b1b0fe4bac | |||
| b4993b202d | |||
| 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 | |||
| d7ee83828c | |||
| eb7b2691df | |||
| 1a0fb4603e | |||
| 5fc5ef59ac | |||
| d1fe164ea2 | |||
| 02f47bd485 | |||
| 45554ceb9a | |||
| 5d0ea3f035 | |||
| 26cf2a146a | |||
| 38c7545413 | |||
| a414dfc4ea | |||
| 3cccaf4c63 | |||
| b7bd8dca5c |
+1
Submodule .claude/worktrees/agent-a98805dc618e0f67c added at b4993b202d
+7
-9
@@ -30,12 +30,10 @@ yarn-error.log*
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
# ── Migrations ─────────────────────────────────────────────────────────
|
# ── Playwright E2E (Testing/e2e) ──────────────────────────────────────
|
||||||
# Reverted 2026-07-31: excluding new EF Core migrations while
|
Testing/e2e/playwright-report/
|
||||||
# ErpDbContextModelSnapshot.cs stayed tracked meant every `dotnet ef
|
Testing/e2e/test-results/
|
||||||
# migrations add` after the initial 4 silently produced a migration git would
|
Testing/e2e/.auth/
|
||||||
# never see, while the (tracked) snapshot's changes committed normally —
|
Testing/e2e/blob-report/
|
||||||
# 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.
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,13 +27,15 @@ public sealed class AuthController : ControllerBase
|
|||||||
private readonly IAuthRecoveryService _recovery;
|
private readonly IAuthRecoveryService _recovery;
|
||||||
private readonly IAuthAltService _alt;
|
private readonly IAuthAltService _alt;
|
||||||
private readonly IRoleService _roles;
|
private readonly IRoleService _roles;
|
||||||
|
private readonly IAuditService _audit;
|
||||||
|
|
||||||
public AuthController(IAuthUserService users, IAuthRecoveryService recovery, IAuthAltService alt, IRoleService roles)
|
public AuthController(IAuthUserService users, IAuthRecoveryService recovery, IAuthAltService alt, IRoleService roles, IAuditService audit)
|
||||||
{
|
{
|
||||||
_users = users;
|
_users = users;
|
||||||
_recovery = recovery;
|
_recovery = recovery;
|
||||||
_alt = alt;
|
_alt = alt;
|
||||||
_roles = roles;
|
_roles = roles;
|
||||||
|
_audit = audit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -68,6 +70,7 @@ public sealed class AuthController : ControllerBase
|
|||||||
{
|
{
|
||||||
var result = await _users.LoginAsync(request, ct);
|
var result = await _users.LoginAsync(request, ct);
|
||||||
AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
|
AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
|
||||||
|
if (result.Body.User?.UserId is Guid authUserId) await _audit.RecordLoginAsync(authUserId, ClientIp(), ClientUserAgent(), ct);
|
||||||
return Ok(result.Body);
|
return Ok(result.Body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,9 +81,13 @@ public sealed class AuthController : ControllerBase
|
|||||||
{
|
{
|
||||||
var result = await _users.VerifyOtpForLoginAsync(request, ct);
|
var result = await _users.VerifyOtpForLoginAsync(request, ct);
|
||||||
AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
|
AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
|
||||||
|
if (result.Body.User?.UserId is Guid authUserId) await _audit.RecordLoginAsync(authUserId, ClientIp(), ClientUserAgent(), ct);
|
||||||
return Ok(result.Body);
|
return Ok(result.Body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string? ClientIp() => HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||||
|
private string? ClientUserAgent() => Request.Headers.UserAgent.ToString() is { Length: > 0 } ua ? ua : null;
|
||||||
|
|
||||||
[HttpPost("refresh-token")]
|
[HttpPost("refresh-token")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[ProducesResponseType(typeof(AuthSessionResponse), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(AuthSessionResponse), StatusCodes.Status200OK)]
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
|
using ERPCore.Dtos.Config;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Branch configuration endpoints — the singleton toggle for whether this deployment
|
||||||
|
/// requires a branch on Employee/Warehouse records at all.
|
||||||
|
/// <para>
|
||||||
|
/// <b>Authorization:</b> writes are admitted by the inherited ERP door policy only, same
|
||||||
|
/// Phase-1 posture as <see cref="ProductConfigController"/> — any ERP-admitted user can
|
||||||
|
/// flip this flag until per-endpoint RBAC lands.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
[Route("api/v1/branch-config")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "settings")]
|
||||||
|
public sealed class BranchConfigController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IBranchConfigService _config;
|
||||||
|
|
||||||
|
public BranchConfigController(IBranchConfigService config) => _config = config;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(BranchConfigDto), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<BranchConfigDto>> Get(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _config.GetAsync(ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut]
|
||||||
|
[ProducesResponseType(typeof(BranchConfigDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||||
|
public async Task<ActionResult<BranchConfigDto>> Update(
|
||||||
|
[FromBody] UpdateBranchConfigRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var expected = RequireIfMatch();
|
||||||
|
var result = await _config.UpdateAsync(request, expected, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Brands;
|
using ERPCore.Dtos.Brands;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
|
|||||||
|
|
||||||
/// <summary>Brand master endpoints (docs/11-BACKEND-PHASE1.md §2.6).</summary>
|
/// <summary>Brand master endpoints (docs/11-BACKEND-PHASE1.md §2.6).</summary>
|
||||||
[Route("api/v1/brands")]
|
[Route("api/v1/brands")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||||
public sealed class BrandsController : ApiControllerBase
|
public sealed class BrandsController : ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly IBrandService _brands;
|
private readonly IBrandService _brands;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Sales;
|
using ERPCore.Dtos.Sales;
|
||||||
@@ -7,6 +9,7 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
namespace ERPCore.Controllers;
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
[Route("api/v1/bundle-sales")]
|
[Route("api/v1/bundle-sales")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "sales")]
|
||||||
public sealed class BundleSalesController : ApiControllerBase
|
public sealed class BundleSalesController : ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly IBundleSaleService _bundles;
|
private readonly IBundleSaleService _bundles;
|
||||||
@@ -29,14 +32,23 @@ public sealed class BundleSalesController : ApiControllerBase
|
|||||||
return result is null ? NotFound() : Ok(result);
|
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]
|
[HttpGet]
|
||||||
[ProducesResponseType(typeof(PagedResponse<BundleSaleSummaryDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(PagedResponse<BundleSaleSummaryDto>), StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<PagedResponse<BundleSaleSummaryDto>>> List(
|
public async Task<ActionResult<PagedResponse<BundleSaleSummaryDto>>> List(
|
||||||
[FromQuery] PageQuery query,
|
[FromQuery] PageQuery query,
|
||||||
|
[FromQuery] BundleSaleStatus? status,
|
||||||
[FromQuery] int? customerId,
|
[FromQuery] int? customerId,
|
||||||
[FromQuery] int? warehouseId,
|
[FromQuery] int? warehouseId,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
=> Ok(await _bundles.ListAsync(query, customerId, warehouseId, ct));
|
=> Ok(await _bundles.ListAsync(query, status, customerId, warehouseId, ct));
|
||||||
|
|
||||||
[HttpGet("{bundleSaleId:int}")]
|
[HttpGet("{bundleSaleId:int}")]
|
||||||
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Categories;
|
using ERPCore.Dtos.Categories;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
@@ -12,6 +14,7 @@ namespace ERPCore.Controllers;
|
|||||||
/// <c>?tree=true</c> parameter is gone along with the self-nesting model.
|
/// <c>?tree=true</c> parameter is gone along with the self-nesting model.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Route("api/v1/categories")]
|
[Route("api/v1/categories")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||||
public sealed class CategoriesController : ApiControllerBase
|
public sealed class CategoriesController : ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly ICategoryService _categories;
|
private readonly ICategoryService _categories;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Customers;
|
using ERPCore.Dtos.Customers;
|
||||||
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
|
|||||||
|
|
||||||
/// <summary>Customer master endpoints for Phase 1 sales.</summary>
|
/// <summary>Customer master endpoints for Phase 1 sales.</summary>
|
||||||
[Route("api/v1/customers")]
|
[Route("api/v1/customers")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "sales")]
|
||||||
public sealed class CustomersController : ApiControllerBase
|
public sealed class CustomersController : ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly ICustomerService _customers;
|
private readonly ICustomerService _customers;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Dtos.Dashboard;
|
using ERPCore.Dtos.Dashboard;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -6,6 +8,7 @@ namespace ERPCore.Controllers;
|
|||||||
|
|
||||||
/// <summary>Dashboard overview stats — cross-domain counts, not a stored entity.</summary>
|
/// <summary>Dashboard overview stats — cross-domain counts, not a stored entity.</summary>
|
||||||
[Route("api/v1/dashboard")]
|
[Route("api/v1/dashboard")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "dashboard")]
|
||||||
public sealed class DashboardController : ApiControllerBase
|
public sealed class DashboardController : ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly IDashboardService _dashboard;
|
private readonly IDashboardService _dashboard;
|
||||||
@@ -15,6 +18,6 @@ public sealed class DashboardController : ApiControllerBase
|
|||||||
/// <summary>Aggregate counts for stock, GRN, and procurement.</summary>
|
/// <summary>Aggregate counts for stock, GRN, and procurement.</summary>
|
||||||
[HttpGet("stats")]
|
[HttpGet("stats")]
|
||||||
[ProducesResponseType(typeof(DashboardStatsDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(DashboardStatsDto), StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<DashboardStatsDto>> GetStats(CancellationToken ct)
|
public async Task<ActionResult<DashboardStatsDto>> GetStats([FromQuery] int? branchId, CancellationToken ct)
|
||||||
=> Ok(await _dashboard.GetStatsAsync(ct));
|
=> Ok(await _dashboard.GetStatsAsync(branchId, ct));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Sales;
|
using ERPCore.Dtos.Sales;
|
||||||
@@ -12,6 +14,7 @@ namespace ERPCore.Controllers;
|
|||||||
/// the existing sales-slip CRUD surface under a more business-friendly route.
|
/// the existing sales-slip CRUD surface under a more business-friendly route.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Route("api/v1/free-issues")]
|
[Route("api/v1/free-issues")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "sales")]
|
||||||
public sealed class FreeIssuesController : ApiControllerBase
|
public sealed class FreeIssuesController : ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly ISalesSlipService _slips;
|
private readonly ISalesSlipService _slips;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
@@ -14,6 +16,7 @@ namespace ERPCore.Controllers;
|
|||||||
/// only and is never exposed to the frontend.
|
/// only and is never exposed to the frontend.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Route("api/v1/gl")]
|
[Route("api/v1/gl")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "ledgers")]
|
||||||
public sealed class GeneralLedgerController : ApiControllerBase
|
public sealed class GeneralLedgerController : ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly IGeneralLedgerService _gl;
|
private readonly IGeneralLedgerService _gl;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Grn;
|
using ERPCore.Dtos.Grn;
|
||||||
@@ -8,19 +10,23 @@ namespace ERPCore.Controllers;
|
|||||||
|
|
||||||
/// <summary>Goods-receipt endpoints (docs/11 §4).</summary>
|
/// <summary>Goods-receipt endpoints (docs/11 §4).</summary>
|
||||||
[Route("api/v1/grns")]
|
[Route("api/v1/grns")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "receiving")]
|
||||||
public sealed class GrnsController : ApiControllerBase
|
public sealed class GrnsController : ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly IGrnService _grns;
|
private readonly IGrnService _grns;
|
||||||
|
|
||||||
public GrnsController(IGrnService grns) => _grns = grns;
|
public GrnsController(IGrnService grns)
|
||||||
|
{
|
||||||
|
_grns = grns;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>List GRNs, newest first.</summary>
|
/// <summary>List GRNs, newest first.</summary>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[ProducesResponseType(typeof(PagedResponse<GrnSummaryDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(PagedResponse<GrnSummaryDto>), StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<PagedResponse<GrnSummaryDto>>> List(
|
public async Task<ActionResult<PagedResponse<GrnSummaryDto>>> List(
|
||||||
[FromQuery] PageQuery query, [FromQuery] GrnStatus? status, [FromQuery] int? poId,
|
[FromQuery] PageQuery query, [FromQuery] GrnStatus? status, [FromQuery] int? poId,
|
||||||
[FromQuery] int? vendorId, [FromQuery] int? warehouseId, CancellationToken ct)
|
[FromQuery] int? vendorId, [FromQuery] int? warehouseId, [FromQuery] int? branchId, CancellationToken ct)
|
||||||
=> Ok(await _grns.ListAsync(query, status, poId, vendorId, warehouseId, ct));
|
=> Ok(await _grns.ListAsync(query, status, poId, vendorId, warehouseId, branchId, ct));
|
||||||
|
|
||||||
[HttpGet("{grnId:int}")]
|
[HttpGet("{grnId:int}")]
|
||||||
[ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)]
|
||||||
|
|||||||
@@ -61,4 +61,14 @@ public sealed class BranchesController : ApiControllerBase
|
|||||||
await _branches.SetStatusAsync(branchId, request.Status, ct);
|
await _branches.SetStatusAsync(branchId, request.Status, ct);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{branchId:int}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> Delete(int branchId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _branches.DeleteAsync(branchId, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,4 +62,14 @@ public sealed class DepartmentsController : ApiControllerBase
|
|||||||
await _departments.SetStatusAsync(departmentId, request.Status, ct);
|
await _departments.SetStatusAsync(departmentId, request.Status, ct);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{departmentId:int}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> Delete(int departmentId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _departments.DeleteAsync(departmentId, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,4 +61,14 @@ public sealed class DesignationsController : ApiControllerBase
|
|||||||
await _designations.SetStatusAsync(designationId, request.Status, ct);
|
await _designations.SetStatusAsync(designationId, request.Status, ct);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{designationId:int}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> Delete(int designationId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _designations.DeleteAsync(designationId, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,6 @@ public sealed class EmployeesController : ApiControllerBase
|
|||||||
return Ok(result.Value);
|
return Ok(result.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Never a hard delete — Employee is retained forever (docs/12-BACKEND-HRM.md C.2).</summary>
|
|
||||||
[HttpPatch("{employeeId:int}/status")]
|
[HttpPatch("{employeeId:int}/status")]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
@@ -93,6 +92,17 @@ public sealed class EmployeesController : ApiControllerBase
|
|||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Permanently deletes the employee and all dependent HR records. Blocked (409) if payroll history exists.</summary>
|
||||||
|
[HttpDelete("{employeeId:int}")]
|
||||||
|
[ProducesResponseType(typeof(EmployeeDeleteResultDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<EmployeeDeleteResultDto>> Delete(int employeeId, [FromQuery] bool deleteUserAccount, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _employees.DeleteAsync(employeeId, deleteUserAccount, ct);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("{employeeId:int}/link-user")]
|
[HttpPost("{employeeId:int}/link-user")]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
|||||||
@@ -61,4 +61,14 @@ public sealed class EmploymentTypesController : ApiControllerBase
|
|||||||
await _employmentTypes.SetStatusAsync(employmentTypeId, request.Status, ct);
|
await _employmentTypes.SetStatusAsync(employmentTypeId, request.Status, ct);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{employmentTypeId:int}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> Delete(int employmentTypeId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _employmentTypes.DeleteAsync(employmentTypeId, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,4 +61,14 @@ public sealed class HrDocumentTypesController : ApiControllerBase
|
|||||||
await _types.SetStatusAsync(hrDocumentTypeId, request.Status, ct);
|
await _types.SetStatusAsync(hrDocumentTypeId, request.Status, ct);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{hrDocumentTypeId:int}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> Delete(int hrDocumentTypeId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _types.DeleteAsync(hrDocumentTypeId, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,4 +61,14 @@ public sealed class LeaveTypesController : ApiControllerBase
|
|||||||
await _leaveTypes.SetStatusAsync(leaveTypeId, request.Status, ct);
|
await _leaveTypes.SetStatusAsync(leaveTypeId, request.Status, ct);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{leaveTypeId:int}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> Delete(int leaveTypeId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _leaveTypes.DeleteAsync(leaveTypeId, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,4 +61,14 @@ public sealed class SalaryComponentsController : ApiControllerBase
|
|||||||
await _components.SetStatusAsync(salaryComponentId, request.Status, ct);
|
await _components.SetStatusAsync(salaryComponentId, request.Status, ct);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{salaryComponentId:int}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> Delete(int salaryComponentId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _components.DeleteAsync(salaryComponentId, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,4 +61,14 @@ public sealed class WorkShiftsController : ApiControllerBase
|
|||||||
await _shifts.SetStatusAsync(workShiftId, request.Status, ct);
|
await _shifts.SetStatusAsync(workShiftId, request.Status, ct);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{workShiftId:int}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> Delete(int workShiftId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _shifts.DeleteAsync(workShiftId, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.ItemTypes;
|
using ERPCore.Dtos.ItemTypes;
|
||||||
@@ -13,6 +15,7 @@ namespace ERPCore.Controllers;
|
|||||||
/// into the client-generated SKU (docs/10 Part C.9).
|
/// into the client-generated SKU (docs/10 Part C.9).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Route("api/v1/item-types")]
|
[Route("api/v1/item-types")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||||
public sealed class ItemTypesController : ApiControllerBase
|
public sealed class ItemTypesController : ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly IItemTypeService _itemTypes;
|
private readonly IItemTypeService _itemTypes;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Items;
|
using ERPCore.Dtos.Items;
|
||||||
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
|
|||||||
|
|
||||||
/// <summary>Item master endpoints (docs/11-BACKEND-PHASE1.md §2.1–2.2).</summary>
|
/// <summary>Item master endpoints (docs/11-BACKEND-PHASE1.md §2.1–2.2).</summary>
|
||||||
[Route("api/v1/items")]
|
[Route("api/v1/items")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||||
public sealed class ItemsController : ApiControllerBase
|
public sealed class ItemsController : ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly IItemService _items;
|
private readonly IItemService _items;
|
||||||
@@ -27,6 +30,17 @@ public sealed class ItemsController : ApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
=> Ok(await _items.ListAsync(query, status, categoryId, subCategoryId, brandId, trackingMode, ct));
|
=> Ok(await _items.ListAsync(query, status, categoryId, subCategoryId, brandId, trackingMode, ct));
|
||||||
|
|
||||||
|
/// <summary>Barcode/RFID lookup — matches an item's barcode/SKU, or a batch/serial
|
||||||
|
/// barcode/no (optionally scoped to <paramref name="itemId"/> first).</summary>
|
||||||
|
[HttpGet("scan")]
|
||||||
|
[ProducesResponseType(typeof(ItemScanResultDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<ItemScanResultDto>> Scan([FromQuery] string code, [FromQuery] int? itemId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _items.ScanAsync(code, itemId, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Get a single item; returns an <c>ETag</c> for optimistic concurrency.</summary>
|
/// <summary>Get a single item; returns an <c>ETag</c> for optimistic concurrency.</summary>
|
||||||
[HttpGet("{itemId:int}")]
|
[HttpGet("{itemId:int}")]
|
||||||
[ProducesResponseType(typeof(ItemDetailDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(ItemDetailDto), StatusCodes.Status200OK)]
|
||||||
@@ -81,11 +95,4 @@ public sealed class ItemsController : ApiControllerBase
|
|||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<ActionResult<ItemReorderSettingsDto>> UpdateReorder(int itemId, [FromBody] UpdateReorderRequest request, CancellationToken ct)
|
public async Task<ActionResult<ItemReorderSettingsDto>> UpdateReorder(int itemId, [FromBody] UpdateReorderRequest request, CancellationToken ct)
|
||||||
=> Ok(await _items.UpdateReorderAsync(itemId, request, 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));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Dtos.Audit;
|
using ERPCore.Dtos.Audit;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
@@ -10,6 +12,7 @@ namespace ERPCore.Controllers;
|
|||||||
/// Data only — no posting in Phase 1.
|
/// Data only — no posting in Phase 1.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Route("api/v1/journal-entries")]
|
[Route("api/v1/journal-entries")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "ledgers")]
|
||||||
public sealed class JournalEntriesController : ApiControllerBase
|
public sealed class JournalEntriesController : ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly IAuditService _audit;
|
private readonly IAuditService _audit;
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
|
using ERPCore.Dtos.Notifications;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
[Route("api/v1/notification-config")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "settings")]
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using ERPCore.Infra.Auth;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
[Route("api/v1/notifications")]
|
||||||
|
public sealed class NotificationsController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly INotificationService _notifications;
|
||||||
|
private readonly ICurrentUser _currentUser;
|
||||||
|
|
||||||
|
public NotificationsController(INotificationService notifications, ICurrentUser currentUser)
|
||||||
|
{
|
||||||
|
_notifications = notifications;
|
||||||
|
_currentUser = currentUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult> Get([FromQuery] int take = 20, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var feed = await _notifications.GetFeedAsync(_currentUser.AuditUserId, take, ct);
|
||||||
|
return Ok(feed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("mark-all-read")]
|
||||||
|
public async Task<ActionResult> MarkAllRead(CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _notifications.MarkAllReadAsync(_currentUser.AuditUserId, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Dtos.Config;
|
using ERPCore.Dtos.Config;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -16,6 +18,7 @@ namespace ERPCore.Controllers;
|
|||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Route("api/v1/product-config")]
|
[Route("api/v1/product-config")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||||
public sealed class ProductConfigController : ApiControllerBase
|
public sealed class ProductConfigController : ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly IProductConfigService _config;
|
private readonly IProductConfigService _config;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Production;
|
using ERPCore.Dtos.Production;
|
||||||
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
|
|||||||
|
|
||||||
/// <summary>Production run endpoints (docs/30-BACKEND-PHASE2.md §D.2–D.3).</summary>
|
/// <summary>Production run endpoints (docs/30-BACKEND-PHASE2.md §D.2–D.3).</summary>
|
||||||
[Route("api/v1/production-runs")]
|
[Route("api/v1/production-runs")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "production")]
|
||||||
public sealed class ProductionRunsController : ApiControllerBase
|
public sealed class ProductionRunsController : ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly IProductionRunService _runs;
|
private readonly IProductionRunService _runs;
|
||||||
@@ -24,6 +27,19 @@ public sealed class ProductionRunsController : ApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
=> Ok(await _runs.ListAsync(query, status, templateId, warehouseId, 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}")]
|
[HttpGet("{runId:int}")]
|
||||||
[ProducesResponseType(typeof(RunGraphDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(RunGraphDto), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Production;
|
using ERPCore.Dtos.Production;
|
||||||
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
|
|||||||
|
|
||||||
/// <summary>Production template endpoints (docs/30-BACKEND-PHASE2.md §D.1).</summary>
|
/// <summary>Production template endpoints (docs/30-BACKEND-PHASE2.md §D.1).</summary>
|
||||||
[Route("api/v1/production-templates")]
|
[Route("api/v1/production-templates")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "production")]
|
||||||
public sealed class ProductionTemplatesController : ApiControllerBase
|
public sealed class ProductionTemplatesController : ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly IProductionTemplateService _templates;
|
private readonly IProductionTemplateService _templates;
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
|
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")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "procurement")]
|
||||||
|
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));
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Procurement;
|
using ERPCore.Dtos.Procurement;
|
||||||
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
|
|||||||
|
|
||||||
/// <summary>Purchase-order endpoints (docs/11 §3.3).</summary>
|
/// <summary>Purchase-order endpoints (docs/11 §3.3).</summary>
|
||||||
[Route("api/v1/purchase-orders")]
|
[Route("api/v1/purchase-orders")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "procurement")]
|
||||||
public sealed class PurchaseOrdersController : ApiControllerBase
|
public sealed class PurchaseOrdersController : ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly IPurchaseOrderService _pos;
|
private readonly IPurchaseOrderService _pos;
|
||||||
@@ -32,7 +35,7 @@ public sealed class PurchaseOrdersController : ApiControllerBase
|
|||||||
return Ok(result.Value);
|
return Ok(result.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Create a PO — auto-approved on creation in Phase 1 (FR-PROC-04). Totals computed server-side.</summary>
|
/// <summary>Create a PO — non-draft is issued straight into PendingApproval (FR-PROC-04). Totals computed server-side.</summary>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status201Created)]
|
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status201Created)]
|
||||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||||
@@ -57,7 +60,7 @@ public sealed class PurchaseOrdersController : ApiControllerBase
|
|||||||
return Ok(result.Value);
|
return Ok(result.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Submit a Draft PO (Draft → Approved). 409 PO_NOT_EDITABLE if not Draft.</summary>
|
/// <summary>Submit a Draft PO (Draft → PendingApproval). 409 PO_NOT_EDITABLE if not Draft.</summary>
|
||||||
[HttpPost("{poId:int}/submit")]
|
[HttpPost("{poId:int}/submit")]
|
||||||
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
@@ -67,6 +70,7 @@ public sealed class PurchaseOrdersController : ApiControllerBase
|
|||||||
|
|
||||||
/// <summary>Delete a PO — permitted only while Draft (409 PO_NOT_EDITABLE otherwise).</summary>
|
/// <summary>Delete a PO — permitted only while Draft (409 PO_NOT_EDITABLE otherwise).</summary>
|
||||||
[HttpDelete("{poId:int}")]
|
[HttpDelete("{poId:int}")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "procurement" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
@@ -76,13 +80,28 @@ public sealed class PurchaseOrdersController : ApiControllerBase
|
|||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Approve — no-op in Phase 1 (POs auto-approve); transitions PendingApproval→Approved when enabled.</summary>
|
/// <summary>Approve — PendingApproval → Approved. 403 PO_APPROVAL_FORBIDDEN if the caller
|
||||||
|
/// isn't an Admin or is the PO's own creator; 409 PO_NOT_PENDING_APPROVAL otherwise.</summary>
|
||||||
[HttpPost("{poId:int}/approve")]
|
[HttpPost("{poId:int}/approve")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "procurement" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
|
||||||
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
public async Task<ActionResult<PurchaseOrderDto>> Approve(int poId, CancellationToken ct)
|
public async Task<ActionResult<PurchaseOrderDto>> Approve(int poId, CancellationToken ct)
|
||||||
=> Ok(await _pos.ApproveAsync(poId, ct));
|
=> Ok(await _pos.ApproveAsync(poId, ct));
|
||||||
|
|
||||||
|
/// <summary>Reject — PendingApproval → Draft, so the requester can revise and resubmit.
|
||||||
|
/// Same caller restrictions as <see cref="Approve"/>.</summary>
|
||||||
|
[HttpPost("{poId:int}/reject")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "procurement" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
|
||||||
|
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<PurchaseOrderDto>> Reject(int poId, [FromBody] RejectPurchaseOrderRequest request, CancellationToken ct)
|
||||||
|
=> Ok(await _pos.RejectAsync(poId, request.Reason, ct));
|
||||||
|
|
||||||
/// <summary>Cancel — 409 if any goods have been received against the PO.</summary>
|
/// <summary>Cancel — 409 if any goods have been received against the PO.</summary>
|
||||||
[HttpPost("{poId:int}/cancel")]
|
[HttpPost("{poId:int}/cancel")]
|
||||||
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Procurement;
|
using ERPCore.Dtos.Procurement;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
@@ -7,6 +9,7 @@ namespace ERPCore.Controllers;
|
|||||||
|
|
||||||
/// <summary>Purchase-return endpoints (docs/11 §3.4).</summary>
|
/// <summary>Purchase-return endpoints (docs/11 §3.4).</summary>
|
||||||
[Route("api/v1/purchase-returns")]
|
[Route("api/v1/purchase-returns")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "procurement")]
|
||||||
public sealed class PurchaseReturnsController : ApiControllerBase
|
public sealed class PurchaseReturnsController : ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly IPurchaseReturnService _returns;
|
private readonly IPurchaseReturnService _returns;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Procurement;
|
using ERPCore.Dtos.Procurement;
|
||||||
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
|
|||||||
|
|
||||||
/// <summary>Purchase-requisition endpoints (docs/11 §3.1).</summary>
|
/// <summary>Purchase-requisition endpoints (docs/11 §3.1).</summary>
|
||||||
[Route("api/v1/requisitions")]
|
[Route("api/v1/requisitions")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "procurement")]
|
||||||
public sealed class RequisitionsController : ApiControllerBase
|
public sealed class RequisitionsController : ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly IRequisitionService _requisitions;
|
private readonly IRequisitionService _requisitions;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Procurement;
|
using ERPCore.Dtos.Procurement;
|
||||||
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
|
|||||||
|
|
||||||
/// <summary>RFQ & quotation endpoints (docs/11 §3.2).</summary>
|
/// <summary>RFQ & quotation endpoints (docs/11 §3.2).</summary>
|
||||||
[Route("api/v1/rfqs")]
|
[Route("api/v1/rfqs")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "procurement")]
|
||||||
public sealed class RfqsController : ApiControllerBase
|
public sealed class RfqsController : ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly IRfqService _rfqs;
|
private readonly IRfqService _rfqs;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Rbac;
|
using ERPCore.Dtos.Rbac;
|
||||||
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
|
|||||||
|
|
||||||
/// <summary>Role CRUD + permission assignment (mirrors AuthHex's Role; see docs/10 C.9).</summary>
|
/// <summary>Role CRUD + permission assignment (mirrors AuthHex's Role; see docs/10 C.9).</summary>
|
||||||
[Route("api/v1/roles")]
|
[Route("api/v1/roles")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "settings")]
|
||||||
public sealed class RolesController : ApiControllerBase
|
public sealed class RolesController : ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly IRoleService _roles;
|
private readonly IRoleService _roles;
|
||||||
@@ -64,6 +67,7 @@ public sealed class RolesController : ApiControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{roleId:int}")]
|
[HttpDelete("{roleId:int}")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "settings" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
|
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")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "sales")]
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Sales;
|
using ERPCore.Dtos.Sales;
|
||||||
@@ -7,11 +9,17 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
namespace ERPCore.Controllers;
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
[Route("api/v1/sales-invoices")]
|
[Route("api/v1/sales-invoices")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "sales")]
|
||||||
public sealed class SalesInvoicesController : ApiControllerBase
|
public sealed class SalesInvoicesController : ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly ISalesInvoiceService _invoices;
|
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]
|
[HttpGet]
|
||||||
[ProducesResponseType(typeof(PagedResponse<SalesInvoiceSummaryDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(PagedResponse<SalesInvoiceSummaryDto>), StatusCodes.Status200OK)]
|
||||||
@@ -20,8 +28,9 @@ public sealed class SalesInvoicesController : ApiControllerBase
|
|||||||
[FromQuery] SalesInvoiceStatus? status,
|
[FromQuery] SalesInvoiceStatus? status,
|
||||||
[FromQuery] int? customerId,
|
[FromQuery] int? customerId,
|
||||||
[FromQuery] int? warehouseId,
|
[FromQuery] int? warehouseId,
|
||||||
|
[FromQuery] int? branchId,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, ct));
|
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, ct));
|
||||||
|
|
||||||
[HttpGet("{salesInvoiceId:int}")]
|
[HttpGet("{salesInvoiceId:int}")]
|
||||||
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
|
||||||
@@ -70,4 +79,23 @@ public sealed class SalesInvoicesController : ApiControllerBase
|
|||||||
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<SalesInvoiceDto>> Cancel(int salesInvoiceId, CancellationToken ct)
|
public async Task<ActionResult<SalesInvoiceDto>> Cancel(int salesInvoiceId, CancellationToken ct)
|
||||||
=> Ok(await _invoices.CancelAsync(salesInvoiceId, 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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Dtos.Sales;
|
using ERPCore.Dtos.Sales;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -5,6 +7,7 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
namespace ERPCore.Controllers;
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
[Route("api/v1/reports/sales")]
|
[Route("api/v1/reports/sales")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "sales")]
|
||||||
public sealed class SalesReportsController : ApiControllerBase
|
public sealed class SalesReportsController : ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly ISalesReportService _reports;
|
private readonly ISalesReportService _reports;
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
|
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")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "sales")]
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Dtos.Sales;
|
using ERPCore.Dtos.Sales;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -5,6 +7,7 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
namespace ERPCore.Controllers;
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
[Route("api/v1/sales-slips/{salesSlipId:int}/free-issue-suggestions")]
|
[Route("api/v1/sales-slips/{salesSlipId:int}/free-issue-suggestions")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "sales")]
|
||||||
public sealed class SalesSlipPromotionsController : ApiControllerBase
|
public sealed class SalesSlipPromotionsController : ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly ISalesPromotionSuggestionService _suggestions;
|
private readonly ISalesPromotionSuggestionService _suggestions;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Sales;
|
using ERPCore.Dtos.Sales;
|
||||||
@@ -7,6 +9,7 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
namespace ERPCore.Controllers;
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
[Route("api/v1/sales-slips")]
|
[Route("api/v1/sales-slips")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "sales")]
|
||||||
public sealed class SalesSlipsController : ApiControllerBase
|
public sealed class SalesSlipsController : ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly ISalesSlipService _slips;
|
private readonly ISalesSlipService _slips;
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using ERPCore.Dtos.Search;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
[Route("api/v1/search")]
|
||||||
|
public sealed class SearchController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IGlobalSearchService _search;
|
||||||
|
|
||||||
|
public SearchController(IGlobalSearchService search) => _search = search;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<GlobalSearchResultDto>>> Get([FromQuery] string q, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var results = await _search.SearchAsync(q, 6, ct);
|
||||||
|
return Ok(results);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Stock;
|
using ERPCore.Dtos.Stock;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
@@ -7,6 +9,7 @@ namespace ERPCore.Controllers;
|
|||||||
|
|
||||||
/// <summary>Stock-adjustment endpoints (docs/11 §5.5).</summary>
|
/// <summary>Stock-adjustment endpoints (docs/11 §5.5).</summary>
|
||||||
[Route("api/v1/stock-adjustments")]
|
[Route("api/v1/stock-adjustments")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "stock")]
|
||||||
public sealed class StockAdjustmentsController : ApiControllerBase
|
public sealed class StockAdjustmentsController : ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly IAdjustmentService _adjustments;
|
private readonly IAdjustmentService _adjustments;
|
||||||
@@ -17,8 +20,8 @@ public sealed class StockAdjustmentsController : ApiControllerBase
|
|||||||
[HttpGet]
|
[HttpGet]
|
||||||
[ProducesResponseType(typeof(PagedResponse<AdjustmentSummaryDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(PagedResponse<AdjustmentSummaryDto>), StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<PagedResponse<AdjustmentSummaryDto>>> List(
|
public async Task<ActionResult<PagedResponse<AdjustmentSummaryDto>>> List(
|
||||||
[FromQuery] PageQuery query, [FromQuery] int? warehouseId, [FromQuery] int? reasonCodeId, CancellationToken ct)
|
[FromQuery] PageQuery query, [FromQuery] int? warehouseId, [FromQuery] int? branchId, [FromQuery] int? reasonCodeId, CancellationToken ct)
|
||||||
=> Ok(await _adjustments.ListAsync(query, warehouseId, reasonCodeId, ct));
|
=> Ok(await _adjustments.ListAsync(query, warehouseId, branchId, reasonCodeId, ct));
|
||||||
|
|
||||||
/// <summary>Get one adjustment with its lines and the ledger entries it posted.</summary>
|
/// <summary>Get one adjustment with its lines and the ledger entries it posted.</summary>
|
||||||
[HttpGet("{adjustmentId:int}")]
|
[HttpGet("{adjustmentId:int}")]
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Procurement;
|
using ERPCore.Dtos.Procurement;
|
||||||
using ERPCore.Dtos.Stock;
|
using ERPCore.Dtos.Stock;
|
||||||
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
|
|||||||
|
|
||||||
/// <summary>Stock enquiry, ledger, valuation and reorder-alert endpoints (docs/11 §5.1–5.3, §5.7).</summary>
|
/// <summary>Stock enquiry, ledger, valuation and reorder-alert endpoints (docs/11 §5.1–5.3, §5.7).</summary>
|
||||||
[Route("api/v1/stock")]
|
[Route("api/v1/stock")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "stock")]
|
||||||
public sealed class StockController : ApiControllerBase
|
public sealed class StockController : ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly IStockService _stock;
|
private readonly IStockService _stock;
|
||||||
@@ -28,8 +31,8 @@ public sealed class StockController : ApiControllerBase
|
|||||||
[HttpGet("on-hand/list")]
|
[HttpGet("on-hand/list")]
|
||||||
[ProducesResponseType(typeof(PagedResponse<StockOnHandDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(PagedResponse<StockOnHandDto>), StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<PagedResponse<StockOnHandDto>>> OnHandList(
|
public async Task<ActionResult<PagedResponse<StockOnHandDto>>> OnHandList(
|
||||||
[FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] PageQuery query, CancellationToken ct)
|
[FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] int? branchId, [FromQuery] PageQuery query, CancellationToken ct)
|
||||||
=> Ok(await _stock.GetOnHandListAsync(itemId, warehouseId, query, ct));
|
=> Ok(await _stock.GetOnHandListAsync(itemId, warehouseId, branchId, query, ct));
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Immutable movement history. <c>sourceDocType</c>/<c>sourceDocId</c> answer "what did
|
/// Immutable movement history. <c>sourceDocType</c>/<c>sourceDocId</c> answer "what did
|
||||||
@@ -39,11 +42,11 @@ public sealed class StockController : ApiControllerBase
|
|||||||
[HttpGet("ledger")]
|
[HttpGet("ledger")]
|
||||||
[ProducesResponseType(typeof(PagedResponse<StockLedgerRowDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(PagedResponse<StockLedgerRowDto>), StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<PagedResponse<StockLedgerRowDto>>> Ledger(
|
public async Task<ActionResult<PagedResponse<StockLedgerRowDto>>> Ledger(
|
||||||
[FromQuery] int? itemId, [FromQuery] int? warehouseId,
|
[FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] int? branchId,
|
||||||
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to,
|
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to,
|
||||||
[FromQuery] string? sourceDocType, [FromQuery] int? sourceDocId,
|
[FromQuery] string? sourceDocType, [FromQuery] int? sourceDocId,
|
||||||
[FromQuery] PageQuery query, CancellationToken ct)
|
[FromQuery] PageQuery query, CancellationToken ct)
|
||||||
=> Ok(await _stock.GetLedgerAsync(itemId, warehouseId, from, to, sourceDocType, sourceDocId, query, ct));
|
=> Ok(await _stock.GetLedgerAsync(itemId, warehouseId, branchId, from, to, sourceDocType, sourceDocId, query, ct));
|
||||||
|
|
||||||
[HttpGet("valuation")]
|
[HttpGet("valuation")]
|
||||||
[ProducesResponseType(typeof(StockValuationDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(StockValuationDto), StatusCodes.Status200OK)]
|
||||||
@@ -54,8 +57,8 @@ public sealed class StockController : ApiControllerBase
|
|||||||
[HttpGet("reorder-alerts")]
|
[HttpGet("reorder-alerts")]
|
||||||
[ProducesResponseType(typeof(PagedResponse<ReorderAlertDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(PagedResponse<ReorderAlertDto>), StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<PagedResponse<ReorderAlertDto>>> ReorderAlerts(
|
public async Task<ActionResult<PagedResponse<ReorderAlertDto>>> ReorderAlerts(
|
||||||
[FromQuery] int? warehouseId, [FromQuery] PageQuery query, CancellationToken ct)
|
[FromQuery] int? warehouseId, [FromQuery] int? branchId, [FromQuery] PageQuery query, CancellationToken ct)
|
||||||
=> Ok(await _reorder.GetAlertsAsync(warehouseId, query, ct));
|
=> Ok(await _reorder.GetAlertsAsync(warehouseId, branchId, query, ct));
|
||||||
|
|
||||||
/// <summary>Create a draft requisition for an item's suggested reorder quantity.</summary>
|
/// <summary>Create a draft requisition for an item's suggested reorder quantity.</summary>
|
||||||
[HttpPost("reorder-alerts/{itemId:int}/requisition")]
|
[HttpPost("reorder-alerts/{itemId:int}/requisition")]
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Stock;
|
using ERPCore.Dtos.Stock;
|
||||||
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
|
|||||||
|
|
||||||
/// <summary>Stock-count endpoints (docs/11 §5.6).</summary>
|
/// <summary>Stock-count endpoints (docs/11 §5.6).</summary>
|
||||||
[Route("api/v1/stock-counts")]
|
[Route("api/v1/stock-counts")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "stock")]
|
||||||
public sealed class StockCountsController : ApiControllerBase
|
public sealed class StockCountsController : ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly ICountService _counts;
|
private readonly ICountService _counts;
|
||||||
@@ -18,8 +21,8 @@ public sealed class StockCountsController : ApiControllerBase
|
|||||||
[HttpGet]
|
[HttpGet]
|
||||||
[ProducesResponseType(typeof(PagedResponse<CountSummaryDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(PagedResponse<CountSummaryDto>), StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<PagedResponse<CountSummaryDto>>> List(
|
public async Task<ActionResult<PagedResponse<CountSummaryDto>>> List(
|
||||||
[FromQuery] PageQuery query, [FromQuery] CountStatus? status, [FromQuery] int? warehouseId, CancellationToken ct)
|
[FromQuery] PageQuery query, [FromQuery] CountStatus? status, [FromQuery] int? warehouseId, [FromQuery] int? branchId, CancellationToken ct)
|
||||||
=> Ok(await _counts.ListAsync(query, status, warehouseId, ct));
|
=> Ok(await _counts.ListAsync(query, status, warehouseId, branchId, ct));
|
||||||
|
|
||||||
[HttpGet("{countId:int}")]
|
[HttpGet("{countId:int}")]
|
||||||
[ProducesResponseType(typeof(CountDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(CountDto), StatusCodes.Status200OK)]
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Stock;
|
using ERPCore.Dtos.Stock;
|
||||||
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
|
|||||||
|
|
||||||
/// <summary>Stock-transfer endpoints (docs/11 §5.4).</summary>
|
/// <summary>Stock-transfer endpoints (docs/11 §5.4).</summary>
|
||||||
[Route("api/v1/stock-transfers")]
|
[Route("api/v1/stock-transfers")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "stock")]
|
||||||
public sealed class StockTransfersController : ApiControllerBase
|
public sealed class StockTransfersController : ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly ITransferService _transfers;
|
private readonly ITransferService _transfers;
|
||||||
@@ -19,8 +22,8 @@ public sealed class StockTransfersController : ApiControllerBase
|
|||||||
[ProducesResponseType(typeof(PagedResponse<TransferSummaryDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(PagedResponse<TransferSummaryDto>), StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<PagedResponse<TransferSummaryDto>>> List(
|
public async Task<ActionResult<PagedResponse<TransferSummaryDto>>> List(
|
||||||
[FromQuery] PageQuery query, [FromQuery] TransferStatus? status,
|
[FromQuery] PageQuery query, [FromQuery] TransferStatus? status,
|
||||||
[FromQuery] int? srcWarehouseId, [FromQuery] int? destWarehouseId, CancellationToken ct)
|
[FromQuery] int? srcWarehouseId, [FromQuery] int? destWarehouseId, [FromQuery] int? branchId, CancellationToken ct)
|
||||||
=> Ok(await _transfers.ListAsync(query, status, srcWarehouseId, destWarehouseId, ct));
|
=> Ok(await _transfers.ListAsync(query, status, srcWarehouseId, destWarehouseId, branchId, ct));
|
||||||
|
|
||||||
[HttpGet("{transferId:int}")]
|
[HttpGet("{transferId:int}")]
|
||||||
[ProducesResponseType(typeof(TransferDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(TransferDto), StatusCodes.Status200OK)]
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Dtos.Categories;
|
using ERPCore.Dtos.Categories;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -10,6 +12,7 @@ namespace ERPCore.Controllers;
|
|||||||
/// since a subcategory only exists in the context of one.
|
/// since a subcategory only exists in the context of one.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Route("api/v1/subcategories")]
|
[Route("api/v1/subcategories")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||||
public sealed class SubCategoriesController : ApiControllerBase
|
public sealed class SubCategoriesController : ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly ICategoryService _categories;
|
private readonly ICategoryService _categories;
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using ERPCore.Dtos.Support;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
[Route("api/v1/support-tickets")]
|
||||||
|
public sealed class SupportTicketsController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly ISupportTicketService _tickets;
|
||||||
|
private readonly ICurrentUser _currentUser;
|
||||||
|
|
||||||
|
public SupportTicketsController(ISupportTicketService tickets, ICurrentUser currentUser)
|
||||||
|
{
|
||||||
|
_tickets = tickets;
|
||||||
|
_currentUser = currentUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(SupportTicketDto), StatusCodes.Status201Created)]
|
||||||
|
public async Task<ActionResult<SupportTicketDto>> Create([FromBody] CreateSupportTicketRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _tickets.CreateAsync(_currentUser.AuditUserId, request, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return CreatedAtAction(nameof(GetById), new { id = result.Value.TicketId }, result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(IReadOnlyList<SupportTicketSummaryDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<SupportTicketSummaryDto>>> GetList(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var list = await _tickets.GetListAsync(_currentUser.AuditUserId, _currentUser.IsAdmin, ct);
|
||||||
|
return Ok(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:int}")]
|
||||||
|
[ProducesResponseType(typeof(SupportTicketDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<SupportTicketDto>> GetById(int id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _tickets.GetByIdAsync(id, _currentUser.AuditUserId, _currentUser.IsAdmin, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:int}/messages")]
|
||||||
|
[ProducesResponseType(typeof(SupportTicketDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||||
|
public async Task<ActionResult<SupportTicketDto>> AddMessage(int id, [FromBody] AddSupportTicketMessageRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var expected = RequireIfMatch();
|
||||||
|
var result = await _tickets.AddMessageAsync(id, _currentUser.AuditUserId, _currentUser.IsAdmin, request, expected, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:int}/status")]
|
||||||
|
[ProducesResponseType(typeof(SupportTicketDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||||
|
public async Task<ActionResult<SupportTicketDto>> UpdateStatus(int id, [FromBody] UpdateSupportTicketStatusRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var expected = RequireIfMatch();
|
||||||
|
var result = await _tickets.UpdateStatusAsync(id, _currentUser.AuditUserId, _currentUser.IsAdmin, request, expected, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.TaxRates;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Tax rate master endpoints — see <see cref="Domain.Entities.TaxRate"/>.</summary>
|
||||||
|
[Route("api/v1/tax-rates")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||||
|
public sealed class TaxRatesController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly ITaxRateService _taxRates;
|
||||||
|
|
||||||
|
public TaxRatesController(ITaxRateService taxRates) => _taxRates = taxRates;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<TaxRateDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<TaxRateDto>>> List(
|
||||||
|
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||||
|
=> Ok(await _taxRates.ListAsync(query, status, ct));
|
||||||
|
|
||||||
|
[HttpGet("{taxRateId:int}")]
|
||||||
|
[ProducesResponseType(typeof(TaxRateDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<TaxRateDto>> GetById(int taxRateId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _taxRates.GetAsync(taxRateId, ct);
|
||||||
|
if (result is null) return NotFound();
|
||||||
|
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(TaxRateDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<TaxRateDto>> Create([FromBody] CreateTaxRateRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _taxRates.CreateAsync(request, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Created($"/api/v1/tax-rates/{result.Value.TaxRateId}", result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{taxRateId:int}")]
|
||||||
|
[ProducesResponseType(typeof(TaxRateDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||||
|
public async Task<ActionResult<TaxRateDto>> Update(int taxRateId, [FromBody] UpdateTaxRateRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var expected = RequireIfMatch();
|
||||||
|
var result = await _taxRates.UpdateAsync(taxRateId, request, expected, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
|
||||||
|
[HttpPatch("{taxRateId:int}/status")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> SetStatus(int taxRateId, [FromBody] UpdateTaxRateStatusRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _taxRates.SetStatusAsync(taxRateId, request.Status, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Uoms;
|
using ERPCore.Dtos.Uoms;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
@@ -7,6 +9,7 @@ namespace ERPCore.Controllers;
|
|||||||
|
|
||||||
/// <summary>Unit-of-measure endpoints (docs/11-BACKEND-PHASE1.md §2.2).</summary>
|
/// <summary>Unit-of-measure endpoints (docs/11-BACKEND-PHASE1.md §2.2).</summary>
|
||||||
[Route("api/v1/uoms")]
|
[Route("api/v1/uoms")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||||
public sealed class UomsController : ApiControllerBase
|
public sealed class UomsController : ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly IUomService _uoms;
|
private readonly IUomService _uoms;
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Audit;
|
||||||
|
using ERPCore.Dtos.Auth;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Hrm;
|
using ERPCore.Dtos.Hrm;
|
||||||
using ERPCore.Dtos.Users;
|
using ERPCore.Dtos.Users;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
|
using ERPCore.System.Errors;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace ERPCore.Controllers;
|
namespace ERPCore.Controllers;
|
||||||
@@ -11,15 +17,20 @@ namespace ERPCore.Controllers;
|
|||||||
/// account creation orchestrated against AuthHex (see <see cref="IUserManagementService.CreateAsync"/>).
|
/// account creation orchestrated against AuthHex (see <see cref="IUserManagementService.CreateAsync"/>).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Route("api/v1/users")]
|
[Route("api/v1/users")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "settings")]
|
||||||
public sealed class UsersController : ApiControllerBase
|
public sealed class UsersController : ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly IUserManagementService _users;
|
private readonly IUserManagementService _users;
|
||||||
private readonly IEmployeeUserLinkService _links;
|
private readonly IEmployeeUserLinkService _links;
|
||||||
|
private readonly IAuditService _audit;
|
||||||
|
private readonly ICurrentUser _currentUser;
|
||||||
|
|
||||||
public UsersController(IUserManagementService users, IEmployeeUserLinkService links)
|
public UsersController(IUserManagementService users, IEmployeeUserLinkService links, IAuditService audit, ICurrentUser currentUser)
|
||||||
{
|
{
|
||||||
_users = users;
|
_users = users;
|
||||||
_links = links;
|
_links = links;
|
||||||
|
_audit = audit;
|
||||||
|
_currentUser = currentUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Advisory forward-direction lookup: does a Staff record already exist with this email? (docs/12-BACKEND-HRM.md A.5)</summary>
|
/// <summary>Advisory forward-direction lookup: does a Staff record already exist with this email? (docs/12-BACKEND-HRM.md A.5)</summary>
|
||||||
@@ -30,8 +41,8 @@ public sealed class UsersController : ApiControllerBase
|
|||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[ProducesResponseType(typeof(PagedResponse<ManagedUserDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(PagedResponse<ManagedUserDto>), StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<PagedResponse<ManagedUserDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
|
public async Task<ActionResult<PagedResponse<ManagedUserDto>>> List([FromQuery] PageQuery query, [FromQuery] EntityStatus? status, [FromQuery] string? roleCode, CancellationToken ct)
|
||||||
=> Ok(await _users.ListAsync(query, ct));
|
=> Ok(await _users.ListAsync(query, status, roleCode, ct));
|
||||||
|
|
||||||
/// <summary>AuthHex UserType options for the create-user form's select.</summary>
|
/// <summary>AuthHex UserType options for the create-user form's select.</summary>
|
||||||
[HttpGet("user-types")]
|
[HttpGet("user-types")]
|
||||||
@@ -53,7 +64,7 @@ public sealed class UsersController : ApiControllerBase
|
|||||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
public async Task<ActionResult<ManagedUserDto>> Create([FromBody] CreateUserRequest request, CancellationToken ct)
|
public async Task<ActionResult<ManagedUserDto>> Create([FromBody] CreateUserRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await _users.CreateAsync(request, ct);
|
var result = await _users.CreateAsync(request, _currentUser.AuditUserId, ct);
|
||||||
return Created($"/api/v1/users/{result.UserId}", result);
|
return Created($"/api/v1/users/{result.UserId}", result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,4 +73,34 @@ public sealed class UsersController : ApiControllerBase
|
|||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<ActionResult<ManagedUserDto>> UpdateRole(int userId, [FromBody] UpdateUserRoleRequest request, CancellationToken ct)
|
public async Task<ActionResult<ManagedUserDto>> UpdateRole(int userId, [FromBody] UpdateUserRoleRequest request, CancellationToken ct)
|
||||||
=> Ok(await _users.UpdateRoleAsync(userId, request, ct));
|
=> Ok(await _users.UpdateRoleAsync(userId, request, ct));
|
||||||
|
|
||||||
|
/// <summary>Revokes/restores standing inside ERPCore only — see <see cref="IUserManagementService.SetStatusAsync"/>.</summary>
|
||||||
|
[HttpPatch("{userId:int}/status")]
|
||||||
|
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<ManagedUserDto>> UpdateStatus(int userId, [FromBody] UpdateUserStatusRequest request, CancellationToken ct)
|
||||||
|
=> Ok(await _users.SetStatusAsync(userId, request.Status, ct));
|
||||||
|
|
||||||
|
/// <summary>Device/login history ERPCore captures itself — not AuthHex's "currently active sessions"
|
||||||
|
/// (which only ever reports the caller's own). See <see cref="IAuditService.ListLoginDevicesAsync"/>.</summary>
|
||||||
|
[HttpGet("{userId:int}/login-devices")]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<LoginActivityDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<LoginActivityDto>>> ListLoginDevices(int userId, [FromQuery] PageQuery query, CancellationToken ct)
|
||||||
|
=> Ok(await _audit.ListLoginDevicesAsync(userId, query, ct));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Admin-only escape hatch: permanently deletes a stale AuthHex identity (by email/mobile/NIC/
|
||||||
|
/// username) that is blocking new registrations. Does not touch ERPCore's own `users` table.
|
||||||
|
/// See <see cref="IUserManagementService.PurgeAuthHexIdentityAsync"/>.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("purge-authhex-identity")]
|
||||||
|
[ProducesResponseType(typeof(DeleteAuthHexUserResult), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
public async Task<ActionResult<DeleteAuthHexUserResult>> PurgeAuthHexIdentity([FromBody] PurgeAuthHexIdentityRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!_currentUser.IsAdmin)
|
||||||
|
throw new DomainException("FORBIDDEN", "Only an administrator can purge an AuthHex identity.", 403);
|
||||||
|
|
||||||
|
return Ok(await _users.PurgeAuthHexIdentityAsync(request, ct));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Vendors;
|
using ERPCore.Dtos.Vendors;
|
||||||
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
|
|||||||
|
|
||||||
/// <summary>Vendor master endpoints (docs/11-BACKEND-PHASE1.md §2.4).</summary>
|
/// <summary>Vendor master endpoints (docs/11-BACKEND-PHASE1.md §2.4).</summary>
|
||||||
[Route("api/v1/vendors")]
|
[Route("api/v1/vendors")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "vendors")]
|
||||||
public sealed class VendorsController : ApiControllerBase
|
public sealed class VendorsController : ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly IVendorService _vendors;
|
private readonly IVendorService _vendors;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Warehouses;
|
using ERPCore.Dtos.Warehouses;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
@@ -7,6 +9,7 @@ namespace ERPCore.Controllers;
|
|||||||
|
|
||||||
/// <summary>Warehouse & bin endpoints (docs/11-BACKEND-PHASE1.md §2.5).</summary>
|
/// <summary>Warehouse & bin endpoints (docs/11-BACKEND-PHASE1.md §2.5).</summary>
|
||||||
[Route("api/v1/warehouses")]
|
[Route("api/v1/warehouses")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "warehouses")]
|
||||||
public sealed class WarehousesController : ApiControllerBase
|
public sealed class WarehousesController : ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly IWarehouseService _warehouses;
|
private readonly IWarehouseService _warehouses;
|
||||||
@@ -15,8 +18,9 @@ public sealed class WarehousesController : ApiControllerBase
|
|||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[ProducesResponseType(typeof(PagedResponse<WarehouseDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(PagedResponse<WarehouseDto>), StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<PagedResponse<WarehouseDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
|
public async Task<ActionResult<PagedResponse<WarehouseDto>>> List(
|
||||||
=> Ok(await _warehouses.ListAsync(query, ct));
|
[FromQuery] PageQuery query, [FromQuery] int? branchId, CancellationToken ct)
|
||||||
|
=> Ok(await _warehouses.ListAsync(query, branchId, ct));
|
||||||
|
|
||||||
[HttpGet("{warehouseId:int}")]
|
[HttpGet("{warehouseId:int}")]
|
||||||
[ProducesResponseType(typeof(WarehouseDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(WarehouseDto), StatusCodes.Status200OK)]
|
||||||
@@ -36,6 +40,24 @@ public sealed class WarehousesController : ApiControllerBase
|
|||||||
return Created($"/api/v1/warehouses/{dto.WarehouseId}", dto);
|
return Created($"/api/v1/warehouses/{dto.WarehouseId}", dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPut("{warehouseId:int}")]
|
||||||
|
[ProducesResponseType(typeof(WarehouseDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<WarehouseDto>> Update(int warehouseId, [FromBody] UpdateWarehouseRequest request, CancellationToken ct)
|
||||||
|
=> Ok(await _warehouses.UpdateAsync(warehouseId, request, ct));
|
||||||
|
|
||||||
|
[HttpDelete("{warehouseId:int}")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "warehouses" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> Delete(int warehouseId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _warehouses.DeleteAsync(warehouseId, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("{warehouseId:int}/bins")]
|
[HttpGet("{warehouseId:int}/bins")]
|
||||||
[ProducesResponseType(typeof(IReadOnlyList<BinDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(IReadOnlyList<BinDto>), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ public static class DocumentTypes
|
|||||||
public const string Rfq = "RFQ";
|
public const string Rfq = "RFQ";
|
||||||
public const string PurchaseOrder = "PO";
|
public const string PurchaseOrder = "PO";
|
||||||
public const string Grn = "GRN";
|
public const string Grn = "GRN";
|
||||||
|
public const string PurchaseInvoice = "PINV";
|
||||||
public const string Transfer = "TRF";
|
public const string Transfer = "TRF";
|
||||||
public const string Adjustment = "ADJ";
|
public const string Adjustment = "ADJ";
|
||||||
public const string Count = "CNT";
|
public const string Count = "CNT";
|
||||||
@@ -20,4 +21,9 @@ public static class DocumentTypes
|
|||||||
public const string SalesInvoice = "SI";
|
public const string SalesInvoice = "SI";
|
||||||
public const string SalesSlip = "SSL";
|
public const string SalesSlip = "SSL";
|
||||||
public const string BundleSale = "BND";
|
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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,4 +13,7 @@ public class Batch
|
|||||||
|
|
||||||
public string BatchNo { get; set; } = string.Empty;
|
public string BatchNo { get; set; } = string.Empty;
|
||||||
public DateOnly? ExpiryDate { get; set; }
|
public DateOnly? ExpiryDate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Optional scan code for barcode/RFID lookup (unique when set).</summary>
|
||||||
|
public string? Barcode { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,19 @@ using ERPCore.Domain.Enums;
|
|||||||
namespace ERPCore.Domain.Entities;
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Branch/location master (FR-HR-MD-01) — multi-branch readiness. Referenced
|
/// Branch/location master (FR-HR-MD-01) — multi-branch readiness. Required by
|
||||||
/// optionally by <see cref="Employee.BranchId"/> and <see cref="PayrollRun.BranchId"/>.
|
/// <see cref="Employee.BranchId"/>, optionally referenced by <see cref="PayrollRun.BranchId"/>.
|
||||||
/// Deactivated, not deleted, when referenced. Model: docs/12-BACKEND-HRM.md Part C.1.
|
/// Deactivated, not deleted, when referenced. Model: docs/12-BACKEND-HRM.md Part C.1.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class Branch
|
public class Branch
|
||||||
{
|
{
|
||||||
public int BranchId { get; set; }
|
public int BranchId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Server-generated on create ("BR-001", "BR-002", ...) — never accepted from the client.</summary>
|
||||||
public string Code { get; set; } = string.Empty;
|
public string Code { get; set; } = string.Empty;
|
||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
public string? Address { get; set; }
|
public string? Address { get; set; }
|
||||||
|
public string? PhoneNumber { get; set; }
|
||||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||||
|
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Branch configuration — a singleton row (single-tenant, docs/00-CORE §1) gating whether
|
||||||
|
/// this deployment uses branches at all. When <see cref="BranchingEnabled"/> is off, the
|
||||||
|
/// Employee/Warehouse create forms stop asking for a branch and every new record is
|
||||||
|
/// silently assigned to the deployment's one default branch instead — schema-wise
|
||||||
|
/// <c>Employee.BranchId</c>/<c>Warehouse.BranchId</c> stay <c>NOT NULL</c> either way, only
|
||||||
|
/// the UI/UX requirement toggles. See <see cref="Services.BranchConfigService.ResolveBranchIdAsync"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class BranchConfig
|
||||||
|
{
|
||||||
|
/// <summary>Always 1 - the singleton row's id.</summary>
|
||||||
|
public const int SingletonId = 1;
|
||||||
|
|
||||||
|
public int ConfigId { get; set; }
|
||||||
|
|
||||||
|
public bool BranchingEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Independent sub-toggle: when the global <see cref="BranchingEnabled"/> is on
|
||||||
|
/// but this is off, Warehouses stop asking for/showing a branch while Employees etc.
|
||||||
|
/// still do. Meaningless when <see cref="BranchingEnabled"/> is off (everything is
|
||||||
|
/// single-branch already).</summary>
|
||||||
|
public bool WarehouseBranchingEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
public int? UpdatedBy { get; set; }
|
||||||
|
public User? UpdatedByUser { get; set; }
|
||||||
|
|
||||||
|
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
|
||||||
|
public uint RowVersion { get; set; }
|
||||||
|
}
|
||||||
@@ -29,5 +29,11 @@ public class BundleSale
|
|||||||
public DateTime? UpdatedAt { get; set; }
|
public DateTime? UpdatedAt { get; set; }
|
||||||
public int ConcurrencyStamp { 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>();
|
public ICollection<BundleSaleLine> Lines { get; set; } = new List<BundleSaleLine>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ public class BundleSaleLine
|
|||||||
public Item? Item { get; set; }
|
public Item? Item { get; set; }
|
||||||
public string Description { get; set; } = string.Empty;
|
public string Description { get; set; } = string.Empty;
|
||||||
public decimal Qty { get; set; }
|
public decimal Qty { get; set; }
|
||||||
public int UomId { get; set; }
|
|
||||||
public Uom? Uom { get; set; }
|
|
||||||
public int WarehouseId { get; set; }
|
public int WarehouseId { get; set; }
|
||||||
public Warehouse? Warehouse { get; set; }
|
public Warehouse? Warehouse { get; set; }
|
||||||
public decimal UnitPrice { get; set; }
|
public decimal UnitPrice { get; set; }
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ public class BundleSaleTemplateLine
|
|||||||
|
|
||||||
public int ItemId { get; set; }
|
public int ItemId { get; set; }
|
||||||
public Item? Item { get; set; }
|
public Item? Item { get; set; }
|
||||||
public int UomId { get; set; }
|
|
||||||
public Uom? Uom { get; set; }
|
|
||||||
public int WarehouseId { get; set; }
|
public int WarehouseId { get; set; }
|
||||||
public Warehouse? Warehouse { get; set; }
|
public Warehouse? Warehouse { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ public class Customer
|
|||||||
public int? DefaultWarehouseId { get; set; }
|
public int? DefaultWarehouseId { get; set; }
|
||||||
public Warehouse? DefaultWarehouse { get; set; }
|
public Warehouse? DefaultWarehouse { get; set; }
|
||||||
|
|
||||||
|
public int BranchId { get; set; }
|
||||||
|
public Branch? Branch { get; set; }
|
||||||
|
|
||||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
public DateTime? UpdatedAt { get; set; }
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ namespace ERPCore.Domain.Entities;
|
|||||||
/// Staff record (FR-HR-MD-02) — distinct from <see cref="User"/> (the system login
|
/// Staff record (FR-HR-MD-02) — distinct from <see cref="User"/> (the system login
|
||||||
/// account): not every employee has a login, and not every login belongs to an
|
/// account): not every employee has a login, and not every login belongs to an
|
||||||
/// employee. <see cref="UserId"/> is the optional, explicit, human-confirmed link
|
/// employee. <see cref="UserId"/> is the optional, explicit, human-confirmed link
|
||||||
/// between the two (docs/12-BACKEND-HRM.md A.5/C.2, Part B.3.2). Never hard-deleted —
|
/// between the two (docs/12-BACKEND-HRM.md A.5/C.2, Part B.3.2). Normal separation is
|
||||||
/// separation is recorded via <see cref="Status"/> + <see cref="LastWorkingDate"/>.
|
/// recorded via <see cref="Status"/> + <see cref="LastWorkingDate"/> rather than deletion;
|
||||||
|
/// an explicit hard delete is also available (<see cref="Services.Interfaces.IEmployeeService.DeleteAsync"/>)
|
||||||
|
/// and cascades to every dependent HR record, blocked only if payroll history exists.
|
||||||
/// <see cref="EmployeeCode"/> is user-entered (not <see cref="NumberSequence"/>-issued):
|
/// <see cref="EmployeeCode"/> is user-entered (not <see cref="NumberSequence"/>-issued):
|
||||||
/// HR departments keep their own legacy numbering scheme, and NumberSequence's
|
/// HR departments keep their own legacy numbering scheme, and NumberSequence's
|
||||||
/// year-scoping is the wrong shape for an identifier that must never look "reset".
|
/// year-scoping is the wrong shape for an identifier that must never look "reset".
|
||||||
@@ -50,7 +52,7 @@ public class Employee
|
|||||||
public Designation? Designation { get; set; }
|
public Designation? Designation { get; set; }
|
||||||
public int EmploymentTypeId { get; set; }
|
public int EmploymentTypeId { get; set; }
|
||||||
public EmploymentType? EmploymentType { get; set; }
|
public EmploymentType? EmploymentType { get; set; }
|
||||||
public int? BranchId { get; set; }
|
public int BranchId { get; set; }
|
||||||
public Branch? Branch { get; set; }
|
public Branch? Branch { get; set; }
|
||||||
public int WorkShiftId { get; set; }
|
public int WorkShiftId { get; set; }
|
||||||
public WorkShift? WorkShift { get; set; }
|
public WorkShift? WorkShift { get; set; }
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ public class Grn
|
|||||||
public int WarehouseId { get; set; }
|
public int WarehouseId { get; set; }
|
||||||
public Warehouse? Warehouse { get; set; }
|
public Warehouse? Warehouse { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Owning branch, set explicitly at creation — warehouses are shared across
|
||||||
|
/// branches, so branch scope can no longer be derived from <see cref="Warehouse"/>.</summary>
|
||||||
|
public int BranchId { get; set; }
|
||||||
|
public Branch? Branch { get; set; }
|
||||||
|
|
||||||
public GrnStatus Status { get; set; } = GrnStatus.Draft;
|
public GrnStatus Status { get; set; } = GrnStatus.Draft;
|
||||||
|
|
||||||
public int CreatedBy { get; set; }
|
public int CreatedBy { get; set; }
|
||||||
@@ -30,6 +35,16 @@ public class Grn
|
|||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
public DateTime? PostedAt { 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>
|
/// <summary>PostgreSQL xmin-backed optimistic concurrency token.</summary>
|
||||||
public uint RowVersion { get; set; }
|
public uint RowVersion { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -25,9 +25,6 @@ public class GrnLine
|
|||||||
public int ItemId { get; set; }
|
public int ItemId { get; set; }
|
||||||
public Item? Item { get; set; }
|
public Item? Item { get; set; }
|
||||||
|
|
||||||
public int UomId { get; set; }
|
|
||||||
public Uom? Uom { get; set; }
|
|
||||||
|
|
||||||
public int? BinId { get; set; }
|
public int? BinId { get; set; }
|
||||||
public Bin? Bin { get; set; }
|
public Bin? Bin { get; set; }
|
||||||
|
|
||||||
@@ -61,4 +58,11 @@ public class GrnLine
|
|||||||
public decimal LineTotal { get; set; }
|
public decimal LineTotal { get; set; }
|
||||||
|
|
||||||
public HoldStatus HoldStatus { get; set; } = HoldStatus.Available;
|
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; }
|
||||||
|
}
|
||||||
@@ -14,6 +14,9 @@ public class Item
|
|||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
public string? Description { get; set; }
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Optional scan code for barcode/RFID lookup (unique when set).</summary>
|
||||||
|
public string? Barcode { get; set; }
|
||||||
|
|
||||||
public int CategoryId { get; set; }
|
public int CategoryId { get; set; }
|
||||||
public Category? Category { get; set; }
|
public Category? Category { get; set; }
|
||||||
|
|
||||||
@@ -24,6 +27,12 @@ public class Item
|
|||||||
public int? BrandId { get; set; }
|
public int? BrandId { get; set; }
|
||||||
public Brand? Brand { 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 int BaseUomId { get; set; }
|
||||||
public Uom? BaseUom { get; set; }
|
public Uom? BaseUom { get; set; }
|
||||||
|
|
||||||
@@ -32,14 +41,53 @@ public class Item
|
|||||||
|
|
||||||
public StockNature StockNature { get; set; }
|
public StockNature StockNature { get; set; }
|
||||||
public TrackingMode TrackingMode { get; set; }
|
public TrackingMode TrackingMode { get; set; }
|
||||||
public string? TaxClass { 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; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Optional fixed selling price used by Sales only. <c>null</c> means "use stock value"
|
/// The tax rate a sales/purchase line for this item defaults to (see
|
||||||
/// (the item is sold at its FIFO stock cost at sale time); a value is the fixed sale price.
|
/// <see cref="TaxRate"/>) — replaces the old free-text "tax class" label that nothing
|
||||||
/// Never enters costing/GRN/FIFO (docs/10 Part C.1, C.9).
|
/// ever read. Null means no default; a line must be given an explicit tax percentage.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public decimal? SalePrice { get; set; }
|
public int? TaxRateId { get; set; }
|
||||||
|
public TaxRate? TaxRate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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? 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;
|
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||||
|
|
||||||
@@ -50,5 +98,4 @@ public class Item
|
|||||||
public uint RowVersion { get; set; }
|
public uint RowVersion { get; set; }
|
||||||
|
|
||||||
public ICollection<ItemReorder> ReorderSettings { get; set; } = new List<ItemReorder>();
|
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.
|
/// Material.
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Deliberately unlinked.</b> Nothing references this entity and it references
|
/// <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
|
/// nothing: there is no value table and no join to <see cref="Item"/>. The chosen
|
||||||
/// to feed the frontend's item-builder dropdown via <c>GET /item-types</c>. The chosen
|
|
||||||
/// values (Red, S, M) are encoded by the client into the generated SKU
|
/// 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
|
/// (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.
|
/// is the record of what was built. See the accepted trade-off in docs/10 Part C.9.
|
||||||
/// </para>
|
/// </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),
|
/// Not to be confused with <see cref="Enums.StockNature"/> (Stocked/NonStocked/Service),
|
||||||
/// which is what the old <c>ItemType</c> enum became.
|
/// which is what the old <c>ItemType</c> enum became.
|
||||||
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
||||||
@@ -21,6 +24,24 @@ public class ItemType
|
|||||||
{
|
{
|
||||||
public int ItemTypeId { get; set; }
|
public int ItemTypeId { get; set; }
|
||||||
public string Name { get; set; } = string.Empty;
|
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 EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||||
|
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One row per successful sign-in, captured directly by ERPCore (not AuthHex) so an
|
||||||
|
/// admin can see a user's device/login history — AuthHex's own session listing only
|
||||||
|
/// ever reports the currently-authenticated caller's own sessions, with no admin-scoped
|
||||||
|
/// call to inspect anyone else's. This is a history of past logins, not a live "currently
|
||||||
|
/// active sessions" view (that would need AuthHex to expose one).
|
||||||
|
/// </summary>
|
||||||
|
public class LoginActivity
|
||||||
|
{
|
||||||
|
public int LoginActivityId { get; set; }
|
||||||
|
public int UserId { get; set; }
|
||||||
|
public User? User { get; set; }
|
||||||
|
|
||||||
|
public string? IpAddress { get; set; }
|
||||||
|
public string? UserAgent { get; set; }
|
||||||
|
/// <summary>Best-effort "Browser on OS" label parsed from <see cref="UserAgent"/> for display.</summary>
|
||||||
|
public string? DeviceLabel { get; set; }
|
||||||
|
|
||||||
|
public DateTime LoginAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A single raised notification event (e.g. "GRN Confirmed"). Broadcast to
|
||||||
|
/// active users via <see cref="NotificationRecipient"/> rows, gated by
|
||||||
|
/// whether the module's InApp channel is enabled in <see cref="NotificationConfig"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class Notification
|
||||||
|
{
|
||||||
|
public int NotificationId { get; set; }
|
||||||
|
|
||||||
|
public string ModuleKey { get; set; } = string.Empty;
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string? RelatedEntityType { get; set; }
|
||||||
|
public int? RelatedEntityId { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
public ICollection<NotificationRecipient> Recipients { get; set; } = new List<NotificationRecipient>();
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-user read state for a broadcast <see cref="Notification"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class NotificationRecipient
|
||||||
|
{
|
||||||
|
public int NotificationRecipientId { get; set; }
|
||||||
|
|
||||||
|
public int NotificationId { get; set; }
|
||||||
|
public Notification? Notification { get; set; }
|
||||||
|
|
||||||
|
public int UserId { get; set; }
|
||||||
|
public User? User { get; set; }
|
||||||
|
|
||||||
|
public bool IsRead { get; set; }
|
||||||
|
public DateTime? ReadAt { get; set; }
|
||||||
|
}
|
||||||
@@ -10,6 +10,10 @@ public class NumberSequence
|
|||||||
{
|
{
|
||||||
public int SequenceId { get; set; }
|
public int SequenceId { get; set; }
|
||||||
public string DocType { get; set; } = string.Empty;
|
public string DocType { get; set; } = string.Empty;
|
||||||
|
/// <summary>Per-branch counter key (docs/BRANCH_ISOLATION_PLAN.md §5) — backfilled to the
|
||||||
|
/// deployment's default branch for rows that predate per-branch numbering.</summary>
|
||||||
|
public int BranchId { get; set; }
|
||||||
|
public Branch? Branch { get; set; }
|
||||||
public int Year { get; set; }
|
public int Year { get; set; }
|
||||||
public int LastNumber { get; set; }
|
public int LastNumber { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,9 +15,6 @@ public class PoLine
|
|||||||
public int ItemId { get; set; }
|
public int ItemId { get; set; }
|
||||||
public Item? Item { get; set; }
|
public Item? Item { get; set; }
|
||||||
|
|
||||||
public int UomId { get; set; }
|
|
||||||
public Uom? Uom { get; set; }
|
|
||||||
|
|
||||||
public int WarehouseId { get; set; }
|
public int WarehouseId { get; set; }
|
||||||
public Warehouse? Warehouse { get; set; }
|
public Warehouse? Warehouse { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,24 @@
|
|||||||
namespace ERPCore.Domain.Entities;
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
/// <summary>
|
/// <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.
|
/// gating optional product master-data features.
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <see cref="SubcategoriesEnabled"/> and <see cref="BrandsEnabled"/> are enforced
|
/// <see cref="SubcategoriesEnabled"/> and <see cref="BrandsEnabled"/> are enforced
|
||||||
/// server-side: an Item write carrying a subcategory/brand while the flag is off is
|
/// 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
|
/// rejected with <c>CONFIG_DISABLED</c>. <see cref="ItemTypesEnabled"/> is advisory only
|
||||||
/// <b>advisory only</b> — items carry no item-type reference (see <see cref="ItemType"/>),
|
/// - items carry no item-type reference (see <see cref="ItemType"/>), so there is nothing
|
||||||
/// so there is nothing on a write to reject; the frontend honours it by hiding the
|
/// on a write to reject; the frontend honours it by hiding the variant builder.
|
||||||
/// builder's type section. Reads are never gated, so existing data stays visible after a
|
/// <see cref="SellingPricesEnabled"/>, <see cref="SerialNumbersEnabled"/>,
|
||||||
/// flag is switched off.
|
/// <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>
|
/// </para>
|
||||||
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ProductConfig
|
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 const int SingletonId = 1;
|
||||||
|
|
||||||
public int ConfigId { get; set; }
|
public int ConfigId { get; set; }
|
||||||
@@ -24,6 +26,10 @@ public class ProductConfig
|
|||||||
public bool SubcategoriesEnabled { get; set; } = true;
|
public bool SubcategoriesEnabled { get; set; } = true;
|
||||||
public bool BrandsEnabled { get; set; } = true;
|
public bool BrandsEnabled { get; set; } = true;
|
||||||
public bool ItemTypesEnabled { 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; }
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
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 int BranchId { get; set; }
|
||||||
|
public Branch? Branch { 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; }
|
||||||
|
}
|
||||||
@@ -5,9 +5,10 @@ namespace ERPCore.Domain.Entities;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Purchase order header (FR-PROC-03..06). Mutable aggregate with a
|
/// Purchase order header (FR-PROC-03..06). Mutable aggregate with a
|
||||||
/// <see cref="RowVersion"/> ETag token; editable while open (FR-PROC-05).
|
/// <see cref="RowVersion"/> ETag token; editable while open (FR-PROC-05).
|
||||||
/// Phase 1 auto-approves on creation; <see cref="ApprovalRequired"/> is retained
|
/// A submitted/issued PO goes to <see cref="PurchaseOrderStatus.PendingApproval"/> and
|
||||||
/// for the future approval workflow. Totals are computed server-side from lines
|
/// needs a separate Approve action (an Admin who isn't <see cref="CreatedBy"/>) before it
|
||||||
/// (not stored). Model: docs/10 Part C.2.
|
/// reaches <see cref="PurchaseOrderStatus.Approved"/>. Totals are computed server-side
|
||||||
|
/// from lines (not stored). Model: docs/10 Part C.2.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class PurchaseOrder
|
public class PurchaseOrder
|
||||||
{
|
{
|
||||||
@@ -17,15 +18,25 @@ public class PurchaseOrder
|
|||||||
public int VendorId { get; set; }
|
public int VendorId { get; set; }
|
||||||
public Vendor? Vendor { get; set; }
|
public Vendor? Vendor { get; set; }
|
||||||
|
|
||||||
|
public int BranchId { get; set; }
|
||||||
|
public Branch? Branch { get; set; }
|
||||||
|
|
||||||
public int? RequisitionId { get; set; }
|
public int? RequisitionId { get; set; }
|
||||||
public Requisition? Requisition { get; set; }
|
public Requisition? Requisition { get; set; }
|
||||||
|
|
||||||
public PurchaseOrderStatus Status { get; set; } = PurchaseOrderStatus.Draft;
|
public PurchaseOrderStatus Status { get; set; } = PurchaseOrderStatus.Draft;
|
||||||
|
/// <summary>True once the PO has been (or needs to be) routed through approval —
|
||||||
|
/// i.e. it is not a still-editable Draft.</summary>
|
||||||
public bool ApprovalRequired { get; set; }
|
public bool ApprovalRequired { get; set; }
|
||||||
|
|
||||||
public int CreatedBy { get; set; }
|
public int CreatedBy { get; set; }
|
||||||
public User? Creator { get; set; }
|
public User? Creator { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Who approved this PO — set only when <see cref="Status"/> reaches Approved.</summary>
|
||||||
|
public int? ApprovedBy { get; set; }
|
||||||
|
public User? Approver { get; set; }
|
||||||
|
public DateTime? ApprovedAt { get; set; }
|
||||||
|
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
public DateTime? UpdatedAt { get; set; }
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -28,5 +28,12 @@ public class PurchaseReturn
|
|||||||
|
|
||||||
public DateTime CreatedAt { 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 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>();
|
public ICollection<PurchaseReturnLine> Lines { get; set; } = new List<PurchaseReturnLine>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ public class Requisition
|
|||||||
public int RequestedBy { get; set; }
|
public int RequestedBy { get; set; }
|
||||||
public User? Requester { get; set; }
|
public User? Requester { get; set; }
|
||||||
|
|
||||||
|
public int BranchId { get; set; }
|
||||||
|
public Branch? Branch { get; set; }
|
||||||
|
|
||||||
public RequisitionStatus Status { get; set; } = RequisitionStatus.Draft;
|
public RequisitionStatus Status { get; set; } = RequisitionStatus.Draft;
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ public class Rfq
|
|||||||
public int RequisitionId { get; set; }
|
public int RequisitionId { get; set; }
|
||||||
public Requisition? Requisition { get; set; }
|
public Requisition? Requisition { get; set; }
|
||||||
|
|
||||||
|
public int BranchId { get; set; }
|
||||||
|
public Branch? Branch { get; set; }
|
||||||
|
|
||||||
public RfqStatus Status { get; set; } = RfqStatus.Open;
|
public RfqStatus Status { get; set; } = RfqStatus.Open;
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
namespace ERPCore.Domain.Entities;
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
/// <summary>Join row granting a <see cref="Role"/> visibility of a <see cref="Permission"/> (nav node).</summary>
|
/// <summary>Join row granting a <see cref="Role"/> visibility of a <see cref="Permission"/> (nav node).</summary>
|
||||||
@@ -6,6 +8,13 @@ public class RolePermission
|
|||||||
public int RoleId { get; set; }
|
public int RoleId { get; set; }
|
||||||
public int PermissionId { get; set; }
|
public int PermissionId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Which mutating actions the role may perform within this permission's module
|
||||||
|
/// (see <see cref="RolePermissionAction"/>); defaults to <see cref="RolePermissionAction.All"/>
|
||||||
|
/// so existing grants keep today's behavior until an admin narrows them.
|
||||||
|
/// </summary>
|
||||||
|
public RolePermissionAction Actions { get; set; } = RolePermissionAction.All;
|
||||||
|
|
||||||
public Role? Role { get; set; }
|
public Role? Role { get; set; }
|
||||||
public Permission? Permission { get; set; }
|
public Permission? Permission { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,10 +31,14 @@ public class RunStageInput
|
|||||||
public int? FromRunOutputId { get; set; }
|
public int? FromRunOutputId { get; set; }
|
||||||
public RunStageOutput? FromRunOutput { get; set; }
|
public RunStageOutput? FromRunOutput { get; set; }
|
||||||
|
|
||||||
public int UomId { get; set; }
|
/// <summary>Copied from the template input: what <see cref="PlannedQty"/> is expressed in.</summary>
|
||||||
public Uom? Uom { get; set; }
|
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; }
|
public decimal PlannedQty { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -23,10 +23,17 @@ public class RunStageOutput
|
|||||||
|
|
||||||
public string Name { get; set; } = string.Empty;
|
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; }
|
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; }
|
public decimal PlannedQty { get; set; }
|
||||||
|
|
||||||
/// <summary>Recorded at complete. A re-complete after a rework <b>overwrites</b> this, never adds to it.</summary>
|
/// <summary>Recorded at complete. A re-complete after a rework <b>overwrites</b> this, never adds to it.</summary>
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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 <see cref="SalesSlip.PaidAmount"/> across the day's slips (bundle sales have no
|
||||||
|
/// partial-payment field and are always counted as fully paid) — what actually gets debited
|
||||||
|
/// to Cash on close. <see cref="BalanceTotal"/> is the rest (<c>GrandTotal - PaidTotal</c>),
|
||||||
|
/// debited to Accounts Receivable instead. Before partial payments existed these were always
|
||||||
|
/// equal to GrandTotal/0 respectively (every sale was assumed fully cash).
|
||||||
|
/// </summary>
|
||||||
|
public decimal PaidTotal { get; set; }
|
||||||
|
public decimal BalanceTotal { 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>();
|
||||||
|
}
|
||||||
@@ -17,6 +17,11 @@ public class SalesInvoice
|
|||||||
public int WarehouseId { get; set; }
|
public int WarehouseId { get; set; }
|
||||||
public Warehouse? Warehouse { get; set; }
|
public Warehouse? Warehouse { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Owning branch, set explicitly at creation — warehouses are shared across
|
||||||
|
/// branches, so branch scope can no longer be derived from <see cref="Warehouse"/>.</summary>
|
||||||
|
public int BranchId { get; set; }
|
||||||
|
public Branch? Branch { get; set; }
|
||||||
|
|
||||||
public SalesInvoiceType InvoiceType { get; set; } = SalesInvoiceType.B2C;
|
public SalesInvoiceType InvoiceType { get; set; } = SalesInvoiceType.B2C;
|
||||||
public SalesInvoiceStatus Status { get; set; } = SalesInvoiceStatus.Draft;
|
public SalesInvoiceStatus Status { get; set; } = SalesInvoiceStatus.Draft;
|
||||||
|
|
||||||
@@ -29,6 +34,14 @@ public class SalesInvoice
|
|||||||
public decimal PaidAmount { get; set; }
|
public decimal PaidAmount { get; set; }
|
||||||
public decimal BalanceAmount { 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 int CreatedBy { get; set; }
|
||||||
public User? Creator { get; set; }
|
public User? Creator { get; set; }
|
||||||
|
|
||||||
@@ -38,4 +51,5 @@ public class SalesInvoice
|
|||||||
public uint RowVersion { get; set; }
|
public uint RowVersion { get; set; }
|
||||||
|
|
||||||
public ICollection<SalesInvoiceLine> Lines { get; set; } = new List<SalesInvoiceLine>();
|
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 Qty { get; set; }
|
||||||
public decimal FreeQty { get; set; }
|
public decimal FreeQty { get; set; }
|
||||||
public int UomId { get; set; }
|
|
||||||
public Uom? Uom { get; set; }
|
|
||||||
public int WarehouseId { get; set; }
|
public int WarehouseId { get; set; }
|
||||||
public Warehouse? Warehouse { 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 PaidAmount { get; set; }
|
||||||
public decimal BalanceAmount { 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 CreatedAt { get; set; }
|
||||||
public DateTime? UpdatedAt { get; set; }
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -16,11 +16,15 @@ public class SalesSlipLine
|
|||||||
|
|
||||||
public decimal Qty { get; set; }
|
public decimal Qty { get; set; }
|
||||||
public decimal FreeQty { get; set; }
|
public decimal FreeQty { get; set; }
|
||||||
public int UomId { get; set; }
|
|
||||||
public Uom? Uom { get; set; }
|
|
||||||
public int WarehouseId { get; set; }
|
public int WarehouseId { get; set; }
|
||||||
public Warehouse? Warehouse { get; set; }
|
public Warehouse? Warehouse { get; set; }
|
||||||
|
|
||||||
|
public int? BatchId { get; set; }
|
||||||
|
public Batch? Batch { get; set; }
|
||||||
|
|
||||||
|
public int? SerialId { get; set; }
|
||||||
|
public Serial? Serial { get; set; }
|
||||||
|
|
||||||
public decimal UnitPrice { get; set; }
|
public decimal UnitPrice { get; set; }
|
||||||
public decimal BaseCost { get; set; }
|
public decimal BaseCost { get; set; }
|
||||||
public string PriceSource { get; set; } = string.Empty;
|
public string PriceSource { get; set; } = string.Empty;
|
||||||
|
|||||||
@@ -13,4 +13,7 @@ public class Serial
|
|||||||
|
|
||||||
public string SerialNo { get; set; } = string.Empty;
|
public string SerialNo { get; set; } = string.Empty;
|
||||||
public string Status { get; set; } = "InStock";
|
public string Status { get; set; } = "InStock";
|
||||||
|
|
||||||
|
/// <summary>Optional scan code for barcode/RFID lookup (unique when set).</summary>
|
||||||
|
public string? Barcode { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,8 +32,13 @@ public class StageInput
|
|||||||
public int? FromOutputId { get; set; }
|
public int? FromOutputId { get; set; }
|
||||||
public StageOutput? FromOutput { get; set; }
|
public StageOutput? FromOutput { get; set; }
|
||||||
|
|
||||||
public int UomId { get; set; }
|
/// <summary>
|
||||||
public Uom? Uom { get; set; }
|
/// 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; }
|
public decimal QtyPerBatch { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,14 @@ public class StageOutput
|
|||||||
|
|
||||||
public string Name { get; set; } = string.Empty;
|
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; }
|
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; }
|
public decimal QtyPerBatch { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user