Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 47683ddd0f |
Submodule .claude/worktrees/agent-a98805dc618e0f67c deleted from b4993b202d
@@ -1,106 +0,0 @@
|
||||
name: CI/CD - ERP Core Monorepo
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- production
|
||||
types:
|
||||
- closed
|
||||
|
||||
jobs:
|
||||
build-and-deploy-backend:
|
||||
name: Build & Deploy Backend
|
||||
runs-on: linux
|
||||
if: github.event.pull_request.merged == true
|
||||
steps:
|
||||
- name: Checkout code
|
||||
run: |
|
||||
git clone \
|
||||
--depth 1 \
|
||||
--branch production \
|
||||
https://oauth2:${{ secrets.ERPCORE }}@gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git \
|
||||
.
|
||||
- name: Restore
|
||||
run: dotnet restore Backend/ERPCore.sln
|
||||
- name: Publish
|
||||
run: |
|
||||
rm -rf /tmp/erpcore-deploy
|
||||
mkdir -p /tmp/erpcore-deploy
|
||||
dotnet publish Backend/ERPCore/ERPCore.csproj \
|
||||
-c Release \
|
||||
-r linux-x64 \
|
||||
--self-contained true \
|
||||
-o /tmp/erpcore-deploy
|
||||
- name: Stop service
|
||||
run: systemctl stop erpcore.service
|
||||
- name: Copy to deploy folder
|
||||
run: |
|
||||
mkdir -p /home/erp/core/publish
|
||||
rm -rf /home/erp/core/publish/*
|
||||
cp -r /tmp/erpcore-deploy/* /home/erp/core/publish/
|
||||
- name: Fix ownership and permissions
|
||||
run: |
|
||||
chown -R erpuser:erpuser /home/erp/core/publish
|
||||
find /home/erp/core/publish -type d -exec chmod 755 {} +
|
||||
find /home/erp/core/publish -type f -exec chmod 644 {} +
|
||||
chmod +x /home/erp/core/publish/ERPCore
|
||||
- name: Restart service
|
||||
# ConnectionStrings__DefaultConnection is supplied by the systemd unit on the host
|
||||
# (Environment= or EnvironmentFile=), deliberately not committed: appsettings.json ships
|
||||
# the key empty and appsettings.Production.json carries no ConnectionStrings section at
|
||||
# all. If the unit does not set it, startup throws before serving and the Verify step
|
||||
# below fails the job.
|
||||
run: systemctl restart erpcore.service
|
||||
- name: Verify
|
||||
run: |
|
||||
# `is-active ... || echo` always exited 0, so a crashed service still reported success.
|
||||
# Wait a few seconds first: a startup failure (bad or missing connection string) happens
|
||||
# during MigrateAsync, after systemctl restart has already returned.
|
||||
sleep 5
|
||||
if ! systemctl is-active --quiet erpcore.service; then
|
||||
echo "❌ erpcore.service is not running:"
|
||||
journalctl -u erpcore.service -n 50 --no-pager
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Service running"
|
||||
|
||||
build-and-deploy-frontend:
|
||||
name: Build & Deploy Frontend
|
||||
runs-on: linux
|
||||
if: github.event.pull_request.merged == true
|
||||
steps:
|
||||
- name: Checkout code
|
||||
run: |
|
||||
git clone \
|
||||
--depth 1 \
|
||||
--branch production \
|
||||
https://oauth2:${{ secrets.ERPCORE }}@gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git \
|
||||
.
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
docker build --progress=plain --no-cache \
|
||||
-t erpcore-frontend:latest \
|
||||
./Frontend/erp-system
|
||||
- name: Stop & remove existing container
|
||||
run: |
|
||||
docker stop erpcore-frontend 2>/dev/null || true
|
||||
docker rm erpcore-frontend 2>/dev/null || true
|
||||
- name: Start container
|
||||
run: |
|
||||
docker run -d \
|
||||
--name erpcore-frontend \
|
||||
-p 6702:6702 \
|
||||
--add-host=host.docker.internal:host-gateway \
|
||||
-e BACKEND_ORIGIN=http://host.docker.internal:6701 \
|
||||
--restart unless-stopped \
|
||||
--memory="512m" \
|
||||
--cpus="1.0" \
|
||||
--cap-drop ALL \
|
||||
--security-opt no-new-privileges \
|
||||
erpcore-frontend:latest
|
||||
- name: Remove old images
|
||||
run: docker image prune -f
|
||||
- name: Verify
|
||||
run: |
|
||||
docker ps --filter name=erpcore-frontend --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" \
|
||||
| grep erpcore-frontend && echo "✅ Container running" || echo "❌ Container failed"
|
||||
@@ -29,11 +29,3 @@ yarn-error.log*
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.idea/
|
||||
|
||||
# ── Playwright E2E (Testing/e2e) ──────────────────────────────────────
|
||||
Testing/e2e/playwright-report/
|
||||
Testing/e2e/test-results/
|
||||
Testing/e2e/.auth/
|
||||
Testing/e2e/blob-report/
|
||||
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<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="9.0.20" />
|
||||
<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>
|
||||
@@ -1,152 +0,0 @@
|
||||
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(1u, 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, 1);
|
||||
|
||||
Assert.Equal(2u, 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, 1));
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Production;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Repositories;
|
||||
using ERPCore.Services.Production;
|
||||
using ERPCore.System.Errors;
|
||||
using ERPCore.Tests.TestSupport;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
|
||||
namespace ERPCore.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the 412 translation on the two production services, which save through
|
||||
/// <c>ExecuteInTransactionAsync</c> rather than a bare <c>SaveChangesAsync</c> and so guard
|
||||
/// concurrency differently from the inline pattern the other services use.
|
||||
/// </summary>
|
||||
public class ProductionConcurrencyTests
|
||||
{
|
||||
private static readonly InMemoryDatabaseRoot Root = new();
|
||||
|
||||
[Fact]
|
||||
public async Task TemplateUpdateAsync_MapsConcurrencyFailuresToDomainException()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
|
||||
// Validation runs before the transaction opens, so the referenced item has to exist.
|
||||
db.Items.Add(new Item
|
||||
{
|
||||
ItemId = 1,
|
||||
Sku = "FG-1",
|
||||
Name = "Finished good",
|
||||
CategoryId = 1,
|
||||
BaseUomId = 1,
|
||||
Status = EntityStatus.Active
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var service = CreateTemplateService(db);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(
|
||||
() => service.UpdateAsync(1, SingleStageTemplate(), expectedRowVersion: 1));
|
||||
|
||||
Assert.Equal(ErrorCodes.ConcurrencyConflict, ex.Code);
|
||||
Assert.Equal(412, ex.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunUpdateStageQuantitiesAsync_MapsConcurrencyFailuresToDomainException()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
var service = CreateRunService(db);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(
|
||||
() => service.UpdateStageQuantitiesAsync(1, 1, new UpdateStageQuantitiesRequest()));
|
||||
|
||||
Assert.Equal(ErrorCodes.ConcurrencyConflict, ex.Code);
|
||||
Assert.Equal(412, ex.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The smallest graph the validator accepts: one stage (both entry and terminal) with a
|
||||
/// stock input and a single item-bearing output, and no edges.
|
||||
/// </summary>
|
||||
private static SaveTemplateRequest SingleStageTemplate() => new()
|
||||
{
|
||||
Code = "TPL-1",
|
||||
Name = "Template 1",
|
||||
Stages =
|
||||
[
|
||||
new SaveStageRequest
|
||||
{
|
||||
Key = "tmp-1",
|
||||
Name = "Mix",
|
||||
Inputs =
|
||||
[
|
||||
new SaveInputRequest
|
||||
{
|
||||
Source = StageInputSource.Stock,
|
||||
ItemId = 1,
|
||||
QtyUnit = StageQtyUnit.Pack,
|
||||
QtyPerBatch = 1m
|
||||
}
|
||||
],
|
||||
Outputs =
|
||||
[
|
||||
new SaveOutputRequest { Key = "tmp-o1", Name = "Output", ItemId = 1, QtyPerBatch = 1m }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
private static ErpDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ErpDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString("N"), Root)
|
||||
.Options;
|
||||
|
||||
// The real CurrentBranchContext over an unauthenticated user yields IsActive=false,
|
||||
// which leaves the branch query filters off: what a unit test wants.
|
||||
var currentUser = new TestCurrentUser();
|
||||
return new ErpDbContext(options, currentUser, new CurrentBranchContext(currentUser));
|
||||
}
|
||||
|
||||
private static ProductionTemplateService CreateTemplateService(ErpDbContext db)
|
||||
=> new(
|
||||
new Repository<ProductionTemplate>(db), new Repository<TemplateStage>(db),
|
||||
new Repository<StageInput>(db), new Repository<StageOutput>(db),
|
||||
new Repository<StageEdge>(db), new Repository<Item>(db),
|
||||
new Repository<Uom>(db), new Repository<ProductionRun>(db),
|
||||
new ThrowingTransactionUnitOfWork(), new TestCurrentUser());
|
||||
|
||||
// The costing, measurement and numbering collaborators are only ever touched inside the
|
||||
// transaction lambda, which ThrowingTransactionUnitOfWork never invokes.
|
||||
private static ProductionRunService CreateRunService(ErpDbContext db)
|
||||
=> new(
|
||||
new Repository<ProductionRun>(db), new Repository<ProductionTemplate>(db),
|
||||
new Repository<Warehouse>(db), new Repository<Bin>(db),
|
||||
new Repository<Item>(db), new Repository<ReasonCode>(db),
|
||||
fifo: null!, measure: null!, numbers: null!,
|
||||
new ThrowingTransactionUnitOfWork(), new TestCurrentUser());
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
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;
|
||||
|
||||
// Unauthenticated, so no role claim and no branch scoping: the three members the
|
||||
// AuthHex integration added to ICurrentUser.
|
||||
public string? RoleCode => null;
|
||||
public bool IsAdmin => false;
|
||||
public int? BranchId => null;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fails the transactional path the way a lost optimistic-concurrency race does, so the
|
||||
/// services that save through <c>ExecuteInTransactionAsync</c> can be covered too. The
|
||||
/// lambda is never invoked, which is the point: the real <c>UnitOfWork</c> issues its own
|
||||
/// <c>SaveChangesAsync</c> after the lambda returns, so the catch under test has to sit
|
||||
/// outside it.
|
||||
/// </summary>
|
||||
internal sealed class ThrowingTransactionUnitOfWork : 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 DbUpdateConcurrencyException("Test concurrency conflict.");
|
||||
|
||||
public Task<T> ExecuteInTransactionAsync<T>(Func<CancellationToken, Task<T>> action, CancellationToken ct = default)
|
||||
=> throw new DbUpdateConcurrencyException("Test concurrency conflict.");
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// The real CurrentBranchContext over an unauthenticated user yields IsActive=false,
|
||||
// which leaves the branch query filters off: what a unit test wants.
|
||||
var currentUser = new TestCurrentUser();
|
||||
return new ErpDbContext(options, currentUser, new CurrentBranchContext(currentUser));
|
||||
}
|
||||
|
||||
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,
|
||||
// No RowVersion here on purpose: ErpDbContext.BumpRowVersions stamps every
|
||||
// inserted row with 1, so any seeded value is overwritten on save.
|
||||
};
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
using ERPCore.Dtos.Auth;
|
||||
using ERPCore.Dtos.Rbac;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
@@ -26,29 +25,12 @@ public sealed class AuthController : ControllerBase
|
||||
private readonly IAuthUserService _users;
|
||||
private readonly IAuthRecoveryService _recovery;
|
||||
private readonly IAuthAltService _alt;
|
||||
private readonly IRoleService _roles;
|
||||
private readonly IAuditService _audit;
|
||||
|
||||
public AuthController(IAuthUserService users, IAuthRecoveryService recovery, IAuthAltService alt, IRoleService roles, IAuditService audit)
|
||||
public AuthController(IAuthUserService users, IAuthRecoveryService recovery, IAuthAltService alt)
|
||||
{
|
||||
_users = users;
|
||||
_recovery = recovery;
|
||||
_alt = alt;
|
||||
_roles = roles;
|
||||
_audit = audit;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authoritative current-session info for the frontend: role + the sidebar nav
|
||||
/// codes it may see (docs/10 C.9 shadow-Role sync). Replaces the frontend's
|
||||
/// previous reliance on a stale, untrusted `roleId` cached in localStorage.
|
||||
/// </summary>
|
||||
[HttpGet("me")]
|
||||
[ProducesResponseType(typeof(MeResponseDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<MeResponseDto>> Me(CancellationToken ct)
|
||||
{
|
||||
var roleCode = User.FindFirst(AuthHexClaims.RoleCode)?.Value;
|
||||
return Ok(await _roles.GetMeAsync(roleCode, ct));
|
||||
}
|
||||
|
||||
// ---- Session-issuing (UserManager) ------------------------------------
|
||||
@@ -70,7 +52,6 @@ public sealed class AuthController : ControllerBase
|
||||
{
|
||||
var result = await _users.LoginAsync(request, ct);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -81,13 +62,9 @@ public sealed class AuthController : ControllerBase
|
||||
{
|
||||
var result = await _users.VerifyOtpForLoginAsync(request, ct);
|
||||
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);
|
||||
}
|
||||
|
||||
private string? ClientIp() => HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
private string? ClientUserAgent() => Request.Headers.UserAgent.ToString() is { Length: > 0 } ua ? ua : null;
|
||||
|
||||
[HttpPost("refresh-token")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(AuthSessionResponse), StatusCodes.Status200OK)]
|
||||
@@ -148,45 +125,16 @@ public sealed class AuthController : ControllerBase
|
||||
public async Task<ActionResult<VerifyPasswordResponse>> VerifyPassword([FromBody] VerifyPasswordRequest request, CancellationToken ct)
|
||||
=> Ok(await _users.VerifyPasswordAsync(request, RequireBearerToken(), ct));
|
||||
|
||||
/// <summary>
|
||||
/// Ends the session: revokes it upstream where possible, and always clears our cookies.
|
||||
/// <para>
|
||||
/// <c>userId</c> is optional because callers usually cannot supply it — AuthHex returns
|
||||
/// <c>user.userId: null</c> in its own login/register response, so a browser has no id
|
||||
/// to send. It is resolved from the session token's <c>UserId</c> claim instead.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The cookies are cleared even if the upstream revoke fails or no user can be
|
||||
/// resolved: a logout that leaves the caller holding a live session cookie is worse
|
||||
/// than one that leaves a stale session server-side (which lapses on its own).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[HttpPost("logout")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> Logout([FromBody] LogoutRequest? request, CancellationToken ct)
|
||||
public async Task<IActionResult> Logout([FromBody] LogoutRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = request?.UserId ?? ResolveTokenUserId();
|
||||
if (userId is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _users.LogoutUserAsync(new LogoutRequest { UserId = userId.Value }, ct);
|
||||
}
|
||||
catch (DomainException)
|
||||
{
|
||||
// Upstream unreachable or already-revoked — fall through and clear anyway.
|
||||
}
|
||||
}
|
||||
|
||||
await _users.LogoutUserAsync(request, ct);
|
||||
AuthCookieWriter.ClearSession(Response);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>AuthHex's identity claim, present when the request carried a valid session.</summary>
|
||||
private Guid? ResolveTokenUserId()
|
||||
=> Guid.TryParse(User.FindFirst(AuthHexClaims.UserId)?.Value, out var id) ? id : null;
|
||||
|
||||
[HttpPut("me")]
|
||||
[ValidateCsrf]
|
||||
[ProducesResponseType(typeof(UserSummaryDto), StatusCodes.Status200OK)]
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
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,70 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Brands;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Brand master endpoints (docs/11-BACKEND-PHASE1.md §2.6).</summary>
|
||||
[Route("api/v1/brands")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||
public sealed class BrandsController : ApiControllerBase
|
||||
{
|
||||
private readonly IBrandService _brands;
|
||||
|
||||
public BrandsController(IBrandService brands) => _brands = brands;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<BrandDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<BrandDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||
=> Ok(await _brands.ListAsync(query, status, ct));
|
||||
|
||||
[HttpGet("{brandId:int}")]
|
||||
[ProducesResponseType(typeof(BrandDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<BrandDto>> GetById(int brandId, CancellationToken ct)
|
||||
{
|
||||
var result = await _brands.GetAsync(brandId, ct);
|
||||
if (result is null) return NotFound();
|
||||
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(BrandDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<BrandDto>> Create([FromBody] CreateBrandRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _brands.CreateAsync(request, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Created($"/api/v1/brands/{result.Value.BrandId}", result.Value);
|
||||
}
|
||||
|
||||
[HttpPut("{brandId:int}")]
|
||||
[ProducesResponseType(typeof(BrandDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<BrandDto>> Update(int brandId, [FromBody] UpdateBrandRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _brands.UpdateAsync(brandId, request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
|
||||
[HttpPatch("{brandId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> SetStatus(int brandId, [FromBody] UpdateBrandStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
await _brands.SetStatusAsync(brandId, request.Status, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Sales;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
[Route("api/v1/bundle-sales")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "sales")]
|
||||
public sealed class BundleSalesController : ApiControllerBase
|
||||
{
|
||||
private readonly IBundleSaleService _bundles;
|
||||
|
||||
public BundleSalesController(IBundleSaleService bundles) => _bundles = bundles;
|
||||
|
||||
[HttpGet("templates")]
|
||||
[ProducesResponseType(typeof(PagedResponse<BundleSaleTemplateSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<BundleSaleTemplateSummaryDto>>> ListTemplates(
|
||||
[FromQuery] PageQuery query,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _bundles.ListTemplatesAsync(query, ct));
|
||||
|
||||
[HttpGet("templates/{bundleSaleTemplateId:int}")]
|
||||
[ProducesResponseType(typeof(BundleSaleTemplateDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<BundleSaleTemplateDto>> GetTemplate(int bundleSaleTemplateId, CancellationToken ct)
|
||||
{
|
||||
var result = await _bundles.GetTemplateAsync(bundleSaleTemplateId, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("templates")]
|
||||
[ProducesResponseType(typeof(BundleSaleTemplateDto), StatusCodes.Status201Created)]
|
||||
public async Task<ActionResult<BundleSaleTemplateDto>> CreateTemplate([FromBody] CreateBundleSaleTemplateRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _bundles.CreateTemplateAsync(request, ct);
|
||||
return Created($"/api/v1/bundle-sales/templates/{result.BundleSaleTemplateId}", result);
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<BundleSaleSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<BundleSaleSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query,
|
||||
[FromQuery] BundleSaleStatus? status,
|
||||
[FromQuery] int? customerId,
|
||||
[FromQuery] int? warehouseId,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _bundles.ListAsync(query, status, customerId, warehouseId, ct));
|
||||
|
||||
[HttpGet("{bundleSaleId:int}")]
|
||||
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<BundleSaleDto>> GetById(int bundleSaleId, CancellationToken ct)
|
||||
{
|
||||
var result = await _bundles.GetAsync(bundleSaleId, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("{bundleSaleId:int}/posting-check")]
|
||||
[ProducesResponseType(typeof(BundleSalePostingCheckDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<BundleSalePostingCheckDto>> PostingCheck(int bundleSaleId, CancellationToken ct)
|
||||
=> Ok(await _bundles.CheckPostingAsync(bundleSaleId, ct));
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status201Created)]
|
||||
public async Task<ActionResult<BundleSaleDto>> Create([FromBody] CreateBundleSaleRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _bundles.CreateAsync(request, ct);
|
||||
return Created($"/api/v1/bundle-sales/{result.BundleSaleId}", result);
|
||||
}
|
||||
|
||||
[HttpPut("{bundleSaleId:int}")]
|
||||
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<BundleSaleDto>> Update(int bundleSaleId, [FromBody] UpdateBundleSaleRequest request, CancellationToken ct)
|
||||
{
|
||||
return Ok(await _bundles.UpdateAsync(bundleSaleId, request, ct));
|
||||
}
|
||||
|
||||
[HttpPost("{bundleSaleId:int}/post")]
|
||||
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<BundleSaleDto>> Post(int bundleSaleId, CancellationToken ct)
|
||||
=> Ok(await _bundles.PostAsync(bundleSaleId, ct));
|
||||
|
||||
[HttpPost("{bundleSaleId:int}/cancel")]
|
||||
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<BundleSaleDto>> Cancel(int bundleSaleId, CancellationToken ct)
|
||||
=> Ok(await _bundles.CancelAsync(bundleSaleId, ct));
|
||||
}
|
||||
@@ -1,6 +1,3 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Categories;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Services.Interfaces;
|
||||
@@ -8,92 +5,27 @@ using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Category endpoints (docs/11-BACKEND-PHASE1.md §2.3), including the subcategories
|
||||
/// nested beneath each category. The hierarchy is exactly two levels deep — the old
|
||||
/// <c>?tree=true</c> parameter is gone along with the self-nesting model.
|
||||
/// </summary>
|
||||
/// <summary>Category endpoints (docs/11-BACKEND-PHASE1.md §2.3).</summary>
|
||||
[Route("api/v1/categories")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||
public sealed class CategoriesController : ApiControllerBase
|
||||
{
|
||||
private readonly ICategoryService _categories;
|
||||
|
||||
public CategoriesController(ICategoryService categories) => _categories = categories;
|
||||
|
||||
/// <summary>Flat paged list, or a nested tree when <c>tree=true</c>.</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<CategoryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<CategoryDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||
=> Ok(await _categories.ListAsync(query, status, ct));
|
||||
|
||||
[HttpGet("{categoryId:int}")]
|
||||
[ProducesResponseType(typeof(CategoryDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<CategoryDto>> GetById(int categoryId, CancellationToken ct)
|
||||
{
|
||||
var result = await _categories.GetAsync(categoryId, ct);
|
||||
if (result is null) return NotFound();
|
||||
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
[ProducesResponseType(typeof(IReadOnlyList<CategoryTreeDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> List([FromQuery] PageQuery query, [FromQuery] bool tree, CancellationToken ct)
|
||||
=> tree ? Ok(await _categories.GetTreeAsync(ct)) : Ok(await _categories.ListAsync(query, ct));
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(CategoryDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<CategoryDto>> Create([FromBody] CreateCategoryRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _categories.CreateAsync(request, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Created($"/api/v1/categories/{result.Value.CategoryId}", result.Value);
|
||||
}
|
||||
|
||||
[HttpPut("{categoryId:int}")]
|
||||
[ProducesResponseType(typeof(CategoryDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<CategoryDto>> Update(
|
||||
int categoryId, [FromBody] UpdateCategoryRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _categories.UpdateAsync(categoryId, request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
|
||||
[HttpPatch("{categoryId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> SetStatus(
|
||||
int categoryId, [FromBody] UpdateCategoryStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
await _categories.SetStatusAsync(categoryId, request.Status, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// Subcategories — nested under their parent category (docs/11 §2.3).
|
||||
// Updates live on SubCategoriesController at /api/v1/subcategories/{id}.
|
||||
|
||||
[HttpGet("{categoryId:int}/subcategories")]
|
||||
[ProducesResponseType(typeof(PagedResponse<SubCategoryDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<PagedResponse<SubCategoryDto>>> ListSubCategories(
|
||||
int categoryId, [FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||
=> Ok(await _categories.ListSubCategoriesAsync(categoryId, query, status, ct));
|
||||
|
||||
[HttpPost("{categoryId:int}/subcategories")]
|
||||
[ProducesResponseType(typeof(SubCategoryDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<SubCategoryDto>> CreateSubCategory(
|
||||
int categoryId, [FromBody] CreateSubCategoryRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _categories.CreateSubCategoryAsync(categoryId, request, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Created($"/api/v1/subcategories/{result.Value.SubCategoryId}", result.Value);
|
||||
var dto = await _categories.CreateAsync(request, ct);
|
||||
return Created($"/api/v1/categories/{dto.CategoryId}", dto);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Customers;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Customer master endpoints for Phase 1 sales.</summary>
|
||||
[Route("api/v1/customers")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "sales")]
|
||||
public sealed class CustomersController : ApiControllerBase
|
||||
{
|
||||
private readonly ICustomerService _customers;
|
||||
|
||||
public CustomersController(ICustomerService customers) => _customers = customers;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<CustomerDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<CustomerDto>>> List(
|
||||
[FromQuery] PageQuery query,
|
||||
[FromQuery] EntityStatus? status,
|
||||
[FromQuery] CustomerType? customerType,
|
||||
CancellationToken ct,
|
||||
[FromQuery] bool includeCashCustomer = false,
|
||||
[FromQuery] int? branchId = null)
|
||||
=> Ok(await _customers.ListAsync(query, status, customerType, includeCashCustomer, branchId, ct));
|
||||
|
||||
[HttpGet("{customerId:int}")]
|
||||
[ProducesResponseType(typeof(CustomerDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<CustomerDto>> GetById(int customerId, CancellationToken ct)
|
||||
{
|
||||
var result = await _customers.GetAsync(customerId, ct);
|
||||
if (result is null) return NotFound();
|
||||
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(CustomerDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<CustomerDto>> Create([FromBody] CreateCustomerRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _customers.CreateAsync(request, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Created($"/api/v1/customers/{result.Value.CustomerId}", result.Value);
|
||||
}
|
||||
|
||||
[HttpPut("{customerId:int}")]
|
||||
[ProducesResponseType(typeof(CustomerDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<CustomerDto>> Update(int customerId, [FromBody] UpdateCustomerRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _customers.UpdateAsync(customerId, request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPatch("{customerId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> SetStatus(int customerId, [FromBody] UpdateCustomerStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
await _customers.SetStatusAsync(customerId, request.Status, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Dashboard;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Dashboard overview stats — cross-domain counts, not a stored entity.</summary>
|
||||
[Route("api/v1/dashboard")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "dashboard")]
|
||||
public sealed class DashboardController : ApiControllerBase
|
||||
{
|
||||
private readonly IDashboardService _dashboard;
|
||||
|
||||
public DashboardController(IDashboardService dashboard) => _dashboard = dashboard;
|
||||
|
||||
/// <summary>Aggregate counts for stock, GRN, and procurement.</summary>
|
||||
[HttpGet("stats")]
|
||||
[ProducesResponseType(typeof(DashboardStatsDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<DashboardStatsDto>> GetStats([FromQuery] int? branchId, CancellationToken ct)
|
||||
=> Ok(await _dashboard.GetStatsAsync(branchId, ct));
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Sales;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Thin API alias for free-issue handling. Free issues are modeled as sales slips
|
||||
/// with line-level <c>IsFreeIssue</c>/<c>FreeQty</c> flags, so this controller reuses
|
||||
/// the existing sales-slip CRUD surface under a more business-friendly route.
|
||||
/// </summary>
|
||||
[Route("api/v1/free-issues")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "sales")]
|
||||
public sealed class FreeIssuesController : ApiControllerBase
|
||||
{
|
||||
private readonly ISalesSlipService _slips;
|
||||
|
||||
public FreeIssuesController(ISalesSlipService slips) => _slips = slips;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<FreeIssueSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<FreeIssueSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query,
|
||||
[FromQuery] SalesSlipStatus? status,
|
||||
[FromQuery] int? customerId,
|
||||
[FromQuery] int? warehouseId,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _slips.ListFreeIssuesAsync(query, status, customerId, warehouseId, ct));
|
||||
|
||||
[HttpGet("{freeIssueId:int}")]
|
||||
[ProducesResponseType(typeof(FreeIssueDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<FreeIssueDto>> GetById(int freeIssueId, CancellationToken ct)
|
||||
{
|
||||
var result = await _slips.GetFreeIssueAsync(freeIssueId, ct);
|
||||
if (result is null) return NotFound();
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpGet("{freeIssueId:int}/posting-check")]
|
||||
[ProducesResponseType(typeof(SalesSlipPostingCheckDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<SalesSlipPostingCheckDto>> PostingCheck(int freeIssueId, CancellationToken ct)
|
||||
=> Ok(await _slips.CheckPostingAsync(freeIssueId, ct));
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(FreeIssueDto), StatusCodes.Status201Created)]
|
||||
public async Task<ActionResult<FreeIssueDto>> Create([FromBody] CreateSalesSlipRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _slips.CreateAsync(request, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Created($"/api/v1/free-issues/{result.Value.SalesSlipId}", result.Value);
|
||||
}
|
||||
|
||||
[HttpPut("{freeIssueId:int}")]
|
||||
[ProducesResponseType(typeof(FreeIssueDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<FreeIssueDto>> Update(int freeIssueId, [FromBody] UpdateSalesSlipRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _slips.UpdateAsync(freeIssueId, request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost("{freeIssueId:int}/post")]
|
||||
[ProducesResponseType(typeof(FreeIssueDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<FreeIssueDto>> Post(int freeIssueId, CancellationToken ct)
|
||||
=> Ok(await _slips.PostAsync(freeIssueId, ct));
|
||||
|
||||
[HttpPost("{freeIssueId:int}/cancel")]
|
||||
[ProducesResponseType(typeof(FreeIssueDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<FreeIssueDto>> Cancel(int freeIssueId, CancellationToken ct)
|
||||
=> Ok(await _slips.CancelAsync(freeIssueId, ct));
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Generic reverse proxy into the external General Ledger service — forwards every
|
||||
/// method/path/query/body under this prefix verbatim via
|
||||
/// <see cref="IGeneralLedgerService"/> and returns GL's response (status, content-type,
|
||||
/// body) unchanged. No endpoint-specific shape lives here; see
|
||||
/// docs/12-GENERAL-LEDGER-INTEGRATION.md for the full GL contract and what this proxy
|
||||
/// does and doesn't do. Gated by the same ERP door policy as every other v1 endpoint
|
||||
/// (<see cref="ApiControllerBase"/>) — the shared GL API key is attached server-side
|
||||
/// only and is never exposed to the frontend.
|
||||
/// </summary>
|
||||
[Route("api/v1/gl")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "ledgers")]
|
||||
public sealed class GeneralLedgerController : ApiControllerBase
|
||||
{
|
||||
private readonly IGeneralLedgerService _gl;
|
||||
|
||||
public GeneralLedgerController(IGeneralLedgerService gl) => _gl = gl;
|
||||
|
||||
[HttpGet("{**path}")]
|
||||
public Task<IActionResult> Get(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Get, path, ct);
|
||||
|
||||
[HttpPost("{**path}")]
|
||||
public Task<IActionResult> Post(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Post, path, ct);
|
||||
|
||||
[HttpPut("{**path}")]
|
||||
public Task<IActionResult> Put(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Put, path, ct);
|
||||
|
||||
private async Task<IActionResult> ForwardAsync(HttpMethod method, string path, CancellationToken ct)
|
||||
{
|
||||
var body = method == HttpMethod.Get ? null : Request.Body;
|
||||
var result = await _gl.ForwardAsync(method, path, Request.QueryString.Value, Request.ContentType, body, ct);
|
||||
return new ContentResult
|
||||
{
|
||||
StatusCode = result.StatusCode,
|
||||
Content = result.Body,
|
||||
ContentType = result.ContentType ?? "application/json"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Config;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// GRN configuration endpoints — warehouse selection and the default receiving warehouse.
|
||||
/// <para>
|
||||
/// <b>Authorization:</b> gated on the Receiving nav policy (the New GRN form has to read it), same
|
||||
/// Phase-1 posture as <see cref="SalesConfigController"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Route("api/v1/grn-config")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "receiving")]
|
||||
public sealed class GrnConfigController : ApiControllerBase
|
||||
{
|
||||
private readonly IGrnConfigService _config;
|
||||
|
||||
public GrnConfigController(IGrnConfigService config) => _config = config;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(GrnConfigDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<GrnConfigDto>> Get(CancellationToken ct)
|
||||
{
|
||||
var result = await _config.GetAsync(ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPut]
|
||||
[ProducesResponseType(typeof(GrnConfigDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<GrnConfigDto>> Update(
|
||||
[FromBody] UpdateGrnConfigRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _config.UpdateAsync(request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,3 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Grn;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -10,23 +6,11 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Goods-receipt endpoints (docs/11 §4).</summary>
|
||||
[Route("api/v1/grns")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "receiving")]
|
||||
public sealed class GrnsController : ApiControllerBase
|
||||
{
|
||||
private readonly IGrnService _grns;
|
||||
|
||||
public GrnsController(IGrnService grns)
|
||||
{
|
||||
_grns = grns;
|
||||
}
|
||||
|
||||
/// <summary>List GRNs, newest first.</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<GrnSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<GrnSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] GrnStatus? status, [FromQuery] int? poId,
|
||||
[FromQuery] int? vendorId, [FromQuery] int? warehouseId, [FromQuery] int? branchId, CancellationToken ct)
|
||||
=> Ok(await _grns.ListAsync(query, status, poId, vendorId, warehouseId, branchId, ct));
|
||||
public GrnsController(IGrnService grns) => _grns = grns;
|
||||
|
||||
[HttpGet("{grnId:int}")]
|
||||
[ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)]
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
|
||||
/// <summary>Attendance upload batch endpoints (docs/13-BACKEND-HRM-API.md §4).</summary>
|
||||
[Route("api/v1/attendance-batches")]
|
||||
public sealed class AttendanceBatchesController : ApiControllerBase
|
||||
{
|
||||
private readonly IAttendanceUploadService _attendance;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
|
||||
public AttendanceBatchesController(IAttendanceUploadService attendance, ICurrentUser currentUser)
|
||||
{
|
||||
_attendance = attendance;
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
|
||||
[HttpGet("template.xlsx")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public IActionResult DownloadTemplate([FromQuery] string? format)
|
||||
{
|
||||
var (content, contentType, fileName) = _attendance.GenerateTemplate(string.Equals(format, "csv", StringComparison.OrdinalIgnoreCase));
|
||||
return File(content, contentType, fileName);
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<AttendanceUploadBatchDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<AttendanceUploadBatchDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] AttendanceBatchStatus? status,
|
||||
[FromQuery] int? periodYear, [FromQuery] int? periodMonth, CancellationToken ct)
|
||||
=> Ok(await _attendance.ListBatchesAsync(query, status, periodYear, periodMonth, ct));
|
||||
|
||||
[HttpGet("{batchId:int}")]
|
||||
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<AttendanceUploadBatchDto>> GetById(int batchId, CancellationToken ct)
|
||||
{
|
||||
var result = await _attendance.GetBatchAsync(batchId, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[RequestSizeLimit(20 * 1024 * 1024)]
|
||||
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<AttendanceUploadBatchDto>> Upload(
|
||||
[FromForm] UploadAttendanceBatchMetadata metadata, IFormFile file, CancellationToken ct)
|
||||
{
|
||||
await using var stream = file.OpenReadStream();
|
||||
var result = await _attendance.UploadAsync(stream, file.FileName, metadata.PeriodStart, metadata.PeriodEnd, _currentUser.AuditUserId, ct);
|
||||
return Created($"/api/v1/attendance-batches/{result.AttendanceUploadBatchId}", result);
|
||||
}
|
||||
|
||||
[HttpGet("{batchId:int}/records")]
|
||||
[ProducesResponseType(typeof(List<AttendanceRecordDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<AttendanceRecordDto>>> ListRecords(
|
||||
int batchId, [FromQuery] RowValidationStatus? status, CancellationToken ct)
|
||||
=> Ok(await _attendance.ListRecordsAsync(batchId, status, ct));
|
||||
|
||||
[HttpPut("{batchId:int}/records/{recordId:int}")]
|
||||
[ProducesResponseType(typeof(AttendanceRecordDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<AttendanceRecordDto>> UpdateRecord(
|
||||
int batchId, int recordId, [FromBody] UpdateAttendanceRecordRequest request, CancellationToken ct)
|
||||
=> Ok(await _attendance.UpdateRecordAsync(batchId, recordId, request, _currentUser.AuditUserId, ct));
|
||||
|
||||
[HttpPost("{batchId:int}/resolve-duplicate")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> ResolveDuplicate(int batchId, [FromBody] ResolveDuplicateRequest request, CancellationToken ct)
|
||||
{
|
||||
await _attendance.ResolveDuplicateAsync(batchId, request, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("{batchId:int}/validate")]
|
||||
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<AttendanceUploadBatchDto>> Validate(int batchId, CancellationToken ct)
|
||||
=> Ok(await _attendance.ValidateAsync(batchId, ct));
|
||||
|
||||
[HttpPost("{batchId:int}/confirm")]
|
||||
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<AttendanceUploadBatchDto>> Confirm(int batchId, CancellationToken ct)
|
||||
=> Ok(await _attendance.ConfirmAsync(batchId, _currentUser.AuditUserId, ct));
|
||||
|
||||
[HttpPost("{batchId:int}/unlock")]
|
||||
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<AttendanceUploadBatchDto>> Unlock(int batchId, [FromBody] UnlockAttendanceBatchRequest request, CancellationToken ct)
|
||||
=> Ok(await _attendance.UnlockAsync(batchId, request.Reason, _currentUser.AuditUserId, ct));
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
|
||||
/// <summary>Branch master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
|
||||
[Route("api/v1/branches")]
|
||||
public sealed class BranchesController : ApiControllerBase
|
||||
{
|
||||
private readonly IBranchService _branches;
|
||||
|
||||
public BranchesController(IBranchService branches) => _branches = branches;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<BranchDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<BranchDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||
=> Ok(await _branches.ListAsync(query, status, ct));
|
||||
|
||||
[HttpGet("{branchId:int}")]
|
||||
[ProducesResponseType(typeof(BranchDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<BranchDto>> GetById(int branchId, CancellationToken ct)
|
||||
{
|
||||
var result = await _branches.GetAsync(branchId, ct);
|
||||
if (result is null) return NotFound();
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(BranchDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<BranchDto>> Create([FromBody] CreateBranchRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _branches.CreateAsync(request, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Created($"/api/v1/branches/{result.Value.BranchId}", result.Value);
|
||||
}
|
||||
|
||||
[HttpPut("{branchId:int}")]
|
||||
[ProducesResponseType(typeof(BranchDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<BranchDto>> Update(int branchId, [FromBody] UpdateBranchRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _branches.UpdateAsync(branchId, request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPatch("{branchId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> SetStatus(int branchId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
await _branches.SetStatusAsync(branchId, request.Status, ct);
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
|
||||
/// <summary>Department master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
|
||||
[Route("api/v1/departments")]
|
||||
public sealed class DepartmentsController : ApiControllerBase
|
||||
{
|
||||
private readonly IDepartmentService _departments;
|
||||
|
||||
public DepartmentsController(IDepartmentService departments) => _departments = departments;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<DepartmentDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<DepartmentDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||
=> Ok(await _departments.ListAsync(query, status, ct));
|
||||
|
||||
[HttpGet("{departmentId:int}")]
|
||||
[ProducesResponseType(typeof(DepartmentDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<DepartmentDto>> GetById(int departmentId, CancellationToken ct)
|
||||
{
|
||||
var result = await _departments.GetAsync(departmentId, ct);
|
||||
if (result is null) return NotFound();
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(DepartmentDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<DepartmentDto>> Create([FromBody] CreateDepartmentRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _departments.CreateAsync(request, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Created($"/api/v1/departments/{result.Value.DepartmentId}", result.Value);
|
||||
}
|
||||
|
||||
[HttpPut("{departmentId:int}")]
|
||||
[ProducesResponseType(typeof(DepartmentDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<DepartmentDto>> Update(int departmentId, [FromBody] UpdateDepartmentRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _departments.UpdateAsync(departmentId, request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPatch("{departmentId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> SetStatus(int departmentId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
await _departments.SetStatusAsync(departmentId, request.Status, ct);
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
|
||||
/// <summary>Designation (job title) master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
|
||||
[Route("api/v1/designations")]
|
||||
public sealed class DesignationsController : ApiControllerBase
|
||||
{
|
||||
private readonly IDesignationService _designations;
|
||||
|
||||
public DesignationsController(IDesignationService designations) => _designations = designations;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<DesignationDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<DesignationDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||
=> Ok(await _designations.ListAsync(query, status, ct));
|
||||
|
||||
[HttpGet("{designationId:int}")]
|
||||
[ProducesResponseType(typeof(DesignationDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<DesignationDto>> GetById(int designationId, CancellationToken ct)
|
||||
{
|
||||
var result = await _designations.GetAsync(designationId, ct);
|
||||
if (result is null) return NotFound();
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(DesignationDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<DesignationDto>> Create([FromBody] CreateDesignationRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _designations.CreateAsync(request, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Created($"/api/v1/designations/{result.Value.DesignationId}", result.Value);
|
||||
}
|
||||
|
||||
[HttpPut("{designationId:int}")]
|
||||
[ProducesResponseType(typeof(DesignationDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<DesignationDto>> Update(int designationId, [FromBody] UpdateDesignationRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _designations.UpdateAsync(designationId, request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPatch("{designationId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> SetStatus(int designationId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
await _designations.SetStatusAsync(designationId, request.Status, ct);
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
|
||||
/// <summary>
|
||||
/// Employee (staff) endpoints, incl. the Employee<->User cross-link and staff
|
||||
/// document sub-resources (docs/13-BACKEND-HRM-API.md §3).
|
||||
/// </summary>
|
||||
[Route("api/v1/employees")]
|
||||
public sealed class EmployeesController : ApiControllerBase
|
||||
{
|
||||
private readonly IEmployeeService _employees;
|
||||
private readonly IEmployeeUserLinkService _links;
|
||||
private readonly IEmployeeDocumentService _documents;
|
||||
private readonly ILeaveBalanceService _leaveBalances;
|
||||
private readonly IEmployeeSalaryStructureService _salaryStructures;
|
||||
private readonly IEmployeeLoanService _loans;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
|
||||
public EmployeesController(
|
||||
IEmployeeService employees, IEmployeeUserLinkService links, IEmployeeDocumentService documents,
|
||||
ILeaveBalanceService leaveBalances, IEmployeeSalaryStructureService salaryStructures,
|
||||
IEmployeeLoanService loans, ICurrentUser currentUser)
|
||||
{
|
||||
_employees = employees;
|
||||
_links = links;
|
||||
_documents = documents;
|
||||
_leaveBalances = leaveBalances;
|
||||
_salaryStructures = salaryStructures;
|
||||
_loans = loans;
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<EmployeeListItemDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<EmployeeListItemDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] EmployeeStatus? status,
|
||||
[FromQuery] int? departmentId, [FromQuery] int? designationId, [FromQuery] int? branchId, CancellationToken ct)
|
||||
=> Ok(await _employees.ListAsync(query, status, departmentId, designationId, branchId, ct));
|
||||
|
||||
/// <summary>Advisory reverse-direction lookup: does a System User already exist with this email? (docs/12-BACKEND-HRM.md A.5)</summary>
|
||||
[HttpGet("email-lookup")]
|
||||
[ProducesResponseType(typeof(UserMatchResponse), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<UserMatchResponse>> EmailLookup([FromQuery] string email, CancellationToken ct)
|
||||
=> Ok(new UserMatchResponse(await _links.FindUserCandidateByEmailAsync(email, ct)));
|
||||
|
||||
[HttpGet("{employeeId:int}")]
|
||||
[ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<EmployeeDetailDto>> GetById(int employeeId, CancellationToken ct)
|
||||
{
|
||||
var result = await _employees.GetAsync(employeeId, ct);
|
||||
if (result is null) return NotFound();
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<EmployeeDetailDto>> Create([FromBody] CreateEmployeeRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _employees.CreateAsync(request, _currentUser.AuditUserId, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Created($"/api/v1/employees/{result.Value.EmployeeId}", result.Value);
|
||||
}
|
||||
|
||||
[HttpPut("{employeeId:int}")]
|
||||
[ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<EmployeeDetailDto>> Update(int employeeId, [FromBody] UpdateEmployeeRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _employees.UpdateAsync(employeeId, request, expected, _currentUser.AuditUserId, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPatch("{employeeId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> SetStatus(int employeeId, [FromBody] UpdateEmployeeStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
await _employees.SetStatusAsync(employeeId, request.Status, ct);
|
||||
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")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> LinkUser(int employeeId, [FromBody] LinkUserRequest request, CancellationToken ct)
|
||||
{
|
||||
await _links.LinkAsync(employeeId, request.UserId, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{employeeId:int}/link-user")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> UnlinkUser(int employeeId, CancellationToken ct)
|
||||
{
|
||||
await _links.UnlinkAsync(employeeId, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("{employeeId:int}/bank-details")]
|
||||
[ProducesResponseType(typeof(List<EmployeeBankDetailDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<EmployeeBankDetailDto>>> ListBankDetails(int employeeId, CancellationToken ct)
|
||||
=> Ok(await _employees.ListBankDetailsAsync(employeeId, ct));
|
||||
|
||||
[HttpPut("{employeeId:int}/bank-details")]
|
||||
[ProducesResponseType(typeof(List<EmployeeBankDetailDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<List<EmployeeBankDetailDto>>> ReplaceBankDetails(
|
||||
int employeeId, [FromBody] ReplaceEmployeeBankDetailsRequest request, CancellationToken ct)
|
||||
=> Ok(await _employees.ReplaceBankDetailsAsync(employeeId, request, ct));
|
||||
|
||||
[HttpGet("{employeeId:int}/leave-balances")]
|
||||
[ProducesResponseType(typeof(List<LeaveBalanceDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<LeaveBalanceDto>>> ListLeaveBalances(int employeeId, [FromQuery] int? year, CancellationToken ct)
|
||||
=> Ok(await _leaveBalances.ListAsync(employeeId, year, ct));
|
||||
|
||||
[HttpPut("{employeeId:int}/leave-balances")]
|
||||
[ProducesResponseType(typeof(List<LeaveBalanceDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<List<LeaveBalanceDto>>> UpdateLeaveBalances(
|
||||
int employeeId, [FromBody] UpdateLeaveBalancesRequest request, CancellationToken ct)
|
||||
=> Ok(await _leaveBalances.ApplyAdjustmentsAsync(employeeId, request, ct));
|
||||
|
||||
[HttpGet("{employeeId:int}/salary-structure")]
|
||||
[ProducesResponseType(typeof(List<EmployeeSalaryStructureDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<EmployeeSalaryStructureDto>>> GetSalaryStructureHistory(int employeeId, CancellationToken ct)
|
||||
=> Ok(await _salaryStructures.ListHistoryAsync(employeeId, ct));
|
||||
|
||||
[HttpPost("{employeeId:int}/salary-structure")]
|
||||
[ProducesResponseType(typeof(EmployeeSalaryStructureDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<EmployeeSalaryStructureDto>> CreateSalaryStructure(
|
||||
int employeeId, [FromBody] CreateSalaryStructureRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _salaryStructures.CreateAsync(employeeId, request, _currentUser.AuditUserId, ct);
|
||||
return Created($"/api/v1/employees/{employeeId}/salary-structure", result);
|
||||
}
|
||||
|
||||
[HttpGet("{employeeId:int}/loans")]
|
||||
[ProducesResponseType(typeof(List<EmployeeLoanDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<EmployeeLoanDto>>> ListLoans(int employeeId, CancellationToken ct)
|
||||
=> Ok(await _loans.ListAsync(employeeId, ct));
|
||||
|
||||
[HttpGet("{employeeId:int}/loans/{loanId:int}")]
|
||||
[ProducesResponseType(typeof(EmployeeLoanDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<EmployeeLoanDto>> GetLoan(int employeeId, int loanId, CancellationToken ct)
|
||||
{
|
||||
var result = await _loans.GetAsync(employeeId, loanId, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("{employeeId:int}/loans")]
|
||||
[ProducesResponseType(typeof(EmployeeLoanDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<EmployeeLoanDto>> CreateLoan(int employeeId, [FromBody] CreateEmployeeLoanRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _loans.CreateAsync(employeeId, request, _currentUser.AuditUserId, ct);
|
||||
return Created($"/api/v1/employees/{employeeId}/loans/{result.EmployeeLoanId}", result);
|
||||
}
|
||||
|
||||
[HttpGet("{employeeId:int}/documents")]
|
||||
[ProducesResponseType(typeof(List<EmployeeDocumentDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<EmployeeDocumentDto>>> ListDocuments(int employeeId, CancellationToken ct)
|
||||
=> Ok(await _documents.ListAsync(employeeId, ct));
|
||||
|
||||
[HttpPost("{employeeId:int}/documents")]
|
||||
[RequestSizeLimit(20 * 1024 * 1024)]
|
||||
[ProducesResponseType(typeof(EmployeeDocumentDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status413PayloadTooLarge)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<EmployeeDocumentDto>> UploadDocument(
|
||||
int employeeId, [FromForm] UploadEmployeeDocumentRequest request, IFormFile file, CancellationToken ct)
|
||||
{
|
||||
await using var stream = file.OpenReadStream();
|
||||
var result = await _documents.UploadAsync(
|
||||
employeeId, request, stream, file.FileName, file.ContentType, _currentUser.AuditUserId, ct);
|
||||
return Created($"/api/v1/employees/{employeeId}/documents/{result.EmployeeDocumentId}", result);
|
||||
}
|
||||
|
||||
[HttpGet("{employeeId:int}/documents/{documentId:int}/download")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DownloadDocument(int employeeId, int documentId, CancellationToken ct)
|
||||
{
|
||||
var (content, fileName, contentType) = await _documents.DownloadAsync(employeeId, documentId, ct);
|
||||
return File(content, contentType, fileName);
|
||||
}
|
||||
|
||||
[HttpPatch("{employeeId:int}/documents/{documentId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> SetDocumentStatus(
|
||||
int employeeId, int documentId, [FromBody] UpdateEmployeeDocumentStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
await _documents.SetStatusAsync(employeeId, documentId, request.Status, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
|
||||
/// <summary>EmploymentType master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
|
||||
[Route("api/v1/employment-types")]
|
||||
public sealed class EmploymentTypesController : ApiControllerBase
|
||||
{
|
||||
private readonly IEmploymentTypeService _employmentTypes;
|
||||
|
||||
public EmploymentTypesController(IEmploymentTypeService employmentTypes) => _employmentTypes = employmentTypes;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<EmploymentTypeDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<EmploymentTypeDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||
=> Ok(await _employmentTypes.ListAsync(query, status, ct));
|
||||
|
||||
[HttpGet("{employmentTypeId:int}")]
|
||||
[ProducesResponseType(typeof(EmploymentTypeDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<EmploymentTypeDto>> GetById(int employmentTypeId, CancellationToken ct)
|
||||
{
|
||||
var result = await _employmentTypes.GetAsync(employmentTypeId, ct);
|
||||
if (result is null) return NotFound();
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(EmploymentTypeDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<EmploymentTypeDto>> Create([FromBody] CreateEmploymentTypeRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _employmentTypes.CreateAsync(request, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Created($"/api/v1/employment-types/{result.Value.EmploymentTypeId}", result.Value);
|
||||
}
|
||||
|
||||
[HttpPut("{employmentTypeId:int}")]
|
||||
[ProducesResponseType(typeof(EmploymentTypeDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<EmploymentTypeDto>> Update(int employmentTypeId, [FromBody] UpdateEmploymentTypeRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _employmentTypes.UpdateAsync(employmentTypeId, request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPatch("{employmentTypeId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> SetStatus(int employmentTypeId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
await _employmentTypes.SetStatusAsync(employmentTypeId, request.Status, ct);
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
|
||||
/// <summary>Staff document-type catalog ("DocType") endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
|
||||
[Route("api/v1/hr-document-types")]
|
||||
public sealed class HrDocumentTypesController : ApiControllerBase
|
||||
{
|
||||
private readonly IHrDocumentTypeService _types;
|
||||
|
||||
public HrDocumentTypesController(IHrDocumentTypeService types) => _types = types;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<HrDocumentTypeDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<HrDocumentTypeDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||
=> Ok(await _types.ListAsync(query, status, ct));
|
||||
|
||||
[HttpGet("{hrDocumentTypeId:int}")]
|
||||
[ProducesResponseType(typeof(HrDocumentTypeDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<HrDocumentTypeDto>> GetById(int hrDocumentTypeId, CancellationToken ct)
|
||||
{
|
||||
var result = await _types.GetAsync(hrDocumentTypeId, ct);
|
||||
if (result is null) return NotFound();
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(HrDocumentTypeDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<HrDocumentTypeDto>> Create([FromBody] CreateHrDocumentTypeRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _types.CreateAsync(request, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Created($"/api/v1/hr-document-types/{result.Value.HrDocumentTypeId}", result.Value);
|
||||
}
|
||||
|
||||
[HttpPut("{hrDocumentTypeId:int}")]
|
||||
[ProducesResponseType(typeof(HrDocumentTypeDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<HrDocumentTypeDto>> Update(int hrDocumentTypeId, [FromBody] UpdateHrDocumentTypeRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _types.UpdateAsync(hrDocumentTypeId, request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPatch("{hrDocumentTypeId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> SetStatus(int hrDocumentTypeId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
await _types.SetStatusAsync(hrDocumentTypeId, request.Status, ct);
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
|
||||
/// <summary>Read-only HRM reports (FR-HR-RPT, docs/13-BACKEND-HRM-API.md §6). No new entities — aggregation over existing tables.</summary>
|
||||
[Route("api/v1/reports/hrm")]
|
||||
public sealed class HrReportsController : ApiControllerBase
|
||||
{
|
||||
private readonly IHrReportService _reports;
|
||||
|
||||
public HrReportsController(IHrReportService reports) => _reports = reports;
|
||||
|
||||
[HttpGet("attendance-summary")]
|
||||
[ProducesResponseType(typeof(List<AttendanceSummaryRowDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<AttendanceSummaryRowDto>>> AttendanceSummary(
|
||||
[FromQuery] int periodYear, [FromQuery] int periodMonth, [FromQuery] int? departmentId, CancellationToken ct)
|
||||
=> Ok(await _reports.AttendanceSummaryAsync(periodYear, periodMonth, departmentId, ct));
|
||||
|
||||
[HttpGet("overtime")]
|
||||
[ProducesResponseType(typeof(List<OvertimeReportRowDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<OvertimeReportRowDto>>> Overtime(
|
||||
[FromQuery] int periodYear, [FromQuery] int periodMonth, CancellationToken ct)
|
||||
=> Ok(await _reports.OvertimeReportAsync(periodYear, periodMonth, ct));
|
||||
|
||||
[HttpGet("late-arrivals")]
|
||||
[ProducesResponseType(typeof(List<LateArrivalReportRowDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<LateArrivalReportRowDto>>> LateArrivals(
|
||||
[FromQuery] int periodYear, [FromQuery] int periodMonth, CancellationToken ct)
|
||||
=> Ok(await _reports.LateArrivalReportAsync(periodYear, periodMonth, ct));
|
||||
|
||||
[HttpGet("payroll-register")]
|
||||
[ProducesResponseType(typeof(List<PayrollRegisterRowDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<PayrollRegisterRowDto>>> PayrollRegister([FromQuery] int payrollRunId, CancellationToken ct)
|
||||
=> Ok(await _reports.PayrollRegisterAsync(payrollRunId, ct));
|
||||
|
||||
[HttpGet("salary-history")]
|
||||
[ProducesResponseType(typeof(List<SalaryHistoryRowDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<SalaryHistoryRowDto>>> SalaryHistory([FromQuery] int employeeId, CancellationToken ct)
|
||||
=> Ok(await _reports.SalaryHistoryAsync(employeeId, ct));
|
||||
|
||||
[HttpGet("leave-balances")]
|
||||
[ProducesResponseType(typeof(List<LeaveBalanceReportRowDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<LeaveBalanceReportRowDto>>> LeaveBalances([FromQuery] int year, CancellationToken ct)
|
||||
=> Ok(await _reports.LeaveBalanceReportAsync(year, ct));
|
||||
|
||||
[HttpGet("document-expiry")]
|
||||
[ProducesResponseType(typeof(List<DocumentExpiryReportRowDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<DocumentExpiryReportRowDto>>> DocumentExpiry([FromQuery] int withinDays, CancellationToken ct)
|
||||
=> Ok(await _reports.DocumentExpiryReportAsync(withinDays, ct));
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
|
||||
/// <summary>Leave request endpoints (docs/13-BACKEND-HRM-API.md §5).</summary>
|
||||
[Route("api/v1/leave-requests")]
|
||||
public sealed class LeaveRequestsController : ApiControllerBase
|
||||
{
|
||||
private readonly ILeaveRequestService _requests;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
|
||||
public LeaveRequestsController(ILeaveRequestService requests, ICurrentUser currentUser)
|
||||
{
|
||||
_requests = requests;
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<LeaveRequestDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<LeaveRequestDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] int? employeeId, [FromQuery] LeaveRequestStatus? status, CancellationToken ct)
|
||||
=> Ok(await _requests.ListAsync(query, employeeId, status, ct));
|
||||
|
||||
[HttpGet("{leaveRequestId:int}")]
|
||||
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<LeaveRequestDto>> GetById(int leaveRequestId, CancellationToken ct)
|
||||
{
|
||||
var result = await _requests.GetAsync(leaveRequestId, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<LeaveRequestDto>> Create([FromBody] CreateLeaveRequestRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _requests.CreateAsync(request, _currentUser.AuditUserId, ct);
|
||||
return Created($"/api/v1/leave-requests/{result.LeaveRequestId}", result);
|
||||
}
|
||||
|
||||
[HttpPost("{leaveRequestId:int}/submit")]
|
||||
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<LeaveRequestDto>> Submit(int leaveRequestId, CancellationToken ct)
|
||||
=> Ok(await _requests.SubmitAsync(leaveRequestId, ct));
|
||||
|
||||
[HttpPost("{leaveRequestId:int}/approve")]
|
||||
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<LeaveRequestDto>> Approve(int leaveRequestId, CancellationToken ct)
|
||||
=> Ok(await _requests.ApproveAsync(leaveRequestId, _currentUser.AuditUserId, ct));
|
||||
|
||||
[HttpPost("{leaveRequestId:int}/reject")]
|
||||
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<LeaveRequestDto>> Reject(int leaveRequestId, [FromBody] RejectLeaveRequestRequest request, CancellationToken ct)
|
||||
=> Ok(await _requests.RejectAsync(leaveRequestId, request.Reason, _currentUser.AuditUserId, ct));
|
||||
|
||||
[HttpPost("{leaveRequestId:int}/cancel")]
|
||||
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<LeaveRequestDto>> Cancel(int leaveRequestId, CancellationToken ct)
|
||||
=> Ok(await _requests.CancelAsync(leaveRequestId, ct));
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
|
||||
/// <summary>Leave type master endpoints (docs/13-BACKEND-HRM-API.md §5).</summary>
|
||||
[Route("api/v1/leave-types")]
|
||||
public sealed class LeaveTypesController : ApiControllerBase
|
||||
{
|
||||
private readonly ILeaveTypeService _leaveTypes;
|
||||
|
||||
public LeaveTypesController(ILeaveTypeService leaveTypes) => _leaveTypes = leaveTypes;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<LeaveTypeDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<LeaveTypeDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||
=> Ok(await _leaveTypes.ListAsync(query, status, ct));
|
||||
|
||||
[HttpGet("{leaveTypeId:int}")]
|
||||
[ProducesResponseType(typeof(LeaveTypeDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<LeaveTypeDto>> GetById(int leaveTypeId, CancellationToken ct)
|
||||
{
|
||||
var result = await _leaveTypes.GetAsync(leaveTypeId, ct);
|
||||
if (result is null) return NotFound();
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(LeaveTypeDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<LeaveTypeDto>> Create([FromBody] CreateLeaveTypeRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _leaveTypes.CreateAsync(request, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Created($"/api/v1/leave-types/{result.Value.LeaveTypeId}", result.Value);
|
||||
}
|
||||
|
||||
[HttpPut("{leaveTypeId:int}")]
|
||||
[ProducesResponseType(typeof(LeaveTypeDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<LeaveTypeDto>> Update(int leaveTypeId, [FromBody] UpdateLeaveTypeRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _leaveTypes.UpdateAsync(leaveTypeId, request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPatch("{leaveTypeId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> SetStatus(int leaveTypeId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
await _leaveTypes.SetStatusAsync(leaveTypeId, request.Status, ct);
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
|
||||
/// <summary>Payroll run endpoints (docs/13-BACKEND-HRM-API.md §6).</summary>
|
||||
[Route("api/v1/payroll-runs")]
|
||||
public sealed class PayrollRunsController : ApiControllerBase
|
||||
{
|
||||
private readonly IPayrollRunService _payrollRuns;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
|
||||
public PayrollRunsController(IPayrollRunService payrollRuns, ICurrentUser currentUser)
|
||||
{
|
||||
_payrollRuns = payrollRuns;
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<PayrollRunDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<PayrollRunDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] int? periodYear, [FromQuery] int? periodMonth,
|
||||
[FromQuery] PayrollRunStatus? status, CancellationToken ct)
|
||||
=> Ok(await _payrollRuns.ListAsync(query, periodYear, periodMonth, status, ct));
|
||||
|
||||
[HttpGet("{payrollRunId:int}")]
|
||||
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<PayrollRunDto>> GetById(int payrollRunId, CancellationToken ct)
|
||||
{
|
||||
var result = await _payrollRuns.GetAsync(payrollRunId, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("{payrollRunId:int}/lines")]
|
||||
[ProducesResponseType(typeof(List<PayrollLineDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<PayrollLineDto>>> ListLines(int payrollRunId, CancellationToken ct)
|
||||
=> Ok(await _payrollRuns.ListLinesAsync(payrollRunId, ct));
|
||||
|
||||
[HttpGet("{payrollRunId:int}/lines/{lineId:int}")]
|
||||
[ProducesResponseType(typeof(PayrollLineDetailDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<PayrollLineDetailDto>> GetLine(int payrollRunId, int lineId, CancellationToken ct)
|
||||
{
|
||||
var result = await _payrollRuns.GetLineAsync(payrollRunId, lineId, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<PayrollRunDto>> Generate([FromBody] GeneratePayrollRunRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _payrollRuns.GenerateAsync(request, _currentUser.AuditUserId, ct);
|
||||
return Created($"/api/v1/payroll-runs/{result.PayrollRunId}", result);
|
||||
}
|
||||
|
||||
[HttpPost("{payrollRunId:int}/approve")]
|
||||
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<PayrollRunDto>> Approve(int payrollRunId, CancellationToken ct)
|
||||
=> Ok(await _payrollRuns.ApproveAsync(payrollRunId, _currentUser.AuditUserId, ct));
|
||||
|
||||
[HttpPost("{payrollRunId:int}/lock")]
|
||||
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<PayrollRunDto>> Lock(int payrollRunId, CancellationToken ct)
|
||||
=> Ok(await _payrollRuns.LockAsync(payrollRunId, _currentUser.AuditUserId, ct));
|
||||
|
||||
[HttpPost("{payrollRunId:int}/unlock")]
|
||||
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<PayrollRunDto>> Unlock(int payrollRunId, [FromBody] UnlockPayrollRunRequest request, CancellationToken ct)
|
||||
=> Ok(await _payrollRuns.UnlockAsync(payrollRunId, request.Reason, _currentUser.AuditUserId, ct));
|
||||
|
||||
[HttpPost("{payrollRunId:int}/generate-payslips")]
|
||||
[ProducesResponseType(typeof(List<PayslipDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<List<PayslipDto>>> GeneratePayslips(int payrollRunId, CancellationToken ct)
|
||||
=> Ok(await _payrollRuns.GeneratePayslipsAsync(payrollRunId, ct));
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
|
||||
/// <summary>Effective-dated EPF/ETF settings (docs/13-BACKEND-HRM-API.md §6).</summary>
|
||||
[Route("api/v1/payroll-statutory-settings")]
|
||||
public sealed class PayrollStatutorySettingsController : ApiControllerBase
|
||||
{
|
||||
private readonly IPayrollStatutorySettingService _settings;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
|
||||
public PayrollStatutorySettingsController(IPayrollStatutorySettingService settings, ICurrentUser currentUser)
|
||||
{
|
||||
_settings = settings;
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(List<PayrollStatutorySettingDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<PayrollStatutorySettingDto>>> List(CancellationToken ct)
|
||||
=> Ok(await _settings.ListAsync(ct));
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(PayrollStatutorySettingDto), StatusCodes.Status201Created)]
|
||||
public async Task<ActionResult<PayrollStatutorySettingDto>> Create([FromBody] UpsertPayrollStatutorySettingRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _settings.CreateAsync(request, _currentUser.AuditUserId, ct);
|
||||
return Created($"/api/v1/payroll-statutory-settings/{result.PayrollStatutorySettingId}", result);
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
|
||||
/// <summary>Payslip retrieval + HTML print view (docs/13-BACKEND-HRM-API.md §6).</summary>
|
||||
[Route("api/v1/payslips")]
|
||||
public sealed class PayslipsController : ApiControllerBase
|
||||
{
|
||||
private readonly IPayslipService _payslips;
|
||||
|
||||
public PayslipsController(IPayslipService payslips) => _payslips = payslips;
|
||||
|
||||
[HttpGet("{payslipId:int}")]
|
||||
[ProducesResponseType(typeof(PayslipDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<PayslipDto>> GetById(int payslipId, CancellationToken ct)
|
||||
{
|
||||
var result = await _payslips.GetAsync(payslipId, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("{payslipId:int}/view")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> View(int payslipId, CancellationToken ct)
|
||||
{
|
||||
var html = await _payslips.RenderHtmlAsync(payslipId, ct);
|
||||
return html is null ? NotFound() : Content(html, "text/html");
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
|
||||
/// <summary>SalaryComponent master endpoints (docs/13-BACKEND-HRM-API.md §6).</summary>
|
||||
[Route("api/v1/salary-components")]
|
||||
public sealed class SalaryComponentsController : ApiControllerBase
|
||||
{
|
||||
private readonly ISalaryComponentService _components;
|
||||
|
||||
public SalaryComponentsController(ISalaryComponentService components) => _components = components;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<SalaryComponentDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<SalaryComponentDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||
=> Ok(await _components.ListAsync(query, status, ct));
|
||||
|
||||
[HttpGet("{salaryComponentId:int}")]
|
||||
[ProducesResponseType(typeof(SalaryComponentDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<SalaryComponentDto>> GetById(int salaryComponentId, CancellationToken ct)
|
||||
{
|
||||
var result = await _components.GetAsync(salaryComponentId, ct);
|
||||
if (result is null) return NotFound();
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(SalaryComponentDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<SalaryComponentDto>> Create([FromBody] CreateSalaryComponentRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _components.CreateAsync(request, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Created($"/api/v1/salary-components/{result.Value.SalaryComponentId}", result.Value);
|
||||
}
|
||||
|
||||
[HttpPut("{salaryComponentId:int}")]
|
||||
[ProducesResponseType(typeof(SalaryComponentDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<SalaryComponentDto>> Update(int salaryComponentId, [FromBody] UpdateSalaryComponentRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _components.UpdateAsync(salaryComponentId, request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPatch("{salaryComponentId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> SetStatus(int salaryComponentId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
await _components.SetStatusAsync(salaryComponentId, request.Status, ct);
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
|
||||
/// <summary>Configurable APIT-style tax slabs (docs/13-BACKEND-HRM-API.md §6).</summary>
|
||||
[Route("api/v1/tax-slabs")]
|
||||
public sealed class TaxSlabsController : ApiControllerBase
|
||||
{
|
||||
private readonly ITaxSlabService _taxSlabs;
|
||||
|
||||
public TaxSlabsController(ITaxSlabService taxSlabs) => _taxSlabs = taxSlabs;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(List<TaxSlabDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<TaxSlabDto>>> List(CancellationToken ct)
|
||||
=> Ok(await _taxSlabs.ListAsync(ct));
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(TaxSlabDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<TaxSlabDto>> Create([FromBody] CreateTaxSlabRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _taxSlabs.CreateAsync(request, ct);
|
||||
return Created($"/api/v1/tax-slabs/{result.TaxSlabId}", result);
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
|
||||
/// <summary>WorkShift master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
|
||||
[Route("api/v1/work-shifts")]
|
||||
public sealed class WorkShiftsController : ApiControllerBase
|
||||
{
|
||||
private readonly IWorkShiftService _shifts;
|
||||
|
||||
public WorkShiftsController(IWorkShiftService shifts) => _shifts = shifts;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<WorkShiftDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<WorkShiftDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||
=> Ok(await _shifts.ListAsync(query, status, ct));
|
||||
|
||||
[HttpGet("{workShiftId:int}")]
|
||||
[ProducesResponseType(typeof(WorkShiftDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<WorkShiftDto>> GetById(int workShiftId, CancellationToken ct)
|
||||
{
|
||||
var result = await _shifts.GetAsync(workShiftId, ct);
|
||||
if (result is null) return NotFound();
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(WorkShiftDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<WorkShiftDto>> Create([FromBody] CreateWorkShiftRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _shifts.CreateAsync(request, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Created($"/api/v1/work-shifts/{result.Value.WorkShiftId}", result.Value);
|
||||
}
|
||||
|
||||
[HttpPut("{workShiftId:int}")]
|
||||
[ProducesResponseType(typeof(WorkShiftDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<WorkShiftDto>> Update(int workShiftId, [FromBody] UpdateWorkShiftRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _shifts.UpdateAsync(workShiftId, request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPatch("{workShiftId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> SetStatus(int workShiftId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
await _shifts.SetStatusAsync(workShiftId, request.Status, ct);
|
||||
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,64 +0,0 @@
|
||||
using ERPCore.Dtos.Items;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Item photos. Same access as the item master itself.</summary>
|
||||
[Route("api/v1/items/{itemId:int}/images")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||
public sealed class ItemImagesController : ApiControllerBase
|
||||
{
|
||||
private readonly IItemImageService _images;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
|
||||
public ItemImagesController(IItemImageService images, ICurrentUser currentUser)
|
||||
{
|
||||
_images = images;
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(List<ItemImageDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<List<ItemImageDto>>> List(int itemId, CancellationToken ct)
|
||||
=> Ok(await _images.ListAsync(itemId, ct));
|
||||
|
||||
/// <summary>Upload one image (multipart <c>file</c>). 422 CONFIG_DISABLED while item images are off.</summary>
|
||||
[HttpPost]
|
||||
[RequestSizeLimit(ItemImageService.MaxSizeBytes + 64 * 1024)]
|
||||
[ProducesResponseType(typeof(ItemImageDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status413PayloadTooLarge)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<ItemImageDto>> Upload(int itemId, IFormFile file, CancellationToken ct)
|
||||
{
|
||||
await using var stream = file.OpenReadStream();
|
||||
var result = await _images.UploadAsync(itemId, stream, file.FileName, file.ContentType, _currentUser.AuditUserId, ct);
|
||||
return Created($"/api/v1/items/{itemId}/images/{result.ItemImageId}", result);
|
||||
}
|
||||
|
||||
/// <summary>The image bytes, served inline so an <img> can point straight at it.</summary>
|
||||
[HttpGet("{itemImageId:int}/content")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Content(int itemId, int itemImageId, CancellationToken ct)
|
||||
{
|
||||
var (content, contentType) = await _images.OpenAsync(itemId, itemImageId, ct);
|
||||
// Stored files are immutable (new upload ⇒ new id), so the browser may cache them.
|
||||
Response.Headers.CacheControl = "private, max-age=86400";
|
||||
return File(content, contentType);
|
||||
}
|
||||
|
||||
[HttpDelete("{itemImageId:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Delete(int itemId, int itemImageId, CancellationToken ct)
|
||||
{
|
||||
await _images.DeleteAsync(itemId, itemImageId, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.ItemTypes;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Item type master endpoints (docs/11-BACKEND-PHASE1.md §2.7) — the Color/Size/Material
|
||||
/// dimension names. <c>GET</c> is the reason this master exists: it populates the item
|
||||
/// builder's dropdown. Items never reference an item type; the chosen values are encoded
|
||||
/// into the client-generated SKU (docs/10 Part C.9).
|
||||
/// </summary>
|
||||
[Route("api/v1/item-types")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||
public sealed class ItemTypesController : ApiControllerBase
|
||||
{
|
||||
private readonly IItemTypeService _itemTypes;
|
||||
|
||||
public ItemTypesController(IItemTypeService itemTypes) => _itemTypes = itemTypes;
|
||||
|
||||
/// <summary>Feeds the frontend item-builder dropdown; filter <c>status=Active</c> for selectable rows.</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<ItemTypeDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<ItemTypeDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||
=> Ok(await _itemTypes.ListAsync(query, status, ct));
|
||||
|
||||
[HttpGet("{itemTypeId:int}")]
|
||||
[ProducesResponseType(typeof(ItemTypeDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<ItemTypeDto>> GetById(int itemTypeId, CancellationToken ct)
|
||||
{
|
||||
var result = await _itemTypes.GetAsync(itemTypeId, ct);
|
||||
if (result is null) return NotFound();
|
||||
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(ItemTypeDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<ItemTypeDto>> Create([FromBody] CreateItemTypeRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _itemTypes.CreateAsync(request, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Created($"/api/v1/item-types/{result.Value.ItemTypeId}", result.Value);
|
||||
}
|
||||
|
||||
[HttpPut("{itemTypeId:int}")]
|
||||
[ProducesResponseType(typeof(ItemTypeDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<ItemTypeDto>> Update(int itemTypeId, [FromBody] UpdateItemTypeRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _itemTypes.UpdateAsync(itemTypeId, request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
|
||||
[HttpPatch("{itemTypeId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> SetStatus(int itemTypeId, [FromBody] UpdateItemTypeStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
await _itemTypes.SetStatusAsync(itemTypeId, request.Status, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Items;
|
||||
@@ -10,7 +8,6 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Item master endpoints (docs/11-BACKEND-PHASE1.md §2.1–2.2).</summary>
|
||||
[Route("api/v1/items")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||
public sealed class ItemsController : ApiControllerBase
|
||||
{
|
||||
private readonly IItemService _items;
|
||||
@@ -24,38 +21,9 @@ public sealed class ItemsController : ApiControllerBase
|
||||
[FromQuery] PageQuery query,
|
||||
[FromQuery] EntityStatus? status,
|
||||
[FromQuery] int? categoryId,
|
||||
[FromQuery] int? subCategoryId,
|
||||
[FromQuery] int? brandId,
|
||||
[FromQuery] TrackingMode? trackingMode,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _items.ListAsync(query, status, categoryId, subCategoryId, brandId, trackingMode, ct));
|
||||
|
||||
/// <summary>Items list enriched with stock value, low-stock, last-purchase and
|
||||
/// trailing-30-day sales aggregates, for the Items overview table.</summary>
|
||||
[HttpGet("overview")]
|
||||
[ProducesResponseType(typeof(PagedResponse<ItemOverviewDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<ItemOverviewDto>>> Overview(
|
||||
[FromQuery] PageQuery query,
|
||||
[FromQuery] EntityStatus? status,
|
||||
[FromQuery] int? categoryId,
|
||||
[FromQuery] int? subCategoryId,
|
||||
[FromQuery] int? brandId,
|
||||
[FromQuery] TrackingMode? trackingMode,
|
||||
[FromQuery] StockNature? stockNature,
|
||||
[FromQuery] ItemStockFilter? stock,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _items.GetOverviewAsync(query, status, categoryId, subCategoryId, brandId, trackingMode, stockNature, stock, 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);
|
||||
}
|
||||
=> Ok(await _items.ListAsync(query, status, categoryId, trackingMode, ct));
|
||||
|
||||
/// <summary>Get a single item; returns an <c>ETag</c> for optimistic concurrency.</summary>
|
||||
[HttpGet("{itemId:int}")]
|
||||
@@ -111,4 +79,11 @@ public sealed class ItemsController : ApiControllerBase
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<ItemReorderSettingsDto>> UpdateReorder(int itemId, [FromBody] UpdateReorderRequest request, CancellationToken ct)
|
||||
=> Ok(await _items.UpdateReorderAsync(itemId, request, ct));
|
||||
|
||||
/// <summary>Replace the item's UOM conversions (FR-MD-02).</summary>
|
||||
[HttpPut("{itemId:int}/uom-conversions")]
|
||||
[ProducesResponseType(typeof(ItemUomConversionsDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<ItemUomConversionsDto>> UpdateUomConversions(int itemId, [FromBody] UpdateUomConversionsRequest request, CancellationToken ct)
|
||||
=> Ok(await _items.UpdateUomConversionsAsync(itemId, request, ct));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Audit;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Services.Interfaces;
|
||||
@@ -12,7 +10,6 @@ namespace ERPCore.Controllers;
|
||||
/// Data only — no posting in Phase 1.
|
||||
/// </summary>
|
||||
[Route("api/v1/journal-entries")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "ledgers")]
|
||||
public sealed class JournalEntriesController : ApiControllerBase
|
||||
{
|
||||
private readonly IAuditService _audit;
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Dtos.Rbac;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only sidebar nav tree, used by the Role permission-assignment checkbox
|
||||
/// UI and by `GET /auth/me` (see AuthController) to resolve a role's visible codes.
|
||||
/// NavItem/SubNavItem rows are seeded (NavItemConfiguration/SubNavItemConfiguration)
|
||||
/// to match the frontend's hardcoded sidebar — not admin-editable in this phase.
|
||||
/// </summary>
|
||||
[Route("api/v1/nav")]
|
||||
public sealed class NavController : ApiControllerBase
|
||||
{
|
||||
private readonly IRepository<NavItem> _navItems;
|
||||
|
||||
public NavController(IRepository<NavItem> navItems) => _navItems = navItems;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(List<NavItemDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<NavItemDto>>> GetTree(CancellationToken ct)
|
||||
{
|
||||
var items = await _navItems.Query().AsNoTracking()
|
||||
.Include(n => n.Children)
|
||||
.OrderBy(n => n.SortOrder)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var dto = items.Select(n => new NavItemDto(
|
||||
n.NavItemId, n.Code, n.Label, n.Icon, n.Href, n.SortOrder,
|
||||
n.Children.OrderBy(c => c.SortOrder)
|
||||
.Select(c => new SubNavItemDto(c.SubNavItemId, c.Code, c.Label, c.Icon, c.Href, c.SortOrder))
|
||||
.ToList())).ToList();
|
||||
|
||||
return Ok(dto);
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
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,48 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Config;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Product configuration endpoints (docs/11-BACKEND-PHASE1.md §2.8) — the singleton
|
||||
/// feature gate for subcategories/brands/item-types.
|
||||
/// <para>
|
||||
/// <b>Authorization:</b> writes are admitted by the inherited ERP door policy only.
|
||||
/// A dedicated <c>CONFIG_MANAGE</c> permission is reserved for when per-endpoint RBAC
|
||||
/// lands (FR-X-01, currently deferred) — at that point this action gets the attribute
|
||||
/// with no other change. Until then any ERP-admitted user can flip these flags; that is
|
||||
/// the accepted Phase-1 posture, consistent with every other endpoint.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Route("api/v1/product-config")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||
public sealed class ProductConfigController : ApiControllerBase
|
||||
{
|
||||
private readonly IProductConfigService _config;
|
||||
|
||||
public ProductConfigController(IProductConfigService config) => _config = config;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(ProductConfigDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ProductConfigDto>> Get(CancellationToken ct)
|
||||
{
|
||||
var result = await _config.GetAsync(ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPut]
|
||||
[ProducesResponseType(typeof(ProductConfigDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<ProductConfigDto>> Update(
|
||||
[FromBody] UpdateProductConfigRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _config.UpdateAsync(request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}}
|
||||
@@ -1,183 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Production;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Production run endpoints (docs/30-BACKEND-PHASE2.md §D.2–D.3).</summary>
|
||||
[Route("api/v1/production-runs")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "production")]
|
||||
public sealed class ProductionRunsController : ApiControllerBase
|
||||
{
|
||||
private readonly IProductionRunService _runs;
|
||||
|
||||
public ProductionRunsController(IProductionRunService runs) => _runs = runs;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<RunSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<RunSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query,
|
||||
[FromQuery] ProductionRunStatus? status,
|
||||
[FromQuery] int? templateId,
|
||||
[FromQuery] int? warehouseId,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _runs.ListAsync(query, status, templateId, warehouseId, ct));
|
||||
|
||||
/// <summary>Produced-items report (FR-MFG-13): one row per run that has produced
|
||||
/// something, plus a summary total over every filtered row.</summary>
|
||||
[HttpGet("produced")]
|
||||
[ProducesResponseType(typeof(ProducedListResponseDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ProducedListResponseDto>> ListProduced(
|
||||
[FromQuery] DateOnly? from,
|
||||
[FromQuery] DateOnly? to,
|
||||
[FromQuery] int? templateId,
|
||||
[FromQuery] int? warehouseId,
|
||||
[FromQuery] ProductionRunStatus? status,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _runs.ListProducedAsync(from, to, templateId, warehouseId, status, ct));
|
||||
|
||||
[HttpGet("{runId:int}")]
|
||||
[ProducesResponseType(typeof(RunGraphDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<RunGraphDto>> GetById(int runId, CancellationToken ct)
|
||||
{
|
||||
var result = await _runs.GetAsync(runId, ct);
|
||||
if (result is null) return NotFound();
|
||||
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(RunGraphDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<RunGraphDto>> Create([FromBody] CreateRunRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _runs.CreateAsync(request, ct);
|
||||
|
||||
SetETag(result.RowVersion);
|
||||
return Created($"/api/v1/production-runs/{result.Value.RunId}", result.Value);
|
||||
}
|
||||
|
||||
[HttpPut("{runId:int}/stages/{runStageId:int}/quantities")]
|
||||
[ProducesResponseType(typeof(RunStageDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<RunStageDto>> UpdateQuantities(
|
||||
int runId, int runStageId, [FromBody] UpdateStageQuantitiesRequest request, CancellationToken ct)
|
||||
=> Ok(await _runs.UpdateStageQuantitiesAsync(runId, runStageId, request, ct));
|
||||
|
||||
// --- stage actions (docs/30 §D.3) ---------------------------------------
|
||||
//
|
||||
// Idempotency-Key is accepted on every action to match the Phase-1 contract (docs/11
|
||||
// §1.6) but, as in GrnService.ConfirmAsync, it is not stored. Replay safety comes from
|
||||
// the status guards instead: a double-fire finds the stage already moved on and gets a
|
||||
// 409, which docs/21 §6 tells the client to treat as a silent refetch. Recorded as a
|
||||
// deviation from §D.3's "idempotency-key honored".
|
||||
|
||||
[HttpPost("{runId:int}/stages/{runStageId:int}/start")]
|
||||
[ProducesResponseType(typeof(StartStageResultDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<StartStageResultDto>> Start(
|
||||
int runId, int runStageId,
|
||||
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _runs.StartStageAsync(runId, runStageId, ct));
|
||||
|
||||
[HttpPost("{runId:int}/stages/{runStageId:int}/complete")]
|
||||
[ProducesResponseType(typeof(RunStageDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<RunStageDto>> Complete(
|
||||
int runId, int runStageId, [FromBody] CompleteStageRequest request,
|
||||
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _runs.CompleteStageAsync(runId, runStageId, request, ct));
|
||||
|
||||
[HttpPost("{runId:int}/stages/{runStageId:int}/approve")]
|
||||
[ProducesResponseType(typeof(ApproveStageResultDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<ApproveStageResultDto>> Approve(
|
||||
int runId, int runStageId, [FromBody] ApproveStageRequest? request,
|
||||
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _runs.ApproveStageAsync(runId, runStageId, request ?? new ApproveStageRequest(), ct));
|
||||
|
||||
[HttpPost("{runId:int}/stages/{runStageId:int}/transfer")]
|
||||
[ProducesResponseType(typeof(TransferResultDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<TransferResultDto>> Transfer(
|
||||
int runId, int runStageId, [FromBody] TransferRemainderRequest request,
|
||||
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _runs.TransferAsync(runId, runStageId, request, ct));
|
||||
|
||||
[HttpPost("{runId:int}/inputs/{runInputId:int}/return-leftover")]
|
||||
[ProducesResponseType(typeof(ReturnLeftoverResultDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<ReturnLeftoverResultDto>> ReturnLeftover(
|
||||
int runId, int runInputId, [FromBody] ReturnLeftoverRequest request,
|
||||
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _runs.ReturnLeftoverAsync(runId, runInputId, request, ct));
|
||||
|
||||
[HttpPost("{runId:int}/stages/{runStageId:int}/reject-intake")]
|
||||
[ProducesResponseType(typeof(RejectIntakeResultDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<RejectIntakeResultDto>> RejectIntake(
|
||||
int runId, int runStageId, [FromBody] RejectRequest? request,
|
||||
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _runs.RejectIntakeAsync(runId, runStageId, request ?? new RejectRequest(), ct));
|
||||
|
||||
/// <summary>Terminal reject — resets the whole run for a rework pass (FR-MFG-16).</summary>
|
||||
[HttpPost("{runId:int}/stages/{runStageId:int}/reject")]
|
||||
[ProducesResponseType(typeof(TerminalRejectResultDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<TerminalRejectResultDto>> Reject(
|
||||
int runId, int runStageId, [FromBody] RejectRequest? request,
|
||||
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _runs.RejectTerminalAsync(runId, runStageId, request ?? new RejectRequest(), ct));
|
||||
|
||||
[HttpPost("{runId:int}/cancel")]
|
||||
[ProducesResponseType(typeof(CancelRunResultDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<CancelRunResultDto>> Cancel(
|
||||
int runId, [FromBody] CancelRunRequest request,
|
||||
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _runs.CancelAsync(runId, request, ct));
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Production;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Production template endpoints (docs/30-BACKEND-PHASE2.md §D.1).</summary>
|
||||
[Route("api/v1/production-templates")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "production")]
|
||||
public sealed class ProductionTemplatesController : ApiControllerBase
|
||||
{
|
||||
private readonly IProductionTemplateService _templates;
|
||||
|
||||
public ProductionTemplatesController(IProductionTemplateService templates) => _templates = templates;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<TemplateSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<TemplateSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||
=> Ok(await _templates.ListAsync(query, status, ct));
|
||||
|
||||
[HttpGet("{templateId:int}")]
|
||||
[ProducesResponseType(typeof(TemplateGraphDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<TemplateGraphDto>> GetById(int templateId, CancellationToken ct)
|
||||
{
|
||||
var result = await _templates.GetAsync(templateId, ct);
|
||||
if (result is null) return NotFound();
|
||||
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(TemplateGraphDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<TemplateGraphDto>> Create(
|
||||
[FromBody] SaveTemplateRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _templates.CreateAsync(request, ct);
|
||||
|
||||
SetETag(result.RowVersion);
|
||||
return Created($"/api/v1/production-templates/{result.Value.TemplateId}", result.Value);
|
||||
}
|
||||
|
||||
[HttpPut("{templateId:int}")]
|
||||
[ProducesResponseType(typeof(TemplateGraphDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<TemplateGraphDto>> Update(
|
||||
int templateId, [FromBody] SaveTemplateRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _templates.UpdateAsync(templateId, request, expected, ct);
|
||||
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPatch("{templateId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> SetStatus(
|
||||
int templateId, [FromBody] UpdateTemplateStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
await _templates.SetStatusAsync(templateId, request.Status, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
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,5 +1,3 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Procurement;
|
||||
@@ -10,7 +8,6 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Purchase-order endpoints (docs/11 §3.3).</summary>
|
||||
[Route("api/v1/purchase-orders")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "procurement")]
|
||||
public sealed class PurchaseOrdersController : ApiControllerBase
|
||||
{
|
||||
private readonly IPurchaseOrderService _pos;
|
||||
@@ -35,7 +32,7 @@ public sealed class PurchaseOrdersController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
/// <summary>Create a PO — non-draft is issued straight into PendingApproval (FR-PROC-04). Totals computed server-side.</summary>
|
||||
/// <summary>Create a PO — auto-approved on creation in Phase 1 (FR-PROC-04). Totals computed server-side.</summary>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
@@ -60,48 +57,13 @@ public sealed class PurchaseOrdersController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
/// <summary>Submit a Draft PO (Draft → PendingApproval). 409 PO_NOT_EDITABLE if not Draft.</summary>
|
||||
[HttpPost("{poId:int}/submit")]
|
||||
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<PurchaseOrderDto>> Submit(int poId, CancellationToken ct)
|
||||
=> Ok(await _pos.SubmitAsync(poId, ct));
|
||||
|
||||
/// <summary>Delete a PO — permitted only while Draft (409 PO_NOT_EDITABLE otherwise).</summary>
|
||||
[HttpDelete("{poId:int}")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "procurement" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> Delete(int poId, CancellationToken ct)
|
||||
{
|
||||
await _pos.DeleteAsync(poId, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// <summary>Approve — no-op in Phase 1 (POs auto-approve); transitions PendingApproval→Approved when enabled.</summary>
|
||||
[HttpPost("{poId:int}/approve")]
|
||||
[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>> Approve(int poId, CancellationToken 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>
|
||||
[HttpPost("{poId:int}/cancel")]
|
||||
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Procurement;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -9,30 +6,12 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Purchase-return endpoints (docs/11 §3.4).</summary>
|
||||
[Route("api/v1/purchase-returns")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "procurement")]
|
||||
public sealed class PurchaseReturnsController : ApiControllerBase
|
||||
{
|
||||
private readonly IPurchaseReturnService _returns;
|
||||
|
||||
public PurchaseReturnsController(IPurchaseReturnService returns) => _returns = returns;
|
||||
|
||||
/// <summary>List posted returns, newest first.</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<PurchaseReturnSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<PurchaseReturnSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] int? vendorId, [FromQuery] int? warehouseId, CancellationToken ct)
|
||||
=> Ok(await _returns.ListAsync(query, vendorId, warehouseId, ct));
|
||||
|
||||
/// <summary>Get one return with its lines and the ledger entries it posted.</summary>
|
||||
[HttpGet("{returnId:int}")]
|
||||
[ProducesResponseType(typeof(PurchaseReturnDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<PurchaseReturnDto>> 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 (outbound movement). 409 if return exceeds available stock.</summary>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(PurchaseReturnDto), StatusCodes.Status201Created)]
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Procurement;
|
||||
using ERPCore.Services.Interfaces;
|
||||
@@ -10,7 +7,6 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Purchase-requisition endpoints (docs/11 §3.1).</summary>
|
||||
[Route("api/v1/requisitions")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "procurement")]
|
||||
public sealed class RequisitionsController : ApiControllerBase
|
||||
{
|
||||
private readonly IRequisitionService _requisitions;
|
||||
@@ -19,9 +15,8 @@ public sealed class RequisitionsController : ApiControllerBase
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<RequisitionSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<RequisitionSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] RequisitionStatus? status, CancellationToken ct)
|
||||
=> Ok(await _requisitions.ListAsync(query, status, ct));
|
||||
public async Task<ActionResult<PagedResponse<RequisitionSummaryDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
|
||||
=> Ok(await _requisitions.ListAsync(query, ct));
|
||||
|
||||
[HttpGet("{requisitionId:int}")]
|
||||
[ProducesResponseType(typeof(RequisitionDto), StatusCodes.Status200OK)]
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Procurement;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -10,20 +6,12 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>RFQ & quotation endpoints (docs/11 §3.2).</summary>
|
||||
[Route("api/v1/rfqs")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "procurement")]
|
||||
public sealed class RfqsController : ApiControllerBase
|
||||
{
|
||||
private readonly IRfqService _rfqs;
|
||||
|
||||
public RfqsController(IRfqService rfqs) => _rfqs = rfqs;
|
||||
|
||||
/// <summary>List RFQs, newest first.</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<RfqSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<RfqSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] RfqStatus? status, CancellationToken ct)
|
||||
=> Ok(await _rfqs.ListAsync(query, status, ct));
|
||||
|
||||
[HttpGet("{rfqId:int}")]
|
||||
[ProducesResponseType(typeof(RfqDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Rbac;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Role CRUD + permission assignment (mirrors AuthHex's Role; see docs/10 C.9).</summary>
|
||||
[Route("api/v1/roles")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "settings")]
|
||||
public sealed class RolesController : ApiControllerBase
|
||||
{
|
||||
private readonly IRoleService _roles;
|
||||
|
||||
public RolesController(IRoleService roles) => _roles = roles;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<RoleDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<RoleDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||
=> Ok(await _roles.ListAsync(query, status, ct));
|
||||
|
||||
[HttpGet("{roleId:int}")]
|
||||
[ProducesResponseType(typeof(RoleDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<RoleDto>> GetById(int roleId, CancellationToken ct)
|
||||
{
|
||||
var result = await _roles.GetAsync(roleId, ct);
|
||||
if (result is null) return NotFound();
|
||||
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(RoleDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<RoleDto>> Create([FromBody] CreateRoleRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _roles.CreateAsync(request, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Created($"/api/v1/roles/{result.Value.RoleId}", result.Value);
|
||||
}
|
||||
|
||||
[HttpPut("{roleId:int}")]
|
||||
[ProducesResponseType(typeof(RoleDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<RoleDto>> Update(int roleId, [FromBody] UpdateRoleRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _roles.UpdateAsync(roleId, request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPatch("{roleId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> SetStatus(int roleId, [FromBody] UpdateRoleStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
await _roles.SetStatusAsync(roleId, request.Status, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{roleId:int}")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "settings" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> Delete(int roleId, CancellationToken ct)
|
||||
{
|
||||
await _roles.DeleteAsync(roleId, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("{roleId:int}/permissions")]
|
||||
[ProducesResponseType(typeof(RolePermissionsDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<RolePermissionsDto>> GetPermissions(int roleId, CancellationToken ct)
|
||||
=> Ok(await _roles.GetPermissionsAsync(roleId, ct));
|
||||
|
||||
[HttpPut("{roleId:int}/permissions")]
|
||||
[ProducesResponseType(typeof(RolePermissionsDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<RolePermissionsDto>> AssignPermissions(
|
||||
int roleId, [FromBody] AssignRolePermissionsRequest request, CancellationToken ct)
|
||||
=> Ok(await _roles.AssignPermissionsAsync(roleId, request, ct));
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Config;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Sales configuration endpoints — the singleton "allow cash customer" toggle.
|
||||
/// <para>
|
||||
/// <b>Authorization:</b> gated on the Sales nav policy (the New Sale form has to read it), same
|
||||
/// Phase-1 posture as <see cref="BranchConfigController"/> — any Sales user can flip the flag
|
||||
/// until per-endpoint RBAC lands.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Route("api/v1/sales-config")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "sales")]
|
||||
public sealed class SalesConfigController : ApiControllerBase
|
||||
{
|
||||
private readonly ISalesConfigService _config;
|
||||
|
||||
public SalesConfigController(ISalesConfigService config) => _config = config;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(SalesConfigDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<SalesConfigDto>> Get(CancellationToken ct)
|
||||
{
|
||||
var result = await _config.GetAsync(ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPut]
|
||||
[ProducesResponseType(typeof(SalesConfigDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<SalesConfigDto>> Update(
|
||||
[FromBody] UpdateSalesConfigRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _config.UpdateAsync(request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
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,176 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Sales;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
[Route("api/v1/sales-invoices")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "sales")]
|
||||
public sealed class SalesInvoicesController : ApiControllerBase
|
||||
{
|
||||
private readonly ISalesInvoiceService _invoices;
|
||||
private readonly ISalesInvoicePaymentService _payments;
|
||||
private readonly ISalesPricingService _pricing;
|
||||
private readonly ISalesDomainService _sales;
|
||||
private readonly IStockService _stock;
|
||||
|
||||
public SalesInvoicesController(
|
||||
ISalesInvoiceService invoices, ISalesInvoicePaymentService payments, ISalesPricingService pricing,
|
||||
ISalesDomainService sales, IStockService stock)
|
||||
{
|
||||
_invoices = invoices;
|
||||
_payments = payments;
|
||||
_pricing = pricing;
|
||||
_sales = sales;
|
||||
_stock = stock;
|
||||
}
|
||||
|
||||
// Lets the New Sale form warn, per line, when qty exceeds the stock a sale can issue — under
|
||||
// the "sales" policy, since sales users may not hold the "stock" nav permission. Non-stocked
|
||||
// items (services) are never short, so they report IsStocked = false and no limit.
|
||||
[HttpGet("stock-availability")]
|
||||
[ProducesResponseType(typeof(SalesStockAvailabilityDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<SalesStockAvailabilityDto>> StockAvailability(
|
||||
[FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
|
||||
{
|
||||
bool isStocked;
|
||||
try
|
||||
{
|
||||
isStocked = await _sales.IsStockedItemAsync(itemId, ct);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
var available = isStocked ? (await _stock.GetOnHandAsync(itemId, warehouseId, ct)).Available : 0m;
|
||||
return Ok(new SalesStockAvailabilityDto(itemId, warehouseId, isStocked, available));
|
||||
}
|
||||
|
||||
// Feeds New Sale's "Add item" dropdown with each item's issuable stock at the sale's warehouse.
|
||||
// Under "sales" for the same reason as above; items absent from the list have no stock there.
|
||||
[HttpGet("stock-summary")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<SalesItemStockDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IReadOnlyList<SalesItemStockDto>>> StockSummary([FromQuery] int warehouseId, CancellationToken ct)
|
||||
{
|
||||
var available = await _stock.GetAvailableByItemAsync(warehouseId, ct);
|
||||
return Ok(available.Select(kv => new SalesItemStockDto(kv.Key, kv.Value)).ToList());
|
||||
}
|
||||
|
||||
// Lets the New Sale form pre-fill Unit price for items with no flat selling price, using the
|
||||
// same resolution the server applies on save.
|
||||
[HttpGet("suggested-price")]
|
||||
[ProducesResponseType(typeof(SalesSuggestedPriceDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<SalesSuggestedPriceDto>> SuggestedPrice(
|
||||
[FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var price = await _pricing.ResolveAsync(itemId, warehouseId, null, false, ct);
|
||||
return Ok(new SalesSuggestedPriceDto(itemId, warehouseId, price.UnitPrice, price.PriceSource));
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<SalesInvoiceSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<SalesInvoiceSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query,
|
||||
[FromQuery] SalesInvoiceStatus? status,
|
||||
[FromQuery] int? customerId,
|
||||
[FromQuery] int? warehouseId,
|
||||
[FromQuery] int? branchId,
|
||||
[FromQuery] DateOnly? from,
|
||||
[FromQuery] DateOnly? to,
|
||||
[FromQuery] int? createdBy,
|
||||
[FromQuery] bool? paid,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, from, to, createdBy, paid, ct));
|
||||
|
||||
[HttpGet("{salesInvoiceId:int}")]
|
||||
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<SalesInvoiceDto>> GetById(int salesInvoiceId, CancellationToken ct)
|
||||
{
|
||||
var result = await _invoices.GetAsync(salesInvoiceId, ct);
|
||||
if (result is null) return NotFound();
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpGet("{salesInvoiceId:int}/posting-check")]
|
||||
[ProducesResponseType(typeof(SalesInvoicePostingCheckDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<SalesInvoicePostingCheckDto>> PostingCheck(int salesInvoiceId, CancellationToken ct)
|
||||
=> Ok(await _invoices.CheckPostingAsync(salesInvoiceId, ct));
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status201Created)]
|
||||
public async Task<ActionResult<SalesInvoiceDto>> Create([FromBody] CreateSalesInvoiceRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _invoices.CreateAsync(request, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Created($"/api/v1/sales-invoices/{result.Value.SalesInvoiceId}", result.Value);
|
||||
}
|
||||
|
||||
[HttpPut("{salesInvoiceId:int}")]
|
||||
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<SalesInvoiceDto>> Update(int salesInvoiceId, [FromBody] UpdateSalesInvoiceRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _invoices.UpdateAsync(salesInvoiceId, request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost("{salesInvoiceId:int}/post")]
|
||||
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<SalesInvoiceDto>> Post(int salesInvoiceId, CancellationToken ct)
|
||||
=> Ok(await _invoices.PostAsync(salesInvoiceId, ct));
|
||||
|
||||
[HttpPost("{salesInvoiceId:int}/cancel")]
|
||||
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<SalesInvoiceDto>> Cancel(int salesInvoiceId, CancellationToken ct)
|
||||
=> Ok(await _invoices.CancelAsync(salesInvoiceId, ct));
|
||||
|
||||
/// <summary>Pay the customer's balance against this invoice, in full or in installments; posts a real GL journal entry.</summary>
|
||||
[HttpPost("{salesInvoiceId:int}/payments")]
|
||||
[ProducesResponseType(typeof(SalesInvoicePaymentDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<SalesInvoicePaymentDto>> Pay(int salesInvoiceId, [FromBody] CreateSalesInvoicePaymentRequest request, CancellationToken ct)
|
||||
{
|
||||
var dto = await _payments.PayAsync(salesInvoiceId, request, ct);
|
||||
return Created($"/api/v1/sales-invoices/{salesInvoiceId}/payments/{dto.SalesInvoicePaymentId}", dto);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pay several Posted invoices of one (non-cash) customer at once, split across payment lines;
|
||||
/// applied oldest invoice first under a single GL journal entry. Returns one payment per split.
|
||||
/// </summary>
|
||||
[HttpPost("payments/bulk")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<SalesInvoicePaymentDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<IReadOnlyList<SalesInvoicePaymentDto>>> PayBulk([FromBody] CreateBulkSalesInvoicePaymentRequest request, CancellationToken ct)
|
||||
=> Ok(await _payments.PayBulkAsync(request, ct));
|
||||
|
||||
/// <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,38 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Sales;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
[Route("api/v1/reports/sales")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "sales")]
|
||||
public sealed class SalesReportsController : ApiControllerBase
|
||||
{
|
||||
private readonly ISalesReportService _reports;
|
||||
|
||||
public SalesReportsController(ISalesReportService reports) => _reports = reports;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<SalesReportDefinitionDto>), StatusCodes.Status200OK)]
|
||||
public ActionResult<IReadOnlyList<SalesReportDefinitionDto>> ListReports()
|
||||
=> Ok(_reports.ListReports());
|
||||
|
||||
[HttpGet("{reportId}")]
|
||||
[ProducesResponseType(typeof(SalesReportDefinitionDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult<SalesReportDefinitionDto> GetReport(string reportId)
|
||||
{
|
||||
var report = _reports.GetReport(reportId);
|
||||
return report is null ? NotFound() : Ok(report);
|
||||
}
|
||||
|
||||
[HttpPost("query")]
|
||||
[ProducesResponseType(typeof(SalesReportQueryResponse), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<SalesReportQueryResponse>> Query([FromBody] SalesReportQueryRequest request, CancellationToken ct)
|
||||
{
|
||||
var rows = await _reports.QueryAsync(request.ReportType, request.From, request.To, request.ItemId, request.CustomerId, request.WarehouseId, ct);
|
||||
return Ok(new SalesReportQueryResponse(request.ReportType, request.From, request.To, rows));
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
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,25 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Sales;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
[Route("api/v1/sales-slips/{salesSlipId:int}/free-issue-suggestions")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "sales")]
|
||||
public sealed class SalesSlipPromotionsController : ApiControllerBase
|
||||
{
|
||||
private readonly ISalesPromotionSuggestionService _suggestions;
|
||||
|
||||
public SalesSlipPromotionsController(ISalesPromotionSuggestionService suggestions) => _suggestions = suggestions;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(SalesFreeIssueSuggestionDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<SalesFreeIssueSuggestionDto>> Get(int salesSlipId, CancellationToken ct)
|
||||
{
|
||||
var result = await _suggestions.GetFreeIssueSuggestionsAsync(salesSlipId, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Sales;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
[Route("api/v1/sales-slips")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "sales")]
|
||||
public sealed class SalesSlipsController : ApiControllerBase
|
||||
{
|
||||
private readonly ISalesSlipService _slips;
|
||||
|
||||
public SalesSlipsController(ISalesSlipService slips) => _slips = slips;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<SalesSlipSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<SalesSlipSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query,
|
||||
[FromQuery] SalesSlipStatus? status,
|
||||
[FromQuery] int? customerId,
|
||||
[FromQuery] int? warehouseId,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _slips.ListAsync(query, status, customerId, warehouseId, ct));
|
||||
|
||||
[HttpGet("{salesSlipId:int}")]
|
||||
[ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<SalesSlipDto>> GetById(int salesSlipId, CancellationToken ct)
|
||||
{
|
||||
var result = await _slips.GetAsync(salesSlipId, ct);
|
||||
if (result is null) return NotFound();
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpGet("{salesSlipId:int}/posting-check")]
|
||||
[ProducesResponseType(typeof(SalesSlipPostingCheckDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<SalesSlipPostingCheckDto>> PostingCheck(int salesSlipId, CancellationToken ct)
|
||||
=> Ok(await _slips.CheckPostingAsync(salesSlipId, ct));
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status201Created)]
|
||||
public async Task<ActionResult<SalesSlipDto>> Create([FromBody] CreateSalesSlipRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _slips.CreateAsync(request, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Created($"/api/v1/sales-slips/{result.Value.SalesSlipId}", result.Value);
|
||||
}
|
||||
|
||||
[HttpPut("{salesSlipId:int}")]
|
||||
[ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<SalesSlipDto>> Update(int salesSlipId, [FromBody] UpdateSalesSlipRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _slips.UpdateAsync(salesSlipId, request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost("{salesSlipId:int}/post")]
|
||||
[ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<SalesSlipDto>> Post(int salesSlipId, CancellationToken ct)
|
||||
=> Ok(await _slips.PostAsync(salesSlipId, ct));
|
||||
|
||||
[HttpPost("{salesSlipId:int}/cancel")]
|
||||
[ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<SalesSlipDto>> Cancel(int salesSlipId, CancellationToken ct)
|
||||
=> Ok(await _slips.CancelAsync(salesSlipId, ct));
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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,6 +1,3 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Stock;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -9,30 +6,12 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Stock-adjustment endpoints (docs/11 §5.5).</summary>
|
||||
[Route("api/v1/stock-adjustments")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "stock")]
|
||||
public sealed class StockAdjustmentsController : ApiControllerBase
|
||||
{
|
||||
private readonly IAdjustmentService _adjustments;
|
||||
|
||||
public StockAdjustmentsController(IAdjustmentService adjustments) => _adjustments = adjustments;
|
||||
|
||||
/// <summary>List posted adjustments, newest first.</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<AdjustmentSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<AdjustmentSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] int? warehouseId, [FromQuery] int? branchId, [FromQuery] int? reasonCodeId, CancellationToken ct)
|
||||
=> Ok(await _adjustments.ListAsync(query, warehouseId, branchId, reasonCodeId, ct));
|
||||
|
||||
/// <summary>Get one adjustment with its lines and the ledger entries it posted.</summary>
|
||||
[HttpGet("{adjustmentId:int}")]
|
||||
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<AdjustmentDto>> GetById(int adjustmentId, CancellationToken ct)
|
||||
{
|
||||
var dto = await _adjustments.GetAsync(adjustmentId, ct);
|
||||
return dto is null ? NotFound() : Ok(dto);
|
||||
}
|
||||
|
||||
/// <summary>Create + auto-post an adjustment (mandatory reason code; decrease FIFO-consumes).</summary>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status201Created)]
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Procurement;
|
||||
using ERPCore.Dtos.Stock;
|
||||
@@ -10,7 +8,6 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Stock enquiry, ledger, valuation and reorder-alert endpoints (docs/11 §5.1–5.3, §5.7).</summary>
|
||||
[Route("api/v1/stock")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "stock")]
|
||||
public sealed class StockController : ApiControllerBase
|
||||
{
|
||||
private readonly IStockService _stock;
|
||||
@@ -27,26 +24,12 @@ public sealed class StockController : ApiControllerBase
|
||||
public async Task<ActionResult<StockOnHandDto>> OnHand([FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
|
||||
=> Ok(await _stock.GetOnHandAsync(itemId, warehouseId, ct));
|
||||
|
||||
/// <summary>On-hand across every stocked (item, warehouse) pair; both filters optional.</summary>
|
||||
[HttpGet("on-hand/list")]
|
||||
[ProducesResponseType(typeof(PagedResponse<StockOnHandDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<StockOnHandDto>>> OnHandList(
|
||||
[FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] int? branchId, [FromQuery] PageQuery query, CancellationToken ct)
|
||||
=> Ok(await _stock.GetOnHandListAsync(itemId, warehouseId, branchId, query, ct));
|
||||
|
||||
/// <summary>
|
||||
/// Immutable movement history. <c>sourceDocType</c>/<c>sourceDocId</c> answer "what did
|
||||
/// this document post?" — the ledger's document reference is polymorphic, so there is
|
||||
/// no FK to navigate instead (docs/10 C.9).
|
||||
/// </summary>
|
||||
[HttpGet("ledger")]
|
||||
[ProducesResponseType(typeof(PagedResponse<StockLedgerRowDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<StockLedgerRowDto>>> Ledger(
|
||||
[FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] int? branchId,
|
||||
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to,
|
||||
[FromQuery] string? sourceDocType, [FromQuery] int? sourceDocId,
|
||||
[FromQuery] PageQuery query, CancellationToken ct)
|
||||
=> Ok(await _stock.GetLedgerAsync(itemId, warehouseId, branchId, from, to, sourceDocType, sourceDocId, query, ct));
|
||||
[FromQuery] int? itemId, [FromQuery] int? warehouseId,
|
||||
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to, [FromQuery] PageQuery query, CancellationToken ct)
|
||||
=> Ok(await _stock.GetLedgerAsync(itemId, warehouseId, from, to, query, ct));
|
||||
|
||||
[HttpGet("valuation")]
|
||||
[ProducesResponseType(typeof(StockValuationDto), StatusCodes.Status200OK)]
|
||||
@@ -57,8 +40,8 @@ public sealed class StockController : ApiControllerBase
|
||||
[HttpGet("reorder-alerts")]
|
||||
[ProducesResponseType(typeof(PagedResponse<ReorderAlertDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<ReorderAlertDto>>> ReorderAlerts(
|
||||
[FromQuery] int? warehouseId, [FromQuery] int? branchId, [FromQuery] PageQuery query, CancellationToken ct)
|
||||
=> Ok(await _reorder.GetAlertsAsync(warehouseId, branchId, query, ct));
|
||||
[FromQuery] int? warehouseId, [FromQuery] PageQuery query, CancellationToken ct)
|
||||
=> Ok(await _reorder.GetAlertsAsync(warehouseId, query, ct));
|
||||
|
||||
/// <summary>Create a draft requisition for an item's suggested reorder quantity.</summary>
|
||||
[HttpPost("reorder-alerts/{itemId:int}/requisition")]
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Stock;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -10,20 +6,12 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Stock-count endpoints (docs/11 §5.6).</summary>
|
||||
[Route("api/v1/stock-counts")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "stock")]
|
||||
public sealed class StockCountsController : ApiControllerBase
|
||||
{
|
||||
private readonly ICountService _counts;
|
||||
|
||||
public StockCountsController(ICountService counts) => _counts = counts;
|
||||
|
||||
/// <summary>List counts, newest first.</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<CountSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<CountSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] CountStatus? status, [FromQuery] int? warehouseId, [FromQuery] int? branchId, CancellationToken ct)
|
||||
=> Ok(await _counts.ListAsync(query, status, warehouseId, branchId, ct));
|
||||
|
||||
[HttpGet("{countId:int}")]
|
||||
[ProducesResponseType(typeof(CountDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Stock;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -10,21 +6,12 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Stock-transfer endpoints (docs/11 §5.4).</summary>
|
||||
[Route("api/v1/stock-transfers")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "stock")]
|
||||
public sealed class StockTransfersController : ApiControllerBase
|
||||
{
|
||||
private readonly ITransferService _transfers;
|
||||
|
||||
public StockTransfersController(ITransferService transfers) => _transfers = transfers;
|
||||
|
||||
/// <summary>List transfers, newest first.</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<TransferSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<TransferSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] TransferStatus? status,
|
||||
[FromQuery] int? srcWarehouseId, [FromQuery] int? destWarehouseId, [FromQuery] int? branchId, CancellationToken ct)
|
||||
=> Ok(await _transfers.ListAsync(query, status, srcWarehouseId, destWarehouseId, branchId, ct));
|
||||
|
||||
[HttpGet("{transferId:int}")]
|
||||
[ProducesResponseType(typeof(TransferDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Categories;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Subcategory endpoints addressed by their own id (docs/11-BACKEND-PHASE1.md §2.3).
|
||||
/// Listing and creation live under the parent category on <see cref="CategoriesController"/>,
|
||||
/// since a subcategory only exists in the context of one.
|
||||
/// </summary>
|
||||
[Route("api/v1/subcategories")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||
public sealed class SubCategoriesController : ApiControllerBase
|
||||
{
|
||||
private readonly ICategoryService _categories;
|
||||
|
||||
public SubCategoriesController(ICategoryService categories) => _categories = categories;
|
||||
|
||||
[HttpGet("{subCategoryId:int}")]
|
||||
[ProducesResponseType(typeof(SubCategoryDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<SubCategoryDto>> GetById(int subCategoryId, CancellationToken ct)
|
||||
{
|
||||
var result = await _categories.GetSubCategoryAsync(subCategoryId, ct);
|
||||
if (result is null) return NotFound();
|
||||
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
/// <summary>Renames a subcategory. It cannot be moved to another category — see the request DTO.</summary>
|
||||
[HttpPut("{subCategoryId:int}")]
|
||||
[ProducesResponseType(typeof(SubCategoryDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<SubCategoryDto>> Update(
|
||||
int subCategoryId, [FromBody] UpdateSubCategoryRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _categories.UpdateSubCategoryAsync(subCategoryId, request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
|
||||
[HttpPatch("{subCategoryId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> SetStatus(
|
||||
int subCategoryId, [FromBody] UpdateSubCategoryStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
await _categories.SetSubCategoryStatusAsync(subCategoryId, request.Status, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
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,6 +1,3 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Uoms;
|
||||
using ERPCore.Services.Interfaces;
|
||||
@@ -10,7 +7,6 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Unit-of-measure endpoints (docs/11-BACKEND-PHASE1.md §2.2).</summary>
|
||||
[Route("api/v1/uoms")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||
public sealed class UomsController : ApiControllerBase
|
||||
{
|
||||
private readonly IUomService _uoms;
|
||||
@@ -19,9 +15,8 @@ public sealed class UomsController : ApiControllerBase
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<UomDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<UomDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||
=> Ok(await _uoms.ListAsync(query, status, ct));
|
||||
public async Task<ActionResult<PagedResponse<UomDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
|
||||
=> Ok(await _uoms.ListAsync(query, ct));
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(UomDto), StatusCodes.Status201Created)]
|
||||
@@ -31,14 +26,4 @@ public sealed class UomsController : ApiControllerBase
|
||||
var dto = await _uoms.CreateAsync(request, ct);
|
||||
return Created($"/api/v1/uoms/{dto.UomId}", dto);
|
||||
}
|
||||
|
||||
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
|
||||
[HttpPatch("{uomId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> SetStatus(int uomId, [FromBody] UpdateUomStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
await _uoms.SetStatusAsync(uomId, request.Status, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Audit;
|
||||
using ERPCore.Dtos.Auth;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Dtos.Users;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// User management: local shadow `User` list/detail + role assignment, and
|
||||
/// account creation orchestrated against AuthHex (see <see cref="IUserManagementService.CreateAsync"/>).
|
||||
/// </summary>
|
||||
[Route("api/v1/users")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "settings")]
|
||||
public sealed class UsersController : ApiControllerBase
|
||||
{
|
||||
private readonly IUserManagementService _users;
|
||||
private readonly IEmployeeUserLinkService _links;
|
||||
private readonly IAuditService _audit;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
|
||||
public UsersController(IUserManagementService users, IEmployeeUserLinkService links, IAuditService audit, ICurrentUser currentUser)
|
||||
{
|
||||
_users = users;
|
||||
_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>
|
||||
[HttpGet("email-lookup")]
|
||||
[ProducesResponseType(typeof(EmployeeMatchResponse), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<EmployeeMatchResponse>> EmailLookup([FromQuery] string email, CancellationToken ct)
|
||||
=> Ok(new EmployeeMatchResponse(await _links.FindStaffCandidateByEmailAsync(email, ct)));
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<ManagedUserDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<ManagedUserDto>>> List([FromQuery] PageQuery query, [FromQuery] EntityStatus? status, [FromQuery] string? roleCode, CancellationToken ct)
|
||||
=> Ok(await _users.ListAsync(query, status, roleCode, ct));
|
||||
|
||||
/// <summary>AuthHex UserType options for the create-user form's select.</summary>
|
||||
[HttpGet("user-types")]
|
||||
[ProducesResponseType(typeof(List<UserTypeOptionDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<UserTypeOptionDto>>> ListUserTypes(CancellationToken ct)
|
||||
=> Ok(await _users.ListUserTypesAsync(ct));
|
||||
|
||||
[HttpGet("{userId:int}")]
|
||||
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<ManagedUserDto>> GetById(int userId, CancellationToken ct)
|
||||
{
|
||||
var result = await _users.GetAsync(userId, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<ManagedUserDto>> Create([FromBody] CreateUserRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _users.CreateAsync(request, _currentUser.AuditUserId, ct);
|
||||
return Created($"/api/v1/users/{result.UserId}", result);
|
||||
}
|
||||
|
||||
[HttpPut("{userId:int}/role")]
|
||||
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<ManagedUserDto>> UpdateRole(int userId, [FromBody] UpdateUserRoleRequest request, CancellationToken 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,5 +1,3 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Vendors;
|
||||
@@ -10,7 +8,6 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Vendor master endpoints (docs/11-BACKEND-PHASE1.md §2.4).</summary>
|
||||
[Route("api/v1/vendors")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "vendors")]
|
||||
public sealed class VendorsController : ApiControllerBase
|
||||
{
|
||||
private readonly IVendorService _vendors;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Warehouses;
|
||||
using ERPCore.Services.Interfaces;
|
||||
@@ -9,7 +7,6 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Warehouse & bin endpoints (docs/11-BACKEND-PHASE1.md §2.5).</summary>
|
||||
[Route("api/v1/warehouses")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "warehouses")]
|
||||
public sealed class WarehousesController : ApiControllerBase
|
||||
{
|
||||
private readonly IWarehouseService _warehouses;
|
||||
@@ -18,9 +15,8 @@ public sealed class WarehousesController : ApiControllerBase
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<WarehouseDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<WarehouseDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] int? branchId, CancellationToken ct)
|
||||
=> Ok(await _warehouses.ListAsync(query, branchId, ct));
|
||||
public async Task<ActionResult<PagedResponse<WarehouseDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
|
||||
=> Ok(await _warehouses.ListAsync(query, ct));
|
||||
|
||||
[HttpGet("{warehouseId:int}")]
|
||||
[ProducesResponseType(typeof(WarehouseDto), StatusCodes.Status200OK)]
|
||||
@@ -40,24 +36,6 @@ public sealed class WarehousesController : ApiControllerBase
|
||||
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")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<BinDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Items;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Custom warranty period master — managed under Products like Item Variants.</summary>
|
||||
[Route("api/v1/warranty-periods")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||
public sealed class WarrantyPeriodsController : ApiControllerBase
|
||||
{
|
||||
private readonly IWarrantyPeriodService _periods;
|
||||
|
||||
public WarrantyPeriodsController(IWarrantyPeriodService periods) => _periods = periods;
|
||||
|
||||
/// <summary>Custom periods only (defaults are fixed); filter <c>status=Active</c> for selectable rows.</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(List<WarrantyPeriodDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<WarrantyPeriodDto>>> List([FromQuery] EntityStatus? status, CancellationToken ct)
|
||||
=> Ok(await _periods.ListAsync(status, ct));
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(WarrantyPeriodDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<WarrantyPeriodDto>> Create([FromBody] CreateWarrantyPeriodRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _periods.CreateAsync(request.Months!.Value, ct);
|
||||
return Created($"/api/v1/warranty-periods/{result.WarrantyPeriodId}", result);
|
||||
}
|
||||
|
||||
/// <summary>Deactivate/reactivate. Never hard-deleted (FR-MD-08).</summary>
|
||||
[HttpPatch("{warrantyPeriodId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> SetStatus(int warrantyPeriodId, [FromBody] UpdateWarrantyPeriodStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
await _periods.SetStatusAsync(warrantyPeriodId, request.Status, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -10,20 +10,8 @@ public static class DocumentTypes
|
||||
public const string Rfq = "RFQ";
|
||||
public const string PurchaseOrder = "PO";
|
||||
public const string Grn = "GRN";
|
||||
public const string PurchaseInvoice = "PINV";
|
||||
public const string Transfer = "TRF";
|
||||
public const string Adjustment = "ADJ";
|
||||
public const string Count = "CNT";
|
||||
public const string PurchaseReturn = "PRET";
|
||||
|
||||
/// <summary>Production run (docs/30 FR-MFG-08) — <c>PRD-2026-00001</c>.</summary>
|
||||
public const string Production = "PRD";
|
||||
public const string SalesInvoice = "SI";
|
||||
public const string SalesSlip = "SSL";
|
||||
public const string BundleSale = "BND";
|
||||
public const string BundleSaleTemplate = "TPL";
|
||||
public const string SalesReturn = "SRET";
|
||||
|
||||
/// <summary>Cashier day-end close-out (Sales) — <c>DEND-2026-00001</c>.</summary>
|
||||
public const string SalesDayEnd = "DEND";
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Per employee/day attendance row (FR-HR-ATT). <see cref="WorkShiftId"/> is
|
||||
/// snapshotted from the employee's shift at ingestion time (docs/12-BACKEND-HRM.md
|
||||
/// A.3) so a later shift reassignment never retroactively changes historical
|
||||
/// Late/OT figures. Model: docs/12-BACKEND-HRM.md Part C.4.
|
||||
/// </summary>
|
||||
public class AttendanceRecord
|
||||
{
|
||||
public int AttendanceRecordId { get; set; }
|
||||
public int? AttendanceUploadBatchId { get; set; }
|
||||
public AttendanceUploadBatch? AttendanceUploadBatch { get; set; }
|
||||
public int EmployeeId { get; set; }
|
||||
public Employee? Employee { get; set; }
|
||||
|
||||
public DateTime AttendanceDate { get; set; }
|
||||
public TimeSpan? CheckIn { get; set; }
|
||||
public TimeSpan? CheckOut { get; set; }
|
||||
public int WorkShiftId { get; set; }
|
||||
public WorkShift? WorkShift { get; set; }
|
||||
|
||||
public int WorkingMinutes { get; set; }
|
||||
public int LateMinutes { get; set; }
|
||||
public int EarlyLeaveMinutes { get; set; }
|
||||
public int OvertimeMinutes { get; set; }
|
||||
|
||||
public AttendanceStatus AttendanceStatus { get; set; }
|
||||
public RowValidationStatus RowValidationStatus { get; set; } = RowValidationStatus.Valid;
|
||||
public int? DuplicateOfAttendanceRecordId { get; set; }
|
||||
|
||||
public string? Notes { get; set; }
|
||||
public bool IsManualOverride { get; set; }
|
||||
public int? EditedBy { get; set; }
|
||||
public DateTime? EditedAt { get; set; }
|
||||
|
||||
public uint RowVersion { get; set; }
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Attendance upload batch (FR-HR-ATT) — the transactional document driving the
|
||||
/// exact status flow Draft→Validated→Confirmed→UsedInPayroll. Scoped to exactly
|
||||
/// one payroll period, numbered via <see cref="NumberSequence"/> (docType "ATT").
|
||||
/// Model: docs/12-BACKEND-HRM.md Part C.4.
|
||||
/// </summary>
|
||||
public class AttendanceUploadBatch
|
||||
{
|
||||
public int AttendanceUploadBatchId { get; set; }
|
||||
public string DocNo { get; set; } = string.Empty;
|
||||
public DateTime PeriodStart { get; set; }
|
||||
public DateTime PeriodEnd { get; set; }
|
||||
public AttendanceSourceType SourceType { get; set; }
|
||||
public string? OriginalFileName { get; set; }
|
||||
|
||||
public int UploadedBy { get; set; }
|
||||
public DateTime UploadedAt { get; set; }
|
||||
public AttendanceBatchStatus Status { get; set; } = AttendanceBatchStatus.Draft;
|
||||
public int? ConfirmedBy { get; set; }
|
||||
public DateTime? ConfirmedAt { get; set; }
|
||||
|
||||
public int RowCountTotal { get; set; }
|
||||
public int RowCountDuplicate { get; set; }
|
||||
public int RowCountError { get; set; }
|
||||
|
||||
public uint RowVersion { get; set; }
|
||||
}
|
||||
@@ -13,7 +13,4 @@ public class Batch
|
||||
|
||||
public string BatchNo { get; set; } = string.Empty;
|
||||
public DateOnly? ExpiryDate { get; set; }
|
||||
|
||||
/// <summary>Optional scan code for barcode/RFID lookup (unique when set).</summary>
|
||||
public string? Barcode { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Branch/location master (FR-HR-MD-01) — multi-branch readiness. Required by
|
||||
/// <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.
|
||||
/// </summary>
|
||||
public class Branch
|
||||
{
|
||||
/// <summary>
|
||||
/// Code of the deployment's default branch. Every branch-scoped entity has a
|
||||
/// non-nullable <c>BranchId</c> with a Restrict FK, so this row must exist before any
|
||||
/// of them can be written — see <c>DataSeeder.EnsureDefaultBranchAsync</c>, which
|
||||
/// creates it at startup, and <c>BranchConfigService.ResolveDefaultBranchIdAsync</c>,
|
||||
/// which is the lazy runtime fallback.
|
||||
/// </summary>
|
||||
public const string DefaultCode = "BR-000";
|
||||
|
||||
/// <summary>Display name paired with <see cref="DefaultCode"/>.</summary>
|
||||
public const string DefaultName = "Main Branch";
|
||||
|
||||
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 Name { get; set; } = string.Empty;
|
||||
public string? Address { get; set; }
|
||||
public string? PhoneNumber { get; set; }
|
||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
|
||||
public uint RowVersion { get; set; }
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Brand master (FR-MD-09). Referenced optionally by <see cref="Item.BrandId"/>.
|
||||
/// Mutable aggregate with a <see cref="RowVersion"/> ETag token. Deactivated, not
|
||||
/// deleted, when referenced (FR-MD-08). Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
||||
/// </summary>
|
||||
public class Brand
|
||||
{
|
||||
public int BrandId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
|
||||
public uint RowVersion { get; set; }
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
public class BundleSale
|
||||
{
|
||||
public int BundleSaleId { get; set; }
|
||||
public string BundleNo { get; set; } = string.Empty;
|
||||
public DateTime BundleDate { get; set; }
|
||||
public int CustomerId { get; set; }
|
||||
public Customer? Customer { get; set; }
|
||||
public string CustomerSnapshotName { get; set; } = string.Empty;
|
||||
public int WarehouseId { get; set; }
|
||||
public Warehouse? Warehouse { get; set; }
|
||||
public int CashierUserId { get; set; }
|
||||
public User? CashierUser { get; set; }
|
||||
public int BundleSaleTemplateId { get; set; }
|
||||
public BundleSaleTemplate? BundleSaleTemplate { get; set; }
|
||||
public string BundleName { get; set; } = string.Empty;
|
||||
public string BundleCode { get; set; } = string.Empty;
|
||||
public BundleSaleStatus Status { get; set; } = BundleSaleStatus.Draft;
|
||||
public decimal ComponentSubtotal { get; set; }
|
||||
public decimal BundlePrice { get; set; }
|
||||
public decimal MarginAmount { get; set; }
|
||||
public decimal DiscountTotal { get; set; }
|
||||
public decimal TaxTotal { get; set; }
|
||||
public decimal GrandTotal { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
public uint RowVersion { 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>();
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
public class BundleSaleLine
|
||||
{
|
||||
public int BundleSaleLineId { get; set; }
|
||||
public int BundleSaleId { get; set; }
|
||||
public BundleSale? BundleSale { get; set; }
|
||||
|
||||
public int ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public decimal Qty { get; set; }
|
||||
public int WarehouseId { get; set; }
|
||||
public Warehouse? Warehouse { get; set; }
|
||||
public decimal UnitPrice { get; set; }
|
||||
public decimal LineTotal { get; set; }
|
||||
public bool IncludeInBundle { get; set; } = true;
|
||||
public bool IsComponent { get; set; } = true;
|
||||
public int? ParentLineId { get; set; }
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
public class BundleSaleTemplate
|
||||
{
|
||||
public int BundleSaleTemplateId { get; set; }
|
||||
public string TemplateCode { get; set; } = string.Empty;
|
||||
public string TemplateName { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
public uint RowVersion { get; set; }
|
||||
|
||||
public ICollection<BundleSaleTemplateLine> Lines { get; set; } = new List<BundleSaleTemplateLine>();
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
public class BundleSaleTemplateLine
|
||||
{
|
||||
public int BundleSaleTemplateLineId { get; set; }
|
||||
public int BundleSaleTemplateId { get; set; }
|
||||
public BundleSaleTemplate? BundleSaleTemplate { get; set; }
|
||||
|
||||
public int ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
public int WarehouseId { get; set; }
|
||||
public Warehouse? Warehouse { get; set; }
|
||||
|
||||
public decimal Qty { get; set; }
|
||||
public decimal UnitPrice { get; set; }
|
||||
public bool IncludeInBundle { get; set; } = true;
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
@@ -1,25 +1,15 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Item category (FR-MD-04) — the top level of a two-level hierarchy. The optional level
|
||||
/// below is <see cref="SubCategory"/>; categories no longer self-nest (the former
|
||||
/// <c>parent_id</c> tree was replaced in migration #2).
|
||||
/// Mutable aggregate with a <see cref="RowVersion"/> ETag token. Deactivated, not
|
||||
/// deleted, when referenced (FR-MD-08). Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
||||
/// Hierarchical item category (FR-MD-04). A null <see cref="ParentId"/> denotes a
|
||||
/// root category. Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
||||
/// </summary>
|
||||
public class Category
|
||||
{
|
||||
public int CategoryId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
|
||||
public uint RowVersion { get; set; }
|
||||
|
||||
public ICollection<SubCategory> SubCategories { get; set; } = new List<SubCategory>();
|
||||
public int? ParentId { get; set; }
|
||||
public Category? Parent { get; set; }
|
||||
public ICollection<Category> Children { get; set; } = new List<Category>();
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Customer master for both B2B and B2C sales.
|
||||
/// Phase 1 keeps this lean: identity, contact, tax, credit, and default warehouse.
|
||||
/// </summary>
|
||||
public class Customer
|
||||
{
|
||||
public int CustomerId { get; set; }
|
||||
public string CustomerCode { get; set; } = string.Empty;
|
||||
public CustomerType CustomerType { get; set; } = CustomerType.B2C;
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? DisplayName { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? Email { get; set; }
|
||||
|
||||
public string? AddressLine1 { get; set; }
|
||||
public string? AddressLine2 { get; set; }
|
||||
public string? City { get; set; }
|
||||
public string? Country { get; set; }
|
||||
|
||||
public string? TaxRegistrationNo { get; set; }
|
||||
public decimal CreditLimit { get; set; }
|
||||
public int CreditDays { get; set; }
|
||||
|
||||
public int? DefaultWarehouseId { 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 DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
|
||||
public uint RowVersion { get; set; }
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Department master (FR-HR-MD-01) — unlimited self-nesting for a real org chart
|
||||
/// (unlike the two-level-capped <see cref="Category"/>); cycle prevention is a
|
||||
/// service-level check on write, not a DB constraint. Model: docs/12-BACKEND-HRM.md Part C.1.
|
||||
/// </summary>
|
||||
public class Department
|
||||
{
|
||||
public int DepartmentId { get; set; }
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public int? ParentDepartmentId { get; set; }
|
||||
public Department? ParentDepartment { get; set; }
|
||||
public int? HeadEmployeeId { get; set; }
|
||||
public Employee? HeadEmployee { get; set; }
|
||||
public int? BranchId { get; set; }
|
||||
public Branch? Branch { get; set; }
|
||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
public uint RowVersion { get; set; }
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Job title master (FR-HR-MD-01), standalone — not FK'd to Department, since a
|
||||
/// title like "Accountant" can exist in multiple departments. Model: docs/12-BACKEND-HRM.md Part C.1.
|
||||
/// </summary>
|
||||
public class Designation
|
||||
{
|
||||
public int DesignationId { get; set; }
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
public uint RowVersion { get; set; }
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// 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). Normal separation is
|
||||
/// 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):
|
||||
/// 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".
|
||||
/// </summary>
|
||||
public class Employee
|
||||
{
|
||||
public int EmployeeId { get; set; }
|
||||
public string EmployeeCode { get; set; } = string.Empty;
|
||||
|
||||
// Identity
|
||||
public string FullName { get; set; } = string.Empty;
|
||||
public string? Nic { get; set; }
|
||||
public DateTime? DateOfBirth { get; set; }
|
||||
public Gender? Gender { get; set; }
|
||||
public string? Nationality { get; set; }
|
||||
public string? ProfilePhotoPath { get; set; }
|
||||
|
||||
// Contact
|
||||
/// <summary>The field used for the bidirectional Employee<->User email cross-check.</summary>
|
||||
public string? Email { get; set; }
|
||||
public string? PersonalMobile { get; set; }
|
||||
public string? AddressLine1 { get; set; }
|
||||
public string? AddressLine2 { get; set; }
|
||||
public string? City { get; set; }
|
||||
public string? PostalCode { get; set; }
|
||||
public string? Country { get; set; }
|
||||
|
||||
// Emergency contact
|
||||
public string? EmergencyContactName { get; set; }
|
||||
public string? EmergencyContactRelationship { get; set; }
|
||||
public string? EmergencyContactPhone { get; set; }
|
||||
|
||||
// Employment
|
||||
public DateTime HireDate { get; set; }
|
||||
public DateTime? ConfirmationDate { get; set; }
|
||||
public DateTime? LastWorkingDate { get; set; }
|
||||
public int DepartmentId { get; set; }
|
||||
public Department? Department { get; set; }
|
||||
public int DesignationId { get; set; }
|
||||
public Designation? Designation { get; set; }
|
||||
public int EmploymentTypeId { get; set; }
|
||||
public EmploymentType? EmploymentType { get; set; }
|
||||
public int BranchId { get; set; }
|
||||
public Branch? Branch { get; set; }
|
||||
public int WorkShiftId { get; set; }
|
||||
public WorkShift? WorkShift { get; set; }
|
||||
public int? ReportingManagerId { get; set; }
|
||||
public Employee? ReportingManager { get; set; }
|
||||
|
||||
// Statutory (Sri Lanka)
|
||||
public string? EpfNumber { get; set; }
|
||||
public string? EtfNumber { get; set; }
|
||||
public string? TaxIdentificationNumber { get; set; }
|
||||
|
||||
/// <summary>Optional login account link (unique — one User backs at most one Employee).</summary>
|
||||
public int? UserId { get; set; }
|
||||
public User? User { get; set; }
|
||||
|
||||
public EmployeeStatus Status { get; set; } = EmployeeStatus.Active;
|
||||
|
||||
public int CreatedBy { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public int? UpdatedBy { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
public uint RowVersion { get; set; }
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Employee bank account (FR-HR-MD-03), one-to-many — a future split-payment
|
||||
/// improvement is possible since this isn't a 1:1 scalar set. Exactly one row per
|
||||
/// employee is <see cref="IsPrimary"/>; payroll disbursement targets it.
|
||||
/// Model: docs/12-BACKEND-HRM.md Part C.2.
|
||||
/// </summary>
|
||||
public class EmployeeBankDetail
|
||||
{
|
||||
public int EmployeeBankDetailId { get; set; }
|
||||
public int EmployeeId { get; set; }
|
||||
public Employee? Employee { get; set; }
|
||||
|
||||
public string BankName { get; set; } = string.Empty;
|
||||
public string BranchName { get; set; } = string.Empty;
|
||||
public string AccountNumber { get; set; } = string.Empty;
|
||||
public string AccountHolderName { get; set; } = string.Empty;
|
||||
public string? SwiftCode { get; set; }
|
||||
public bool IsPrimary { get; set; }
|
||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
public uint RowVersion { get; set; }
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Uploaded staff document (FR-HR-DOC-02..04) — the user's "Doc". <see cref="StoredFileName"/>/
|
||||
/// <see cref="RelativePath"/> are server-generated (never the client's filename), so the
|
||||
/// file is only ever reachable through <see cref="Services.Interfaces.IFileStorageService"/>,
|
||||
/// never a guessable static path. Archived, not deleted, so the audit trail of what was
|
||||
/// once on file is retained. Model: docs/12-BACKEND-HRM.md Part C.3.
|
||||
/// </summary>
|
||||
public class EmployeeDocument
|
||||
{
|
||||
public int EmployeeDocumentId { get; set; }
|
||||
public int EmployeeId { get; set; }
|
||||
public Employee? Employee { get; set; }
|
||||
public int HrDocumentTypeId { get; set; }
|
||||
public HrDocumentType? HrDocumentType { get; set; }
|
||||
|
||||
public string OriginalFileName { get; set; } = string.Empty;
|
||||
public string StoredFileName { get; set; } = string.Empty;
|
||||
public string RelativePath { get; set; } = string.Empty;
|
||||
public string ContentType { get; set; } = string.Empty;
|
||||
public long SizeBytes { get; set; }
|
||||
public DateTime? IssueDate { get; set; }
|
||||
public DateTime? ExpiryDate { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public int UploadedBy { get; set; }
|
||||
public DateTime UploadedAt { get; set; }
|
||||
public int? VerifiedBy { get; set; }
|
||||
public DateTime? VerifiedAt { get; set; }
|
||||
|
||||
public EmployeeDocumentStatus Status { get; set; } = EmployeeDocumentStatus.Active;
|
||||
public uint RowVersion { get; set; }
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Loan/Advance (FR-HR-PAY-03) — <see cref="LoanKind"/> discriminates, structurally
|
||||
/// identical otherwise. <see cref="OutstandingBalance"/> is denormalized (parallel to
|
||||
/// <c>StockLayer.QtyRemaining</c>). Numbered via <see cref="NumberSequence"/> (docType "LOAN").
|
||||
/// Model: docs/12-BACKEND-HRM.md Part C.6.
|
||||
/// </summary>
|
||||
public class EmployeeLoan
|
||||
{
|
||||
public int EmployeeLoanId { get; set; }
|
||||
public string DocNo { get; set; } = string.Empty;
|
||||
public int EmployeeId { get; set; }
|
||||
public Employee? Employee { get; set; }
|
||||
public LoanKind LoanKind { get; set; }
|
||||
|
||||
public decimal PrincipalAmount { get; set; }
|
||||
public decimal InterestRate { get; set; }
|
||||
public decimal InstallmentAmount { get; set; }
|
||||
public int NumberOfInstallments { get; set; }
|
||||
public int StartYear { get; set; }
|
||||
public int StartMonth { get; set; }
|
||||
public decimal OutstandingBalance { get; set; }
|
||||
public LoanStatus Status { get; set; } = LoanStatus.Active;
|
||||
|
||||
public int ApprovedBy { get; set; }
|
||||
public DateTime ApprovedAt { get; set; }
|
||||
public int CreatedBy { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public uint RowVersion { get; set; }
|
||||
|
||||
public List<LoanInstallment> Installments { get; set; } = new();
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Effective-dated salary structure header (FR-HR-PAY-02) — the audit trail a
|
||||
/// salary revision needs (docs/12-BACKEND-HRM.md §13): exactly one row with
|
||||
/// <see cref="EffectiveTo"/> null (the current one) per employee at a time.
|
||||
/// Model: docs/12-BACKEND-HRM.md Part C.6.
|
||||
/// </summary>
|
||||
public class EmployeeSalaryStructure
|
||||
{
|
||||
public int EmployeeSalaryStructureId { get; set; }
|
||||
public int EmployeeId { get; set; }
|
||||
public Employee? Employee { get; set; }
|
||||
|
||||
public DateTime EffectiveFrom { get; set; }
|
||||
public DateTime? EffectiveTo { get; set; }
|
||||
public decimal BasicSalary { get; set; }
|
||||
public string Currency { get; set; } = "LKR";
|
||||
public SalaryStructureStatus Status { get; set; } = SalaryStructureStatus.Active;
|
||||
|
||||
public int ApprovedBy { get; set; }
|
||||
public DateTime ApprovedAt { get; set; }
|
||||
public int CreatedBy { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public uint RowVersion { get; set; }
|
||||
|
||||
public List<EmployeeSalaryStructureLine> Lines { get; set; } = new();
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>Allowance/other-deduction line on a salary structure. Model: docs/12-BACKEND-HRM.md Part C.6.</summary>
|
||||
public class EmployeeSalaryStructureLine
|
||||
{
|
||||
public int EmployeeSalaryStructureLineId { get; set; }
|
||||
public int EmployeeSalaryStructureId { get; set; }
|
||||
public EmployeeSalaryStructure? EmployeeSalaryStructure { get; set; }
|
||||
public int SalaryComponentId { get; set; }
|
||||
public SalaryComponent? SalaryComponent { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Labor category master (FR-HR-MD-01) — a master, not an enum, mirroring
|
||||
/// <see cref="Brand"/>: employment categories change with company/labor-law
|
||||
/// policy without wanting a code deploy. Model: docs/12-BACKEND-HRM.md Part C.1.
|
||||
/// </summary>
|
||||
public class EmploymentType
|
||||
{
|
||||
public int EmploymentTypeId { get; set; }
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
public uint RowVersion { get; set; }
|
||||
}
|
||||
@@ -22,11 +22,6 @@ public class Grn
|
||||
public int WarehouseId { 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 int CreatedBy { get; set; }
|
||||
@@ -35,16 +30,6 @@ public class Grn
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? PostedAt { get; set; }
|
||||
|
||||
/// <summary>Journal number of the real GL journal entry posted for this GRN's receipt (set on confirm).</summary>
|
||||
public string? GlJournalNo { get; set; }
|
||||
public DateTime? GlPostedAt { get; set; }
|
||||
|
||||
/// <summary>The Purchase Invoice this GRN has been consolidated into, if any (null until
|
||||
/// invoiced). Vendor settlement is tracked on the invoice, not per-GRN — see
|
||||
/// <see cref="PurchaseInvoice"/>.</summary>
|
||||
public int? PurchaseInvoiceId { get; set; }
|
||||
public PurchaseInvoice? PurchaseInvoice { get; set; }
|
||||
|
||||
/// <summary>PostgreSQL xmin-backed optimistic concurrency token.</summary>
|
||||
public uint RowVersion { get; set; }
|
||||
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// GRN (goods receipt) configuration — a singleton row, same shape as <see cref="SalesConfig"/>.
|
||||
/// Controls which warehouse goods are received into.
|
||||
/// </summary>
|
||||
public class GrnConfig
|
||||
{
|
||||
/// <summary>Always 1 - the singleton row's id.</summary>
|
||||
public const int SingletonId = 1;
|
||||
|
||||
public int ConfigId { get; set; }
|
||||
|
||||
/// <summary>When on (default) and no <see cref="DefaultWarehouseId"/> is saved, New GRN shows
|
||||
/// a Warehouse picker. When off, a default must be saved.</summary>
|
||||
public bool AllowWarehouseSelection { get; set; } = true;
|
||||
|
||||
/// <summary>Default receiving warehouse. Whenever set, New GRN shows only this warehouse (locked)
|
||||
/// and every GRN is forced onto it server-side (see GrnConfigService.GetForcedWarehouseIdAsync).
|
||||
/// Required while <see cref="AllowWarehouseSelection"/> is off; clearing it brings the picker back.</summary>
|
||||
public int? DefaultWarehouseId { get; set; }
|
||||
public Warehouse? DefaultWarehouse { get; set; }
|
||||
|
||||
/// <summary>When on (default), GRN lines may carry a discount % (the New GRN "Disc %" column and
|
||||
/// header rate); when off, they are hidden and a discount above zero is rejected server-side
|
||||
/// (see GrnService.CreateAsync).</summary>
|
||||
public bool AllowDiscounts { get; set; } = true;
|
||||
|
||||
/// <summary>When on (default), GRN lines carry VAT (entered, or the item's tax rate when left
|
||||
/// empty); when off, the VAT columns are hidden, every line is received at 0% VAT, and a VAT
|
||||
/// above zero is rejected server-side (see GrnService.CreateAsync).</summary>
|
||||
public bool AllowVat { 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; }
|
||||
}
|
||||
@@ -3,13 +3,9 @@ using ERPCore.Domain.Enums;
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// GRN line (FR-GRN-04..08). <see cref="UnitCost"/> is the gross cost received at:
|
||||
/// entered on the line, defaulting to the PO price when omitted (a per-receipt price
|
||||
/// override is now permitted — see docs/02-SECURITY C.3, revised). <see cref="PoUnitPrice"/>
|
||||
/// snapshots the PO price at receipt so the variance survives later PO edits.
|
||||
/// <see cref="NetUnitCost"/> = unitCost after trade discount — this is what the FIFO layer
|
||||
/// costs at (VAT never enters stock value; it is recoverable input tax).
|
||||
/// <see cref="ReceivedValue"/> = qty × netUnitCost (after discount, before VAT).
|
||||
/// GRN line (FR-GRN-04..08). <see cref="UnitCost"/> is the PO-derived cost for
|
||||
/// PO-based receipts (client cost ignored — 02-SECURITY C.3) or the entered cost
|
||||
/// for direct receipts. <see cref="ReceivedValue"/> = qty × unitCost.
|
||||
/// <see cref="HoldStatus"/> gates issuability. Model: docs/10 Part C.3.
|
||||
/// </summary>
|
||||
public class GrnLine
|
||||
@@ -25,6 +21,9 @@ public class GrnLine
|
||||
public int ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
|
||||
public int UomId { get; set; }
|
||||
public Uom? Uom { get; set; }
|
||||
|
||||
public int? BinId { get; set; }
|
||||
public Bin? Bin { get; set; }
|
||||
|
||||
@@ -32,37 +31,7 @@ public class GrnLine
|
||||
public Batch? Batch { get; set; }
|
||||
|
||||
public decimal Qty { get; set; }
|
||||
|
||||
/// <summary>Gross unit cost received at (entered, or PO price when omitted).</summary>
|
||||
public decimal UnitCost { get; set; }
|
||||
|
||||
/// <summary>Snapshot of the PO line price at receipt; null for direct receipts.</summary>
|
||||
public decimal? PoUnitPrice { get; set; }
|
||||
|
||||
/// <summary>Trade discount percentage (0–100), entered.</summary>
|
||||
public decimal DiscountPct { get; set; }
|
||||
|
||||
/// <summary>UnitCost × (1 − DiscountPct/100) — the inventory (FIFO layer) cost.</summary>
|
||||
public decimal NetUnitCost { get; set; }
|
||||
|
||||
/// <summary>VAT percentage (0–100), entered. Recoverable — does not affect stock value.</summary>
|
||||
public decimal VatPct { get; set; }
|
||||
|
||||
/// <summary>Qty × NetUnitCost × VatPct/100.</summary>
|
||||
public decimal VatAmount { get; set; }
|
||||
|
||||
/// <summary>Qty × NetUnitCost (after discount, before VAT).</summary>
|
||||
public decimal ReceivedValue { get; set; }
|
||||
|
||||
/// <summary>Qty × NetUnitCost + VatAmount — payable to the vendor.</summary>
|
||||
public decimal LineTotal { get; set; }
|
||||
|
||||
public HoldStatus HoldStatus { get; set; } = HoldStatus.Available;
|
||||
|
||||
/// <summary>
|
||||
/// One row per received unit when <see cref="Item.Warranty"/> is
|
||||
/// <see cref="Enums.Warranty.Warranty"/> — count must equal <see cref="Qty"/>.
|
||||
/// Empty for a non-warranty item.
|
||||
/// </summary>
|
||||
public ICollection<GrnLineWarrantyNumber> WarrantyNumbers { get; set; } = new List<GrnLineWarrantyNumber>();
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Staff document catalog (FR-HR-DOC-01) — the user's "DocType": a category of
|
||||
/// document (NIC, contract, certificate...), not the uploaded file itself (see
|
||||
/// <see cref="EmployeeDocument"/>, the "Doc"). Deactivated, not deleted, when
|
||||
/// referenced. Model: docs/12-BACKEND-HRM.md Part C.3.
|
||||
/// </summary>
|
||||
public class HrDocumentType
|
||||
{
|
||||
public int HrDocumentTypeId { get; set; }
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public HrDocumentCategory Category { get; set; }
|
||||
public bool RequiredAtOnboarding { get; set; }
|
||||
public bool ExpiryTracked { get; set; }
|
||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
public uint RowVersion { get; set; }
|
||||
}
|
||||
@@ -14,81 +14,18 @@ public class Item
|
||||
public string Name { get; set; } = string.Empty;
|
||||
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 Category? Category { get; set; }
|
||||
|
||||
/// <summary>Optional second level below <see cref="CategoryId"/>; must belong to it.</summary>
|
||||
public int? SubCategoryId { get; set; }
|
||||
public SubCategory? SubCategory { get; set; }
|
||||
|
||||
public int? BrandId { get; set; }
|
||||
public Brand? Brand { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The stocking unit — the pack the item is counted in (BOTTLE, PACKET, BOX, PCS).
|
||||
/// <b>Every</b> quantity in the system is a count of these: stock layers, the ledger,
|
||||
/// and every document line. Nothing converts, so this is the sole meaning of a
|
||||
/// quantity and cannot be changed once the item has stock history.
|
||||
/// </summary>
|
||||
public int BaseUomId { get; set; }
|
||||
public Uom? BaseUom { get; set; }
|
||||
|
||||
public int? DefaultVendorId { get; set; }
|
||||
public Vendor? DefaultVendor { get; set; }
|
||||
|
||||
public StockNature StockNature { get; set; }
|
||||
public ItemType ItemType { get; set; }
|
||||
public TrackingMode TrackingMode { get; set; }
|
||||
public Warranty Warranty { get; set; } = Warranty.NonWarranty;
|
||||
/// <summary>Coverage length in months (see <see cref="Enums.WarrantyPeriods.AllowedMonths"/>) when <see cref="Warranty"/> is Warranty; null otherwise.</summary>
|
||||
public int? WarrantyPeriodMonths { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The tax rate a sales/purchase line for this item defaults to (see
|
||||
/// <see cref="TaxRate"/>) — replaces the old free-text "tax class" label that nothing
|
||||
/// ever read. Null means no default; a line must be given an explicit tax percentage.
|
||||
/// </summary>
|
||||
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 string? TaxClass { get; set; }
|
||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
@@ -98,4 +35,5 @@ public class Item
|
||||
public uint RowVersion { get; set; }
|
||||
|
||||
public ICollection<ItemReorder> ReorderSettings { get; set; } = new List<ItemReorder>();
|
||||
public ICollection<UomConversion> UomConversions { get; set; } = new List<UomConversion>();
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A product photo attached to an item. Like <see cref="EmployeeDocument"/>, the file lives
|
||||
/// behind <see cref="Infra.Storage.IFileStorageService"/> under a server-generated name and is
|
||||
/// only reachable through the authenticated items API — never a static path. Only uploadable
|
||||
/// while <see cref="ProductConfig.ItemImagesEnabled"/> is on; existing images stay readable
|
||||
/// after the flag is switched off (reads are never gated).
|
||||
/// </summary>
|
||||
public class ItemImage
|
||||
{
|
||||
public int ItemImageId { get; set; }
|
||||
public int ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
|
||||
public string OriginalFileName { get; set; } = string.Empty;
|
||||
public string StoredFileName { get; set; } = string.Empty;
|
||||
public string RelativePath { get; set; } = string.Empty;
|
||||
public string ContentType { get; set; } = string.Empty;
|
||||
public long SizeBytes { get; set; }
|
||||
|
||||
/// <summary>Display order; the lowest is the item's main image.</summary>
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
public int UploadedBy { get; set; }
|
||||
public DateTime UploadedAt { 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