first commit
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ERPCore", "ERPCore\ERPCore.csproj", "{B38203ED-80EA-41C6-8B7D-B664451299E3}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{B38203ED-80EA-41C6-8B7D-B664451299E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B38203ED-80EA-41C6-8B7D-B664451299E3}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B38203ED-80EA-41C6-8B7D-B664451299E3}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{B38203ED-80EA-41C6-8B7D-B664451299E3}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{B38203ED-80EA-41C6-8B7D-B664451299E3}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{B38203ED-80EA-41C6-8B7D-B664451299E3}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{B38203ED-80EA-41C6-8B7D-B664451299E3}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B38203ED-80EA-41C6-8B7D-B664451299E3}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B38203ED-80EA-41C6-8B7D-B664451299E3}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{B38203ED-80EA-41C6-8B7D-B664451299E3}.Release|x64.Build.0 = Release|Any CPU
|
||||
{B38203ED-80EA-41C6-8B7D-B664451299E3}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{B38203ED-80EA-41C6-8B7D-B664451299E3}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace ERPCore.Common.Models;
|
||||
|
||||
/// <summary>Pagination envelope metadata returned alongside a page of items.</summary>
|
||||
public sealed record PaginationMeta(int Page, int PageSize, int TotalCount)
|
||||
{
|
||||
public int TotalPages => PageSize <= 0 ? 0 : (int)Math.Ceiling(TotalCount / (double)PageSize);
|
||||
public bool HasPrevious => Page > 1;
|
||||
public bool HasNext => Page < TotalPages;
|
||||
}
|
||||
|
||||
/// <summary>A single page of <typeparamref name="T"/> plus its pagination metadata.</summary>
|
||||
public sealed class PagedResult<T>
|
||||
{
|
||||
public IReadOnlyList<T> Items { get; init; } = Array.Empty<T>();
|
||||
public PaginationMeta Meta { get; init; } = new(1, 0, 0);
|
||||
|
||||
public PagedResult() { }
|
||||
|
||||
public PagedResult(IReadOnlyList<T> items, int page, int pageSize, int totalCount)
|
||||
{
|
||||
Items = items;
|
||||
Meta = new PaginationMeta(page, pageSize, totalCount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace ERPCore.Common.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight success/failure envelope for service-layer results where an
|
||||
/// exception would be overkill. Domain errors that must reach the client as
|
||||
/// RFC 7807 ProblemDetails should throw a DomainException instead
|
||||
/// (see System/Errors).
|
||||
/// </summary>
|
||||
public readonly struct Result<T>
|
||||
{
|
||||
public bool IsSuccess { get; }
|
||||
public T? Value { get; }
|
||||
public string? ErrorCode { get; }
|
||||
public string? ErrorMessage { get; }
|
||||
|
||||
private Result(bool ok, T? value, string? code, string? message)
|
||||
{
|
||||
IsSuccess = ok;
|
||||
Value = value;
|
||||
ErrorCode = code;
|
||||
ErrorMessage = message;
|
||||
}
|
||||
|
||||
public static Result<T> Success(T value) => new(true, value, null, null);
|
||||
public static Result<T> Failure(string code, string message) => new(false, default, code, message);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Non-domain metadata endpoint. Confirms the controller pipeline, Swagger and
|
||||
/// routing are wired; domain endpoints are defined per docs/11-BACKEND-PHASE1.md.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public sealed class MetaController : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public IActionResult Get() => Ok(new
|
||||
{
|
||||
name = "ERPCore API",
|
||||
phase = "Phase 1 — Inventory & Supply Chain",
|
||||
version = "v1",
|
||||
utc = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.9" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Common\Models\" />
|
||||
<Folder Include="Infra\UoW\" />
|
||||
<Folder Include="System\Errors\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace ERPCore.Infra.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the audit actor from the current HTTP request's principal
|
||||
/// (token `sub` / NameIdentifier). Falls back to <see cref="SystemActor"/>
|
||||
/// when the request is unauthenticated (e.g. health checks, seeding).
|
||||
/// </summary>
|
||||
public sealed class CurrentUser : ICurrentUser
|
||||
{
|
||||
public const string SystemActor = "system";
|
||||
|
||||
private readonly IHttpContextAccessor _accessor;
|
||||
|
||||
public CurrentUser(IHttpContextAccessor accessor) => _accessor = accessor;
|
||||
|
||||
public bool IsAuthenticated =>
|
||||
_accessor.HttpContext?.User?.Identity?.IsAuthenticated ?? false;
|
||||
|
||||
public string UserId
|
||||
{
|
||||
get
|
||||
{
|
||||
var user = _accessor.HttpContext?.User;
|
||||
var sub = user?.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? user?.FindFirstValue("sub");
|
||||
return string.IsNullOrWhiteSpace(sub) ? SystemActor : sub;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace ERPCore.Infra.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction over the authenticated principal, used purely as the audit actor
|
||||
/// (RBAC is deferred for Phase 1). The actor is derived from the token's `sub`
|
||||
/// claim — never from the request body (see 00-CORE §4).
|
||||
/// </summary>
|
||||
public interface ICurrentUser
|
||||
{
|
||||
/// <summary>The audit actor identity (token `sub`), or "system" when unauthenticated.</summary>
|
||||
string UserId { get; }
|
||||
|
||||
/// <summary>True when the request carries an authenticated principal.</summary>
|
||||
bool IsAuthenticated { get; }
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace ERPCore.Infra.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// JWT bearer wiring. Authentication only — RBAC/authorization policies are
|
||||
/// deferred for Phase 1; the validated principal exists solely so that
|
||||
/// <see cref="ICurrentUser"/> can stamp the audit actor.
|
||||
/// </summary>
|
||||
public static class JwtAuthExtensions
|
||||
{
|
||||
public static IServiceCollection AddErpJwtAuth(this IServiceCollection services, IConfiguration config)
|
||||
{
|
||||
var issuer = config["Jwt:Issuer"];
|
||||
var audience = config["Jwt:Audience"];
|
||||
var signingKey = config["Jwt:SigningKey"] ?? string.Empty;
|
||||
|
||||
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = issuer,
|
||||
ValidateAudience = true,
|
||||
ValidAudience = audience,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey)),
|
||||
ClockSkew = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
});
|
||||
|
||||
services.AddAuthorization();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Infra.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core context for the ERP database. The 38 Phase 1 entities and their
|
||||
/// <see cref="IEntityTypeConfiguration{TEntity}"/> configurations are added under
|
||||
/// Domain/Entities and Infra/Persistence/Configurations as they are implemented.
|
||||
/// The authoritative schema lives in docs/10-BACKEND-PHASE1.md — do not invent it here.
|
||||
/// </summary>
|
||||
public class ErpDbContext : DbContext
|
||||
{
|
||||
public ErpDbContext(DbContextOptions<ErpDbContext> options) : base(options)
|
||||
{
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
// Pick up every IEntityTypeConfiguration in this assembly
|
||||
// (Infra/Persistence/Configurations/*).
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ErpDbContext).Assembly);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace ERPCore.Infra.UoW;
|
||||
|
||||
/// <summary>
|
||||
/// Transaction boundary for stock-affecting operations (00-CORE §4). Every
|
||||
/// GRN confirm, transfer dispatch/receive, adjustment, return and count post
|
||||
/// runs inside a single UoW transaction so FIFO layer consumption and ledger
|
||||
/// writes commit or roll back together.
|
||||
/// </summary>
|
||||
public interface IUnitOfWork
|
||||
{
|
||||
/// <summary>Persist tracked changes without an explicit transaction scope.</summary>
|
||||
Task<int> SaveChangesAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>Run <paramref name="action"/> inside a transaction, saving and committing on success.</summary>
|
||||
Task ExecuteInTransactionAsync(Func<CancellationToken, Task> action, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Transactional variant that returns a value from <paramref name="action"/>.</summary>
|
||||
Task<T> ExecuteInTransactionAsync<T>(Func<CancellationToken, Task<T>> action, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using ERPCore.Infra.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Infra.UoW;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core-backed unit of work. Uses the provider execution strategy so the
|
||||
/// transactional path is safe under connection resiliency / retries.
|
||||
/// </summary>
|
||||
public sealed class UnitOfWork : IUnitOfWork
|
||||
{
|
||||
private readonly ErpDbContext _db;
|
||||
|
||||
public UnitOfWork(ErpDbContext db) => _db = db;
|
||||
|
||||
public Task<int> SaveChangesAsync(CancellationToken ct = default) => _db.SaveChangesAsync(ct);
|
||||
|
||||
public async Task ExecuteInTransactionAsync(Func<CancellationToken, Task> action, CancellationToken ct = default)
|
||||
{
|
||||
await ExecuteInTransactionAsync<object?>(async token =>
|
||||
{
|
||||
await action(token);
|
||||
return null;
|
||||
}, ct);
|
||||
}
|
||||
|
||||
public async Task<T> ExecuteInTransactionAsync<T>(Func<CancellationToken, Task<T>> action, CancellationToken ct = default)
|
||||
{
|
||||
var strategy = _db.Database.CreateExecutionStrategy();
|
||||
return await strategy.ExecuteAsync(async () =>
|
||||
{
|
||||
await using var tx = await _db.Database.BeginTransactionAsync(ct);
|
||||
try
|
||||
{
|
||||
var result = await action(ct);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
await tx.CommitAsync(ct);
|
||||
return result;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await tx.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.OpenApi;
|
||||
using Serilog;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Serilog (file sink) — reads levels from configuration, plus a rolling daily file.
|
||||
builder.Host.UseSerilog((ctx, cfg) => cfg
|
||||
.ReadFrom.Configuration(ctx.Configuration)
|
||||
.WriteTo.File("logs/erpcore-.log", rollingInterval: RollingInterval.Day));
|
||||
|
||||
builder.Services.AddControllers();
|
||||
|
||||
// EF Core + PostgreSQL
|
||||
builder.Services.AddDbContext<ErpDbContext>(o =>
|
||||
o.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
|
||||
|
||||
// ProblemDetails (RFC 7807) + domain-exception mapping
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddExceptionHandler<DomainExceptionHandler>();
|
||||
|
||||
// JWT bearer auth (RBAC deferred; identity used only for the audit stamp)
|
||||
builder.Services.AddErpJwtAuth(builder.Configuration);
|
||||
|
||||
// Current-user (audit actor) derived from token `sub`
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped<ICurrentUser, CurrentUser>();
|
||||
|
||||
// Unit of work + generic repository base
|
||||
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
|
||||
builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
|
||||
|
||||
// Health checks (EF Core DB)
|
||||
builder.Services.AddHealthChecks().AddDbContextCheck<ErpDbContext>();
|
||||
|
||||
// Swagger / OpenAPI (Swashbuckle v10 → OpenAPI 3.1)
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(o => o.SwaggerDoc("v1", new OpenApiInfo { Title = "ERPCore API", Version = "v1" }));
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseSerilogRequestLogging();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
app.UseExceptionHandler(); // -> DomainExceptionHandler / ProblemDetails
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
app.MapHealthChecks("/health");
|
||||
app.Run();
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5224",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "https://localhost:7112;http://localhost:5224",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace ERPCore.Repositories.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Generic data-access base. Repositories own EF entities; business rules stay
|
||||
/// in services (00-CORE §4). Concrete repositories extend this with query
|
||||
/// methods specific to their aggregate.
|
||||
/// </summary>
|
||||
public interface IRepository<T> where T : class
|
||||
{
|
||||
Task<T?> GetByIdAsync(object id, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<T>> ListAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>Composable query root for service-defined filtering/paging.</summary>
|
||||
IQueryable<T> Query();
|
||||
|
||||
Task AddAsync(T entity, CancellationToken ct = default);
|
||||
void Update(T entity);
|
||||
void Remove(T entity);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IRepository{T}"/> implementation over <see cref="ErpDbContext"/>.
|
||||
/// Registered open-generically so any entity gets a repository for free; specialise
|
||||
/// by deriving when an aggregate needs bespoke queries.
|
||||
/// </summary>
|
||||
public class Repository<T> : IRepository<T> where T : class
|
||||
{
|
||||
protected readonly ErpDbContext Db;
|
||||
protected readonly DbSet<T> Set;
|
||||
|
||||
public Repository(ErpDbContext db)
|
||||
{
|
||||
Db = db;
|
||||
Set = db.Set<T>();
|
||||
}
|
||||
|
||||
public virtual async Task<T?> GetByIdAsync(object id, CancellationToken ct = default)
|
||||
=> await Set.FindAsync(new[] { id }, ct);
|
||||
|
||||
public virtual async Task<IReadOnlyList<T>> ListAsync(CancellationToken ct = default)
|
||||
=> await Set.AsNoTracking().ToListAsync(ct);
|
||||
|
||||
public IQueryable<T> Query() => Set.AsQueryable();
|
||||
|
||||
public virtual async Task AddAsync(T entity, CancellationToken ct = default)
|
||||
=> await Set.AddAsync(entity, ct);
|
||||
|
||||
public virtual void Update(T entity) => Set.Update(entity);
|
||||
|
||||
public virtual void Remove(T entity) => Set.Remove(entity);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace ERPCore.System.Errors;
|
||||
|
||||
/// <summary>
|
||||
/// Base for domain / business-rule violations. Carries a stable <see cref="Code"/>
|
||||
/// (see the error catalog in docs/11-BACKEND-PHASE1.md) and an HTTP
|
||||
/// <see cref="StatusCode"/>; mapped to RFC 7807 ProblemDetails by
|
||||
/// <see cref="DomainExceptionHandler"/>.
|
||||
/// </summary>
|
||||
public class DomainException : Exception
|
||||
{
|
||||
public string Code { get; }
|
||||
public int StatusCode { get; }
|
||||
|
||||
public DomainException(string code, string message, int statusCode = 400)
|
||||
: base(message)
|
||||
{
|
||||
Code = code;
|
||||
StatusCode = statusCode;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>404 — a referenced resource does not exist.</summary>
|
||||
public sealed class NotFoundException : DomainException
|
||||
{
|
||||
public NotFoundException(string message) : base(ErrorCodes.NotFound, message, 404) { }
|
||||
}
|
||||
|
||||
/// <summary>409 — the request conflicts with current server state.</summary>
|
||||
public sealed class ConflictException : DomainException
|
||||
{
|
||||
public ConflictException(string message) : base(ErrorCodes.Conflict, message, 409) { }
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Microsoft.AspNetCore.Diagnostics;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.System.Errors;
|
||||
|
||||
/// <summary>
|
||||
/// Translates <see cref="DomainException"/> into an RFC 7807 ProblemDetails
|
||||
/// response, preserving the stable domain <c>code</c> as an extension. Non-domain
|
||||
/// exceptions are left unhandled so the framework returns a 500 ProblemDetails.
|
||||
/// Registered via AddExceptionHandler and invoked by UseExceptionHandler.
|
||||
/// </summary>
|
||||
public sealed class DomainExceptionHandler : IExceptionHandler
|
||||
{
|
||||
private readonly IProblemDetailsService _problemDetails;
|
||||
|
||||
public DomainExceptionHandler(IProblemDetailsService problemDetails)
|
||||
=> _problemDetails = problemDetails;
|
||||
|
||||
public async ValueTask<bool> TryHandleAsync(
|
||||
HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
|
||||
{
|
||||
if (exception is not DomainException domain)
|
||||
return false;
|
||||
|
||||
httpContext.Response.StatusCode = domain.StatusCode;
|
||||
|
||||
return await _problemDetails.TryWriteAsync(new ProblemDetailsContext
|
||||
{
|
||||
HttpContext = httpContext,
|
||||
Exception = domain,
|
||||
ProblemDetails = new ProblemDetails
|
||||
{
|
||||
Status = domain.StatusCode,
|
||||
Title = "Domain rule violation",
|
||||
Detail = domain.Message,
|
||||
Extensions = { ["code"] = domain.Code }
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace ERPCore.System.Errors;
|
||||
|
||||
/// <summary>
|
||||
/// Stable domain error codes carried in the <c>code</c> extension of RFC 7807
|
||||
/// ProblemDetails responses. The authoritative catalog lives in
|
||||
/// docs/11-BACKEND-PHASE1.md; add codes here as endpoints are implemented so
|
||||
/// the two stay in sync.
|
||||
/// </summary>
|
||||
public static class ErrorCodes
|
||||
{
|
||||
public const string Validation = "validation_error";
|
||||
public const string NotFound = "not_found";
|
||||
public const string Conflict = "conflict";
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCore;Username=CHANGE_ME;Password=CHANGE_ME"
|
||||
},
|
||||
"Jwt": {
|
||||
"Issuer": "ERPCore",
|
||||
"Audience": "ERPCore.Clients",
|
||||
"SigningKey": "CHANGE_ME_DEV_ONLY_32+_CHARS",
|
||||
"AccessTokenMinutes": 120
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
# Backend — PROGRESS (Phase 1: Inventory & Supply Chain)
|
||||
|
||||
Legend: `[ ]` not started · `[~]` in progress · `[x]` done
|
||||
Spec: `docs/10-BACKEND-PHASE1.md` (model + rules) · `docs/11-BACKEND-PHASE1.md` (API)
|
||||
Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** as the code. When ticking `[x]`, append a short note + any deviation.
|
||||
|
||||
## 0. Bootstrap
|
||||
- [ ] Solution + Web API project (`net10.0`), packages restored (00-CORE §5.4)
|
||||
- [ ] Folder structure per 00-CORE §5.3
|
||||
- [ ] `ErpDbContext` + Npgsql wired; `InitialCreate` migration applied
|
||||
- [ ] Serilog, JWT, Swagger, HealthChecks, ProblemDetails in `Program.cs`
|
||||
- [ ] `IUnitOfWork` + `UnitOfWork` (transaction boundary)
|
||||
- [ ] Generic repository base + interfaces
|
||||
- [ ] `ICurrentUser` (audit stamp from token `sub`)
|
||||
- [ ] ProblemDetails middleware + domain exception → `code` mapping (System/Errors)
|
||||
|
||||
## 1. Master Data
|
||||
- [ ] Item: entity + config + enums (ItemType, TrackingMode)
|
||||
- [ ] Item: repository + service + controller (CRUD, DTOs, ETag)
|
||||
- [ ] UOM + UOM conversions
|
||||
- [ ] Category (hierarchy, `?tree=true`)
|
||||
- [ ] Vendor
|
||||
- [ ] Warehouse + Bin
|
||||
- [ ] Item reorder settings (`PUT /items/{id}/reorder`)
|
||||
|
||||
## 2. Procurement
|
||||
- [ ] Requisition (+ lines) + submit
|
||||
- [ ] RFQ + quotations + comparison
|
||||
- [ ] Purchase Order: create (auto-approve, `approvalRequired` flag), edit-while-open, approve (no-op), cancel
|
||||
- [ ] Purchase Return (outbound movement, reason code)
|
||||
|
||||
## 3. Goods Receipt
|
||||
- [ ] GRN create (against PO / direct), over-receipt tolerance
|
||||
- [ ] GRN confirm → FIFO layer + ledger + PO `qtyReceived` (single UoW txn, Idempotency-Key)
|
||||
- [ ] Inspection hold release / reject
|
||||
|
||||
## 4. Stock Core
|
||||
- [ ] StockLayer + StockLedger entities/config (ledger append-only)
|
||||
- [ ] `FifoCostingService` (consume oldest-first with row lock; valuation)
|
||||
- [ ] Stock enquiry (onHand / available / onHold / inTransit)
|
||||
- [ ] Ledger query · Valuation query
|
||||
|
||||
## 5. Stock Transactions
|
||||
- [ ] Transfer: create → dispatch (consume, In-Transit) → receive (dest layer, cost-preserving)
|
||||
- [ ] Adjustment (auto-post, mandatory reason code)
|
||||
- [ ] Count (cycle/full → enter counts → variance → post)
|
||||
- [ ] Reorder alerts (query) + suggest requisition
|
||||
|
||||
## 6. Cross-cutting
|
||||
- [ ] Audit log on every mutation (who/when/old→new)
|
||||
- [ ] Document numbering sequences (per type, per year)
|
||||
- [ ] Auth: simple in-app login → JWT (`POST /auth/login`)
|
||||
- [ ] JournalEntryStub emitted per stock movement (data only)
|
||||
- [ ] Negative-stock policy enforcement (default block)
|
||||
- [ ] FEFO picking for perishables; block expired / on-hold issue
|
||||
|
||||
## Deferred (Phase 2+ — do NOT build now, hooks only)
|
||||
- [ ] Vendor invoice + three-way match
|
||||
- [ ] Reservation/allocation fulfilment
|
||||
- [ ] RBAC policy enforcement + approval workflow activation
|
||||
|
||||
## Done
|
||||
<!-- move [x] items here with date + note if the active list grows long -->
|
||||
Reference in New Issue
Block a user