diff --git a/.claude/worktrees/agent-a98805dc618e0f67c b/.claude/worktrees/agent-a98805dc618e0f67c
new file mode 160000
index 0000000..b4993b2
--- /dev/null
+++ b/.claude/worktrees/agent-a98805dc618e0f67c
@@ -0,0 +1 @@
+Subproject commit b4993b202deed0c14f4739b3ce363e8c63bb91b4
diff --git a/Backend/ERPCore.Tests/ERPCore.Tests.csproj b/Backend/ERPCore.Tests/ERPCore.Tests.csproj
new file mode 100644
index 0000000..b2bafa9
--- /dev/null
+++ b/Backend/ERPCore.Tests/ERPCore.Tests.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Backend/ERPCore.Tests/NotificationConfigServiceTests.cs b/Backend/ERPCore.Tests/NotificationConfigServiceTests.cs
new file mode 100644
index 0000000..6588fd3
--- /dev/null
+++ b/Backend/ERPCore.Tests/NotificationConfigServiceTests.cs
@@ -0,0 +1,152 @@
+using ERPCore.Domain.Entities;
+using ERPCore.Domain.Notifications;
+using ERPCore.Repositories;
+using ERPCore.Services;
+using ERPCore.System.Errors;
+using ERPCore.Tests.TestSupport;
+using Microsoft.EntityFrameworkCore;
+
+namespace ERPCore.Tests;
+
+public class NotificationConfigServiceTests
+{
+ [Fact]
+ public async Task GetAsync_MergesSavedValuesWithDiscoveredModules()
+ {
+ var dbName = Guid.NewGuid().ToString("N");
+ await using var db = NotificationConfigTestSupport.CreateContext(dbName);
+ await NotificationConfigTestSupport.SeedAsync(db, new[]
+ {
+ new NotificationConfigModule
+ {
+ ModuleKey = "sale.create",
+ EmailEnabled = true,
+ SmsEnabled = false,
+ InAppEnabled = true
+ }
+ });
+
+ var service = CreateService(db);
+
+ var result = await service.GetAsync();
+
+ Assert.Equal(7u, result.RowVersion);
+ Assert.Contains(result.Value.Modules, m => m.ModuleKey == "sale.create" && m.EmailEnabled && !m.SmsEnabled && m.InAppEnabled);
+ Assert.Contains(result.Value.Modules, m => m.ModuleKey == "grn.confirmed" && !m.EmailEnabled && !m.SmsEnabled && !m.InAppEnabled);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_PersistsEmptyAndMultiChannelSelections()
+ {
+ var dbName = Guid.NewGuid().ToString("N");
+ await using var db = NotificationConfigTestSupport.CreateContext(dbName);
+ await NotificationConfigTestSupport.SeedAsync(db);
+
+ var service = CreateService(db);
+ var catalog = new NotificationModuleCatalog();
+ var request = new ERPCore.Dtos.Notifications.UpdateNotificationConfigRequest
+ {
+ Modules = catalog.GetAll().Select((module, index) => new ERPCore.Dtos.Notifications.UpdateNotificationModuleRequest
+ {
+ ModuleKey = module.Key,
+ EmailEnabled = index == 0,
+ SmsEnabled = index == 1,
+ InAppEnabled = index == 1
+ }).ToList()
+ };
+
+ var updated = await service.UpdateAsync(request, 7);
+
+ Assert.Equal(7u, updated.RowVersion);
+ Assert.Contains(updated.Value.Modules, m => m.ModuleKey == catalog.GetAll()[0].Key && m.EmailEnabled && !m.SmsEnabled && !m.InAppEnabled);
+ Assert.Contains(updated.Value.Modules, m => m.ModuleKey == catalog.GetAll()[1].Key && !m.EmailEnabled && m.SmsEnabled && m.InAppEnabled);
+
+ await using var verifyDb = NotificationConfigTestSupport.CreateContext(dbName);
+ var persisted = await verifyDb.NotificationConfigModules.AsNoTracking().ToListAsync();
+ Assert.Contains(persisted, m => m.ModuleKey == catalog.GetAll()[0].Key && m.EmailEnabled && !m.SmsEnabled && !m.InAppEnabled);
+ Assert.Contains(persisted, m => m.ModuleKey == catalog.GetAll()[1].Key && !m.EmailEnabled && m.SmsEnabled && m.InAppEnabled);
+ }
+
+ [Fact]
+ public async Task ResolveEnabledChannelsAsync_ReturnsEmptyWhenNothingEnabled()
+ {
+ var dbName = Guid.NewGuid().ToString("N");
+ await using var db = NotificationConfigTestSupport.CreateContext(dbName);
+ await NotificationConfigTestSupport.SeedAsync(db, new[]
+ {
+ new NotificationConfigModule
+ {
+ ModuleKey = "sale.create",
+ EmailEnabled = false,
+ SmsEnabled = false,
+ InAppEnabled = false
+ }
+ });
+
+ var service = CreateService(db);
+
+ var channels = await service.ResolveEnabledChannelsAsync("sale.create");
+
+ Assert.Empty(channels);
+ }
+
+ [Fact]
+ public async Task ResolveEnabledChannelsAsync_ReturnsMultipleChannels()
+ {
+ var dbName = Guid.NewGuid().ToString("N");
+ await using var db = NotificationConfigTestSupport.CreateContext(dbName);
+ await NotificationConfigTestSupport.SeedAsync(db, new[]
+ {
+ new NotificationConfigModule
+ {
+ ModuleKey = "sale.create",
+ EmailEnabled = true,
+ SmsEnabled = true,
+ InAppEnabled = false
+ }
+ });
+
+ var service = CreateService(db);
+
+ var channels = await service.ResolveEnabledChannelsAsync("sale.create");
+
+ Assert.Equal(new[] { NotificationChannel.Email, NotificationChannel.Sms }, channels);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_MapsConcurrencyFailuresToDomainException()
+ {
+ var dbName = Guid.NewGuid().ToString("N");
+ await using var db = NotificationConfigTestSupport.CreateContext(dbName);
+ await NotificationConfigTestSupport.SeedAsync(db);
+
+ var service = CreateService(db, new ThrowingUnitOfWork());
+ var catalog = new NotificationModuleCatalog();
+ var request = new ERPCore.Dtos.Notifications.UpdateNotificationConfigRequest
+ {
+ Modules = catalog.GetAll().Select(module => new ERPCore.Dtos.Notifications.UpdateNotificationModuleRequest
+ {
+ ModuleKey = module.Key,
+ EmailEnabled = false,
+ SmsEnabled = false,
+ InAppEnabled = false
+ }).ToList()
+ };
+
+ var ex = await Assert.ThrowsAsync(() => service.UpdateAsync(request, 7));
+ Assert.Equal(ErrorCodes.ConcurrencyConflict, ex.Code);
+ Assert.Equal(412, ex.StatusCode);
+ }
+
+ private static NotificationConfigService CreateService(ERPCore.Infra.Persistence.ErpDbContext db, ERPCore.Infra.UoW.IUnitOfWork? uow = null)
+ {
+ var configRepo = new Repository(db);
+ var moduleRepo = new Repository(db);
+ return new NotificationConfigService(
+ configRepo,
+ moduleRepo,
+ new NotificationModuleCatalog(),
+ new TestCurrentUser(),
+ uow ?? new ERPCore.Infra.UoW.UnitOfWork(db));
+ }
+}
diff --git a/Backend/ERPCore.Tests/NotificationModuleCatalogTests.cs b/Backend/ERPCore.Tests/NotificationModuleCatalogTests.cs
new file mode 100644
index 0000000..c4230e2
--- /dev/null
+++ b/Backend/ERPCore.Tests/NotificationModuleCatalogTests.cs
@@ -0,0 +1,17 @@
+using ERPCore.Services;
+
+namespace ERPCore.Tests;
+
+public class NotificationModuleCatalogTests
+{
+ [Fact]
+ public void GetAll_ReturnsStableUniqueModuleKeys()
+ {
+ var catalog = new NotificationModuleCatalog();
+
+ Assert.NotEmpty(catalog.GetAll());
+ Assert.Contains(catalog.GetAll(), m => m.Key == "sale.create");
+ Assert.Contains(catalog.GetAll(), m => m.Key == "grn.confirmed");
+ Assert.Equal(catalog.GetAll().Count, catalog.GetAll().Select(m => m.Key).Distinct(StringComparer.OrdinalIgnoreCase).Count());
+ }
+}
diff --git a/Backend/ERPCore.Tests/TestSupport/NotificationConfigTestSupport.cs b/Backend/ERPCore.Tests/TestSupport/NotificationConfigTestSupport.cs
new file mode 100644
index 0000000..55128d3
--- /dev/null
+++ b/Backend/ERPCore.Tests/TestSupport/NotificationConfigTestSupport.cs
@@ -0,0 +1,70 @@
+using ERPCore.Domain.Entities;
+using ERPCore.Infra.Auth;
+using ERPCore.Infra.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Storage;
+
+namespace ERPCore.Tests.TestSupport;
+
+internal sealed class TestCurrentUser : ICurrentUser
+{
+ public string UserId => "system";
+ public int AuditUserId => User.SystemUserId;
+ public bool IsAuthenticated => false;
+}
+
+internal sealed class ThrowingUnitOfWork : ERPCore.Infra.UoW.IUnitOfWork
+{
+ public Task SaveChangesAsync(CancellationToken ct = default)
+ => throw new DbUpdateConcurrencyException("Test concurrency conflict.");
+
+ public Task ExecuteInTransactionAsync(Func action, CancellationToken ct = default)
+ => throw new NotSupportedException();
+
+ public Task ExecuteInTransactionAsync(Func> action, CancellationToken ct = default)
+ => throw new NotSupportedException();
+}
+
+internal static class NotificationConfigTestSupport
+{
+ private static readonly InMemoryDatabaseRoot Root = new();
+
+ public static ErpDbContext CreateContext(string name)
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(name, Root)
+ .Options;
+
+ return new ErpDbContext(options, new TestCurrentUser());
+ }
+
+ public static async Task SeedAsync(ErpDbContext db, IEnumerable? modules = null)
+ {
+ db.Users.Add(new User
+ {
+ UserId = User.SystemUserId,
+ Username = "system",
+ DisplayName = "System"
+ });
+
+ var config = new NotificationConfig
+ {
+ NotificationConfigId = NotificationConfig.SingletonId,
+ UpdatedBy = User.SystemUserId,
+ UpdatedAt = DateTime.UtcNow,
+ RowVersion = 7,
+ };
+
+ if (modules is not null)
+ {
+ foreach (var module in modules)
+ {
+ module.NotificationConfigId = NotificationConfig.SingletonId;
+ config.Modules.Add(module);
+ }
+ }
+
+ db.NotificationConfig.Add(config);
+ await db.SaveChangesAsync();
+ }
+}
diff --git a/Backend/ERPCore/Controllers/AuthController.cs b/Backend/ERPCore/Controllers/AuthController.cs
index 0b7dd25..172b8db 100644
--- a/Backend/ERPCore/Controllers/AuthController.cs
+++ b/Backend/ERPCore/Controllers/AuthController.cs
@@ -27,13 +27,15 @@ public sealed class AuthController : ControllerBase
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)
+ public AuthController(IAuthUserService users, IAuthRecoveryService recovery, IAuthAltService alt, IRoleService roles, IAuditService audit)
{
_users = users;
_recovery = recovery;
_alt = alt;
_roles = roles;
+ _audit = audit;
}
///
@@ -68,6 +70,7 @@ 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);
}
@@ -78,9 +81,13 @@ 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)]
diff --git a/Backend/ERPCore/Controllers/BranchConfigController.cs b/Backend/ERPCore/Controllers/BranchConfigController.cs
new file mode 100644
index 0000000..8ea5430
--- /dev/null
+++ b/Backend/ERPCore/Controllers/BranchConfigController.cs
@@ -0,0 +1,44 @@
+using ERPCore.Dtos.Config;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers;
+
+///
+/// Branch configuration endpoints — the singleton toggle for whether this deployment
+/// requires a branch on Employee/Warehouse records at all.
+///
+/// Authorization: writes are admitted by the inherited ERP door policy only, same
+/// Phase-1 posture as — any ERP-admitted user can
+/// flip this flag until per-endpoint RBAC lands.
+///
+///
+[Route("api/v1/branch-config")]
+public sealed class BranchConfigController : ApiControllerBase
+{
+ private readonly IBranchConfigService _config;
+
+ public BranchConfigController(IBranchConfigService config) => _config = config;
+
+ [HttpGet]
+ [ProducesResponseType(typeof(BranchConfigDto), StatusCodes.Status200OK)]
+ public async Task> 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> Update(
+ [FromBody] UpdateBranchConfigRequest request, CancellationToken ct)
+ {
+ var expected = RequireIfMatch();
+ var result = await _config.UpdateAsync(request, expected, ct);
+ SetETag(result.RowVersion);
+ return Ok(result.Value);
+ }
+}
diff --git a/Backend/ERPCore/Controllers/BundleSalesController.cs b/Backend/ERPCore/Controllers/BundleSalesController.cs
index b1d99a5..d768c97 100644
--- a/Backend/ERPCore/Controllers/BundleSalesController.cs
+++ b/Backend/ERPCore/Controllers/BundleSalesController.cs
@@ -29,6 +29,14 @@ public sealed class BundleSalesController : ApiControllerBase
return result is null ? NotFound() : Ok(result);
}
+ [HttpPost("templates")]
+ [ProducesResponseType(typeof(BundleSaleTemplateDto), StatusCodes.Status201Created)]
+ public async Task> 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), StatusCodes.Status200OK)]
public async Task>> List(
diff --git a/Backend/ERPCore/Controllers/DashboardController.cs b/Backend/ERPCore/Controllers/DashboardController.cs
index b10062f..eeaf72d 100644
--- a/Backend/ERPCore/Controllers/DashboardController.cs
+++ b/Backend/ERPCore/Controllers/DashboardController.cs
@@ -15,6 +15,6 @@ public sealed class DashboardController : ApiControllerBase
/// Aggregate counts for stock, GRN, and procurement.
[HttpGet("stats")]
[ProducesResponseType(typeof(DashboardStatsDto), StatusCodes.Status200OK)]
- public async Task> GetStats(CancellationToken ct)
- => Ok(await _dashboard.GetStatsAsync(ct));
+ public async Task> GetStats([FromQuery] int? branchId, CancellationToken ct)
+ => Ok(await _dashboard.GetStatsAsync(branchId, ct));
}
diff --git a/Backend/ERPCore/Controllers/GrnsController.cs b/Backend/ERPCore/Controllers/GrnsController.cs
index 3ad1f5a..1ba4861 100644
--- a/Backend/ERPCore/Controllers/GrnsController.cs
+++ b/Backend/ERPCore/Controllers/GrnsController.cs
@@ -12,15 +12,18 @@ public sealed class GrnsController : ApiControllerBase
{
private readonly IGrnService _grns;
- public GrnsController(IGrnService grns) => _grns = grns;
+ public GrnsController(IGrnService grns)
+ {
+ _grns = grns;
+ }
/// List GRNs, newest first.
[HttpGet]
[ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
public async Task>> List(
[FromQuery] PageQuery query, [FromQuery] GrnStatus? status, [FromQuery] int? poId,
- [FromQuery] int? vendorId, [FromQuery] int? warehouseId, CancellationToken ct)
- => Ok(await _grns.ListAsync(query, status, poId, vendorId, warehouseId, ct));
+ [FromQuery] int? vendorId, [FromQuery] int? warehouseId, [FromQuery] int? branchId, CancellationToken ct)
+ => Ok(await _grns.ListAsync(query, status, poId, vendorId, warehouseId, branchId, ct));
[HttpGet("{grnId:int}")]
[ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)]
diff --git a/Backend/ERPCore/Controllers/Hrm/BranchesController.cs b/Backend/ERPCore/Controllers/Hrm/BranchesController.cs
index fb62839..95739e6 100644
--- a/Backend/ERPCore/Controllers/Hrm/BranchesController.cs
+++ b/Backend/ERPCore/Controllers/Hrm/BranchesController.cs
@@ -61,4 +61,14 @@ public sealed class BranchesController : ApiControllerBase
await _branches.SetStatusAsync(branchId, request.Status, ct);
return NoContent();
}
+
+ [HttpDelete("{branchId:int}")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task Delete(int branchId, CancellationToken ct)
+ {
+ await _branches.DeleteAsync(branchId, ct);
+ return NoContent();
+ }
}
diff --git a/Backend/ERPCore/Controllers/Hrm/DepartmentsController.cs b/Backend/ERPCore/Controllers/Hrm/DepartmentsController.cs
index 2a84611..25af869 100644
--- a/Backend/ERPCore/Controllers/Hrm/DepartmentsController.cs
+++ b/Backend/ERPCore/Controllers/Hrm/DepartmentsController.cs
@@ -62,4 +62,14 @@ public sealed class DepartmentsController : ApiControllerBase
await _departments.SetStatusAsync(departmentId, request.Status, ct);
return NoContent();
}
+
+ [HttpDelete("{departmentId:int}")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task Delete(int departmentId, CancellationToken ct)
+ {
+ await _departments.DeleteAsync(departmentId, ct);
+ return NoContent();
+ }
}
diff --git a/Backend/ERPCore/Controllers/Hrm/DesignationsController.cs b/Backend/ERPCore/Controllers/Hrm/DesignationsController.cs
index 6ebc1fe..ba2000b 100644
--- a/Backend/ERPCore/Controllers/Hrm/DesignationsController.cs
+++ b/Backend/ERPCore/Controllers/Hrm/DesignationsController.cs
@@ -61,4 +61,14 @@ public sealed class DesignationsController : ApiControllerBase
await _designations.SetStatusAsync(designationId, request.Status, ct);
return NoContent();
}
+
+ [HttpDelete("{designationId:int}")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task Delete(int designationId, CancellationToken ct)
+ {
+ await _designations.DeleteAsync(designationId, ct);
+ return NoContent();
+ }
}
diff --git a/Backend/ERPCore/Controllers/Hrm/EmployeesController.cs b/Backend/ERPCore/Controllers/Hrm/EmployeesController.cs
index 645a90b..bde2285 100644
--- a/Backend/ERPCore/Controllers/Hrm/EmployeesController.cs
+++ b/Backend/ERPCore/Controllers/Hrm/EmployeesController.cs
@@ -83,7 +83,6 @@ public sealed class EmployeesController : ApiControllerBase
return Ok(result.Value);
}
- /// Never a hard delete — Employee is retained forever (docs/12-BACKEND-HRM.md C.2).
[HttpPatch("{employeeId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -93,6 +92,17 @@ public sealed class EmployeesController : ApiControllerBase
return NoContent();
}
+ /// Permanently deletes the employee and all dependent HR records. Blocked (409) if payroll history exists.
+ [HttpDelete("{employeeId:int}")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task Delete(int employeeId, CancellationToken ct)
+ {
+ await _employees.DeleteAsync(employeeId, ct);
+ return NoContent();
+ }
+
[HttpPost("{employeeId:int}/link-user")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
diff --git a/Backend/ERPCore/Controllers/Hrm/EmploymentTypesController.cs b/Backend/ERPCore/Controllers/Hrm/EmploymentTypesController.cs
index e80d878..e954b73 100644
--- a/Backend/ERPCore/Controllers/Hrm/EmploymentTypesController.cs
+++ b/Backend/ERPCore/Controllers/Hrm/EmploymentTypesController.cs
@@ -61,4 +61,14 @@ public sealed class EmploymentTypesController : ApiControllerBase
await _employmentTypes.SetStatusAsync(employmentTypeId, request.Status, ct);
return NoContent();
}
+
+ [HttpDelete("{employmentTypeId:int}")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task Delete(int employmentTypeId, CancellationToken ct)
+ {
+ await _employmentTypes.DeleteAsync(employmentTypeId, ct);
+ return NoContent();
+ }
}
diff --git a/Backend/ERPCore/Controllers/Hrm/HrDocumentTypesController.cs b/Backend/ERPCore/Controllers/Hrm/HrDocumentTypesController.cs
index 6b4b557..8e6824e 100644
--- a/Backend/ERPCore/Controllers/Hrm/HrDocumentTypesController.cs
+++ b/Backend/ERPCore/Controllers/Hrm/HrDocumentTypesController.cs
@@ -61,4 +61,14 @@ public sealed class HrDocumentTypesController : ApiControllerBase
await _types.SetStatusAsync(hrDocumentTypeId, request.Status, ct);
return NoContent();
}
+
+ [HttpDelete("{hrDocumentTypeId:int}")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task Delete(int hrDocumentTypeId, CancellationToken ct)
+ {
+ await _types.DeleteAsync(hrDocumentTypeId, ct);
+ return NoContent();
+ }
}
diff --git a/Backend/ERPCore/Controllers/Hrm/LeaveTypesController.cs b/Backend/ERPCore/Controllers/Hrm/LeaveTypesController.cs
index 9f28099..91aef6d 100644
--- a/Backend/ERPCore/Controllers/Hrm/LeaveTypesController.cs
+++ b/Backend/ERPCore/Controllers/Hrm/LeaveTypesController.cs
@@ -61,4 +61,14 @@ public sealed class LeaveTypesController : ApiControllerBase
await _leaveTypes.SetStatusAsync(leaveTypeId, request.Status, ct);
return NoContent();
}
+
+ [HttpDelete("{leaveTypeId:int}")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task Delete(int leaveTypeId, CancellationToken ct)
+ {
+ await _leaveTypes.DeleteAsync(leaveTypeId, ct);
+ return NoContent();
+ }
}
diff --git a/Backend/ERPCore/Controllers/Hrm/SalaryComponentsController.cs b/Backend/ERPCore/Controllers/Hrm/SalaryComponentsController.cs
index d8108cd..a84322d 100644
--- a/Backend/ERPCore/Controllers/Hrm/SalaryComponentsController.cs
+++ b/Backend/ERPCore/Controllers/Hrm/SalaryComponentsController.cs
@@ -61,4 +61,14 @@ public sealed class SalaryComponentsController : ApiControllerBase
await _components.SetStatusAsync(salaryComponentId, request.Status, ct);
return NoContent();
}
+
+ [HttpDelete("{salaryComponentId:int}")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task Delete(int salaryComponentId, CancellationToken ct)
+ {
+ await _components.DeleteAsync(salaryComponentId, ct);
+ return NoContent();
+ }
}
diff --git a/Backend/ERPCore/Controllers/Hrm/WorkShiftsController.cs b/Backend/ERPCore/Controllers/Hrm/WorkShiftsController.cs
index 88148d9..2b2dd2d 100644
--- a/Backend/ERPCore/Controllers/Hrm/WorkShiftsController.cs
+++ b/Backend/ERPCore/Controllers/Hrm/WorkShiftsController.cs
@@ -61,4 +61,14 @@ public sealed class WorkShiftsController : ApiControllerBase
await _shifts.SetStatusAsync(workShiftId, request.Status, ct);
return NoContent();
}
+
+ [HttpDelete("{workShiftId:int}")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task Delete(int workShiftId, CancellationToken ct)
+ {
+ await _shifts.DeleteAsync(workShiftId, ct);
+ return NoContent();
+ }
}
diff --git a/Backend/ERPCore/Controllers/NotificationConfigController.cs b/Backend/ERPCore/Controllers/NotificationConfigController.cs
new file mode 100644
index 0000000..13b035b
--- /dev/null
+++ b/Backend/ERPCore/Controllers/NotificationConfigController.cs
@@ -0,0 +1,34 @@
+using ERPCore.Dtos.Notifications;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers;
+
+[Route("api/v1/notification-config")]
+public sealed class NotificationConfigController : ApiControllerBase
+{
+ private readonly INotificationConfigService _config;
+
+ public NotificationConfigController(INotificationConfigService config) => _config = config;
+
+ [HttpGet]
+ [ProducesResponseType(typeof(NotificationConfigDto), StatusCodes.Status200OK)]
+ public async Task> 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> Update([FromBody] UpdateNotificationConfigRequest request, CancellationToken ct)
+ {
+ var expected = RequireIfMatch();
+ var result = await _config.UpdateAsync(request, expected, ct);
+ SetETag(result.RowVersion);
+ return Ok(result.Value);
+ }
+}
diff --git a/Backend/ERPCore/Controllers/ProductionRunsController.cs b/Backend/ERPCore/Controllers/ProductionRunsController.cs
index 00d0eb8..3937f86 100644
--- a/Backend/ERPCore/Controllers/ProductionRunsController.cs
+++ b/Backend/ERPCore/Controllers/ProductionRunsController.cs
@@ -24,6 +24,19 @@ public sealed class ProductionRunsController : ApiControllerBase
CancellationToken ct)
=> Ok(await _runs.ListAsync(query, status, templateId, warehouseId, ct));
+ /// Produced-items report (FR-MFG-13): one row per run that has produced
+ /// something, plus a summary total over every filtered row.
+ [HttpGet("produced")]
+ [ProducesResponseType(typeof(ProducedListResponseDto), StatusCodes.Status200OK)]
+ public async Task> 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)]
diff --git a/Backend/ERPCore/Controllers/PurchaseInvoicesController.cs b/Backend/ERPCore/Controllers/PurchaseInvoicesController.cs
new file mode 100644
index 0000000..35a8696
--- /dev/null
+++ b/Backend/ERPCore/Controllers/PurchaseInvoicesController.cs
@@ -0,0 +1,102 @@
+using ERPCore.Domain.Enums;
+using ERPCore.Dtos.Common;
+using ERPCore.Dtos.Grn;
+using ERPCore.Dtos.Procurement;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers;
+
+/// Purchase invoice endpoints — vendor invoices consolidating Confirmed GRNs.
+[Route("api/v1/purchase-invoices")]
+public sealed class PurchaseInvoicesController : ApiControllerBase
+{
+ private readonly IPurchaseInvoiceService _invoices;
+ private readonly IPurchaseInvoicePaymentService _payments;
+
+ public PurchaseInvoicesController(IPurchaseInvoiceService invoices, IPurchaseInvoicePaymentService payments)
+ {
+ _invoices = invoices;
+ _payments = payments;
+ }
+
+ [HttpGet]
+ [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
+ public async Task>> List(
+ [FromQuery] PageQuery query, [FromQuery] PurchaseInvoiceStatus? status, [FromQuery] int? vendorId, CancellationToken ct)
+ => Ok(await _invoices.ListAsync(query, status, vendorId, ct));
+
+ /// Confirmed, not-yet-invoiced GRNs for a vendor — candidates for a new/edited invoice.
+ [HttpGet("eligible-grns")]
+ [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)]
+ public async Task>> 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> 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> 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> 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> 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> Cancel(int purchaseInvoiceId, CancellationToken ct)
+ => Ok(await _invoices.CancelAsync(purchaseInvoiceId, ct));
+
+ /// Pay the vendor against this invoice's balance, in full or in installments; posts a real GL journal entry.
+ [HttpPost("{purchaseInvoiceId:int}/payments")]
+ [ProducesResponseType(typeof(PurchaseInvoicePaymentDto), StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task> 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);
+ }
+
+ /// Payment history for this invoice, newest first.
+ [HttpGet("{purchaseInvoiceId:int}/payments")]
+ [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task>> ListPayments(int purchaseInvoiceId, CancellationToken ct)
+ => Ok(await _payments.ListAsync(purchaseInvoiceId, ct));
+}
diff --git a/Backend/ERPCore/Controllers/SalesDayEndController.cs b/Backend/ERPCore/Controllers/SalesDayEndController.cs
new file mode 100644
index 0000000..4695c04
--- /dev/null
+++ b/Backend/ERPCore/Controllers/SalesDayEndController.cs
@@ -0,0 +1,50 @@
+using ERPCore.Dtos.Common;
+using ERPCore.Dtos.Sales;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers;
+
+/// 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).
+[Route("api/v1/sales-day-end")]
+public sealed class SalesDayEndController : ApiControllerBase
+{
+ private readonly ISalesDayEndService _dayEnds;
+
+ public SalesDayEndController(ISalesDayEndService dayEnds) => _dayEnds = dayEnds;
+
+ /// List day-end closes, newest first.
+ [HttpGet]
+ [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
+ public async Task>> List(
+ [FromQuery] PageQuery query, [FromQuery] int? cashierUserId, CancellationToken ct)
+ => Ok(await _dayEnds.ListAsync(query, cashierUserId, ct));
+
+ /// What closing this cashier/date right now would include (or the existing close's totals, if already closed).
+ [HttpGet("preview")]
+ [ProducesResponseType(typeof(SalesDayEndPreviewDto), StatusCodes.Status200OK)]
+ public async Task> 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> GetById(int salesDayEndId, CancellationToken ct)
+ {
+ var dto = await _dayEnds.GetAsync(salesDayEndId, ct);
+ return dto is null ? NotFound() : Ok(dto);
+ }
+
+ /// 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.
+ [HttpPost]
+ [ProducesResponseType(typeof(SalesDayEndDto), StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task> Close([FromBody] CreateSalesDayEndRequest request, CancellationToken ct)
+ {
+ var dto = await _dayEnds.CloseAsync(request, ct);
+ return Created($"/api/v1/sales-day-end/{dto.SalesDayEndId}", dto);
+ }
+}
diff --git a/Backend/ERPCore/Controllers/SalesInvoicesController.cs b/Backend/ERPCore/Controllers/SalesInvoicesController.cs
index 29eab9e..3720f0d 100644
--- a/Backend/ERPCore/Controllers/SalesInvoicesController.cs
+++ b/Backend/ERPCore/Controllers/SalesInvoicesController.cs
@@ -10,8 +10,13 @@ namespace ERPCore.Controllers;
public sealed class SalesInvoicesController : ApiControllerBase
{
private readonly ISalesInvoiceService _invoices;
+ private readonly ISalesInvoicePaymentService _payments;
- public SalesInvoicesController(ISalesInvoiceService invoices) => _invoices = invoices;
+ public SalesInvoicesController(ISalesInvoiceService invoices, ISalesInvoicePaymentService payments)
+ {
+ _invoices = invoices;
+ _payments = payments;
+ }
[HttpGet]
[ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
@@ -20,8 +25,9 @@ public sealed class SalesInvoicesController : ApiControllerBase
[FromQuery] SalesInvoiceStatus? status,
[FromQuery] int? customerId,
[FromQuery] int? warehouseId,
+ [FromQuery] int? branchId,
CancellationToken ct)
- => Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, ct));
+ => Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, ct));
[HttpGet("{salesInvoiceId:int}")]
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
@@ -70,4 +76,23 @@ public sealed class SalesInvoicesController : ApiControllerBase
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
public async Task> Cancel(int salesInvoiceId, CancellationToken ct)
=> Ok(await _invoices.CancelAsync(salesInvoiceId, ct));
+
+ /// Pay the customer's balance against this invoice, in full or in installments; posts a real GL journal entry.
+ [HttpPost("{salesInvoiceId:int}/payments")]
+ [ProducesResponseType(typeof(SalesInvoicePaymentDto), StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task> 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);
+ }
+
+ /// Payment history for this invoice, newest first.
+ [HttpGet("{salesInvoiceId:int}/payments")]
+ [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task>> ListPayments(int salesInvoiceId, CancellationToken ct)
+ => Ok(await _payments.ListAsync(salesInvoiceId, ct));
}
diff --git a/Backend/ERPCore/Controllers/StockAdjustmentsController.cs b/Backend/ERPCore/Controllers/StockAdjustmentsController.cs
index 51b2544..f8a1b52 100644
--- a/Backend/ERPCore/Controllers/StockAdjustmentsController.cs
+++ b/Backend/ERPCore/Controllers/StockAdjustmentsController.cs
@@ -17,8 +17,8 @@ public sealed class StockAdjustmentsController : ApiControllerBase
[HttpGet]
[ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
public async Task>> List(
- [FromQuery] PageQuery query, [FromQuery] int? warehouseId, [FromQuery] int? reasonCodeId, CancellationToken ct)
- => Ok(await _adjustments.ListAsync(query, warehouseId, reasonCodeId, ct));
+ [FromQuery] PageQuery query, [FromQuery] int? warehouseId, [FromQuery] int? branchId, [FromQuery] int? reasonCodeId, CancellationToken ct)
+ => Ok(await _adjustments.ListAsync(query, warehouseId, branchId, reasonCodeId, ct));
/// Get one adjustment with its lines and the ledger entries it posted.
[HttpGet("{adjustmentId:int}")]
diff --git a/Backend/ERPCore/Controllers/StockController.cs b/Backend/ERPCore/Controllers/StockController.cs
index 3189268..609ac08 100644
--- a/Backend/ERPCore/Controllers/StockController.cs
+++ b/Backend/ERPCore/Controllers/StockController.cs
@@ -28,8 +28,8 @@ public sealed class StockController : ApiControllerBase
[HttpGet("on-hand/list")]
[ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
public async Task>> OnHandList(
- [FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] PageQuery query, CancellationToken ct)
- => Ok(await _stock.GetOnHandListAsync(itemId, warehouseId, query, ct));
+ [FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] int? branchId, [FromQuery] PageQuery query, CancellationToken ct)
+ => Ok(await _stock.GetOnHandListAsync(itemId, warehouseId, branchId, query, ct));
///
/// Immutable movement history. sourceDocType/sourceDocId answer "what did
@@ -39,11 +39,11 @@ public sealed class StockController : ApiControllerBase
[HttpGet("ledger")]
[ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
public async Task>> Ledger(
- [FromQuery] int? itemId, [FromQuery] int? warehouseId,
+ [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, from, to, sourceDocType, sourceDocId, query, ct));
+ => Ok(await _stock.GetLedgerAsync(itemId, warehouseId, branchId, from, to, sourceDocType, sourceDocId, query, ct));
[HttpGet("valuation")]
[ProducesResponseType(typeof(StockValuationDto), StatusCodes.Status200OK)]
@@ -54,8 +54,8 @@ public sealed class StockController : ApiControllerBase
[HttpGet("reorder-alerts")]
[ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
public async Task>> ReorderAlerts(
- [FromQuery] int? warehouseId, [FromQuery] PageQuery query, CancellationToken ct)
- => Ok(await _reorder.GetAlertsAsync(warehouseId, query, ct));
+ [FromQuery] int? warehouseId, [FromQuery] int? branchId, [FromQuery] PageQuery query, CancellationToken ct)
+ => Ok(await _reorder.GetAlertsAsync(warehouseId, branchId, query, ct));
/// Create a draft requisition for an item's suggested reorder quantity.
[HttpPost("reorder-alerts/{itemId:int}/requisition")]
diff --git a/Backend/ERPCore/Controllers/StockCountsController.cs b/Backend/ERPCore/Controllers/StockCountsController.cs
index aa8b477..3802f1f 100644
--- a/Backend/ERPCore/Controllers/StockCountsController.cs
+++ b/Backend/ERPCore/Controllers/StockCountsController.cs
@@ -18,8 +18,8 @@ public sealed class StockCountsController : ApiControllerBase
[HttpGet]
[ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
public async Task>> List(
- [FromQuery] PageQuery query, [FromQuery] CountStatus? status, [FromQuery] int? warehouseId, CancellationToken ct)
- => Ok(await _counts.ListAsync(query, status, warehouseId, ct));
+ [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)]
diff --git a/Backend/ERPCore/Controllers/StockTransfersController.cs b/Backend/ERPCore/Controllers/StockTransfersController.cs
index 49d1a3f..67d9525 100644
--- a/Backend/ERPCore/Controllers/StockTransfersController.cs
+++ b/Backend/ERPCore/Controllers/StockTransfersController.cs
@@ -19,8 +19,8 @@ public sealed class StockTransfersController : ApiControllerBase
[ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
public async Task>> List(
[FromQuery] PageQuery query, [FromQuery] TransferStatus? status,
- [FromQuery] int? srcWarehouseId, [FromQuery] int? destWarehouseId, CancellationToken ct)
- => Ok(await _transfers.ListAsync(query, status, srcWarehouseId, destWarehouseId, ct));
+ [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)]
diff --git a/Backend/ERPCore/Controllers/UsersController.cs b/Backend/ERPCore/Controllers/UsersController.cs
index bb1e3cf..e71bf7d 100644
--- a/Backend/ERPCore/Controllers/UsersController.cs
+++ b/Backend/ERPCore/Controllers/UsersController.cs
@@ -1,6 +1,9 @@
+using ERPCore.Domain.Enums;
+using ERPCore.Dtos.Audit;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Dtos.Users;
+using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -15,11 +18,15 @@ 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)
+ public UsersController(IUserManagementService users, IEmployeeUserLinkService links, IAuditService audit, ICurrentUser currentUser)
{
_users = users;
_links = links;
+ _audit = audit;
+ _currentUser = currentUser;
}
/// Advisory forward-direction lookup: does a Staff record already exist with this email? (docs/12-BACKEND-HRM.md A.5)
@@ -30,8 +37,8 @@ public sealed class UsersController : ApiControllerBase
[HttpGet]
[ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
- public async Task>> List([FromQuery] PageQuery query, CancellationToken ct)
- => Ok(await _users.ListAsync(query, ct));
+ public async Task>> List([FromQuery] PageQuery query, [FromQuery] EntityStatus? status, [FromQuery] string? roleCode, CancellationToken ct)
+ => Ok(await _users.ListAsync(query, status, roleCode, ct));
/// AuthHex UserType options for the create-user form's select.
[HttpGet("user-types")]
@@ -53,7 +60,7 @@ public sealed class UsersController : ApiControllerBase
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task> Create([FromBody] CreateUserRequest request, CancellationToken ct)
{
- var result = await _users.CreateAsync(request, ct);
+ var result = await _users.CreateAsync(request, _currentUser.AuditUserId, ct);
return Created($"/api/v1/users/{result.UserId}", result);
}
@@ -62,4 +69,18 @@ public sealed class UsersController : ApiControllerBase
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task> UpdateRole(int userId, [FromBody] UpdateUserRoleRequest request, CancellationToken ct)
=> Ok(await _users.UpdateRoleAsync(userId, request, ct));
+
+ /// Revokes/restores standing inside ERPCore only — see .
+ [HttpPatch("{userId:int}/status")]
+ [ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task> UpdateStatus(int userId, [FromBody] UpdateUserStatusRequest request, CancellationToken ct)
+ => Ok(await _users.SetStatusAsync(userId, request.Status, ct));
+
+ /// Device/login history ERPCore captures itself — not AuthHex's "currently active sessions"
+ /// (which only ever reports the caller's own). See .
+ [HttpGet("{userId:int}/login-devices")]
+ [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
+ public async Task>> ListLoginDevices(int userId, [FromQuery] PageQuery query, CancellationToken ct)
+ => Ok(await _audit.ListLoginDevicesAsync(userId, query, ct));
}
diff --git a/Backend/ERPCore/Controllers/WarehousesController.cs b/Backend/ERPCore/Controllers/WarehousesController.cs
index ff3304b..939bc94 100644
--- a/Backend/ERPCore/Controllers/WarehousesController.cs
+++ b/Backend/ERPCore/Controllers/WarehousesController.cs
@@ -15,8 +15,9 @@ public sealed class WarehousesController : ApiControllerBase
[HttpGet]
[ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
- public async Task>> List([FromQuery] PageQuery query, CancellationToken ct)
- => Ok(await _warehouses.ListAsync(query, ct));
+ public async Task>> List(
+ [FromQuery] PageQuery query, [FromQuery] int? branchId, CancellationToken ct)
+ => Ok(await _warehouses.ListAsync(query, branchId, ct));
[HttpGet("{warehouseId:int}")]
[ProducesResponseType(typeof(WarehouseDto), StatusCodes.Status200OK)]
diff --git a/Backend/ERPCore/Domain/DocumentTypes.cs b/Backend/ERPCore/Domain/DocumentTypes.cs
index 69e1419..f00ad99 100644
--- a/Backend/ERPCore/Domain/DocumentTypes.cs
+++ b/Backend/ERPCore/Domain/DocumentTypes.cs
@@ -10,6 +10,7 @@ public static class DocumentTypes
public const string Rfq = "RFQ";
public const string PurchaseOrder = "PO";
public const string Grn = "GRN";
+ public const string PurchaseInvoice = "PINV";
public const string Transfer = "TRF";
public const string Adjustment = "ADJ";
public const string Count = "CNT";
@@ -20,5 +21,9 @@ public static class DocumentTypes
public const string SalesInvoice = "SI";
public const string SalesSlip = "SSL";
public const string BundleSale = "BND";
+ public const string BundleSaleTemplate = "TPL";
public const string SalesReturn = "SRET";
+
+ /// Cashier day-end close-out (Sales) — DEND-2026-00001.
+ public const string SalesDayEnd = "DEND";
}
diff --git a/Backend/ERPCore/Domain/Entities/Branch.cs b/Backend/ERPCore/Domain/Entities/Branch.cs
index 7a71081..bf71632 100644
--- a/Backend/ERPCore/Domain/Entities/Branch.cs
+++ b/Backend/ERPCore/Domain/Entities/Branch.cs
@@ -3,16 +3,19 @@ using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
///
-/// Branch/location master (FR-HR-MD-01) — multi-branch readiness. Referenced
-/// optionally by and .
+/// Branch/location master (FR-HR-MD-01) — multi-branch readiness. Required by
+/// , optionally referenced by .
/// Deactivated, not deleted, when referenced. Model: docs/12-BACKEND-HRM.md Part C.1.
///
public class Branch
{
public int BranchId { get; set; }
+
+ /// Server-generated on create ("BR-001", "BR-002", ...) — never accepted from the client.
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; }
diff --git a/Backend/ERPCore/Domain/Entities/BranchConfig.cs b/Backend/ERPCore/Domain/Entities/BranchConfig.cs
new file mode 100644
index 0000000..37d83c0
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/BranchConfig.cs
@@ -0,0 +1,27 @@
+namespace ERPCore.Domain.Entities;
+
+///
+/// Branch configuration — a singleton row (single-tenant, docs/00-CORE §1) gating whether
+/// this deployment uses branches at all. When 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
+/// Employee.BranchId/Warehouse.BranchId stay NOT NULL either way, only
+/// the UI/UX requirement toggles. See .
+///
+public class BranchConfig
+{
+ /// Always 1 - the singleton row's id.
+ public const int SingletonId = 1;
+
+ public int ConfigId { get; set; }
+
+ public bool BranchingEnabled { get; set; } = true;
+
+ public DateTime? UpdatedAt { get; set; }
+
+ public int? UpdatedBy { get; set; }
+ public User? UpdatedByUser { get; set; }
+
+ /// PostgreSQL xmin-backed optimistic concurrency token (ETag source).
+ public uint RowVersion { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/BundleSale.cs b/Backend/ERPCore/Domain/Entities/BundleSale.cs
index 3129935..cfdb355 100644
--- a/Backend/ERPCore/Domain/Entities/BundleSale.cs
+++ b/Backend/ERPCore/Domain/Entities/BundleSale.cs
@@ -29,5 +29,11 @@ public class BundleSale
public DateTime? UpdatedAt { get; set; }
public int ConcurrencyStamp { get; set; }
+ /// Set once this bundle's business date is closed by — a
+ /// closed bundle sale is frozen (no further edits). Same role as ;
+ /// a bundle sale is a cashier document exactly like a sales slip, just priced as a set.
+ public int? DayEndId { get; set; }
+ public SalesDayEnd? DayEnd { get; set; }
+
public ICollection Lines { get; set; } = new List();
}
diff --git a/Backend/ERPCore/Domain/Entities/Employee.cs b/Backend/ERPCore/Domain/Entities/Employee.cs
index 2248904..b19635b 100644
--- a/Backend/ERPCore/Domain/Entities/Employee.cs
+++ b/Backend/ERPCore/Domain/Entities/Employee.cs
@@ -6,8 +6,10 @@ namespace ERPCore.Domain.Entities;
/// Staff record (FR-HR-MD-02) — distinct from (the system login
/// account): not every employee has a login, and not every login belongs to an
/// employee. is the optional, explicit, human-confirmed link
-/// between the two (docs/12-BACKEND-HRM.md A.5/C.2, Part B.3.2). Never hard-deleted —
-/// separation is recorded via + .
+/// between the two (docs/12-BACKEND-HRM.md A.5/C.2, Part B.3.2). Normal separation is
+/// recorded via + rather than deletion;
+/// an explicit hard delete is also available ()
+/// and cascades to every dependent HR record, blocked only if payroll history exists.
/// is user-entered (not -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".
@@ -50,7 +52,7 @@ public class Employee
public Designation? Designation { get; set; }
public int EmploymentTypeId { get; set; }
public EmploymentType? EmploymentType { get; set; }
- public int? BranchId { get; set; }
+ public int BranchId { get; set; }
public Branch? Branch { get; set; }
public int WorkShiftId { get; set; }
public WorkShift? WorkShift { get; set; }
diff --git a/Backend/ERPCore/Domain/Entities/Grn.cs b/Backend/ERPCore/Domain/Entities/Grn.cs
index 5642f1b..e742923 100644
--- a/Backend/ERPCore/Domain/Entities/Grn.cs
+++ b/Backend/ERPCore/Domain/Entities/Grn.cs
@@ -22,6 +22,11 @@ public class Grn
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
+ /// Owning branch, set explicitly at creation — warehouses are shared across
+ /// branches, so branch scope can no longer be derived from .
+ public int BranchId { get; set; }
+ public Branch? Branch { get; set; }
+
public GrnStatus Status { get; set; } = GrnStatus.Draft;
public int CreatedBy { get; set; }
@@ -30,6 +35,16 @@ public class Grn
public DateTime CreatedAt { get; set; }
public DateTime? PostedAt { get; set; }
+ /// Journal number of the real GL journal entry posted for this GRN's receipt (set on confirm).
+ public string? GlJournalNo { get; set; }
+ public DateTime? GlPostedAt { get; set; }
+
+ /// 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
+ /// .
+ public int? PurchaseInvoiceId { get; set; }
+ public PurchaseInvoice? PurchaseInvoice { get; set; }
+
/// PostgreSQL xmin-backed optimistic concurrency token.
public uint RowVersion { get; set; }
diff --git a/Backend/ERPCore/Domain/Entities/Item.cs b/Backend/ERPCore/Domain/Entities/Item.cs
index 94b22a7..2a6fa07 100644
--- a/Backend/ERPCore/Domain/Entities/Item.cs
+++ b/Backend/ERPCore/Domain/Entities/Item.cs
@@ -44,11 +44,12 @@ public class Item
public string? TaxClass { get; set; }
///
- /// Optional fixed selling price used by Sales only. null means "use stock value"
- /// (the item is sold at its FIFO stock cost at sale time); a value is the fixed sale price.
- /// Never enters costing/GRN/FIFO (docs/10 Part C.1, C.9).
+ /// Optional selling price range and flat price used by Sales only.
+ /// null values mean the field is not set. These values never enter costing/GRN/FIFO.
///
- public decimal? SalePrice { get; set; }
+ public decimal? SellingMinPrice { get; set; }
+ public decimal? SellingMaxPrice { get; set; }
+ public decimal? SellingFlatPrice { get; set; }
///
/// How much one pack holds, as the user entered it — 500 with
diff --git a/Backend/ERPCore/Domain/Entities/LoginActivity.cs b/Backend/ERPCore/Domain/Entities/LoginActivity.cs
new file mode 100644
index 0000000..2352ed1
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/LoginActivity.cs
@@ -0,0 +1,22 @@
+namespace ERPCore.Domain.Entities;
+
+///
+/// One row per successful sign-in, captured directly by ERPCore (not AuthHex) so an
+/// admin can see a user's device/login history — AuthHex's own session listing only
+/// ever reports the currently-authenticated caller's own sessions, with no admin-scoped
+/// call to inspect anyone else's. This is a history of past logins, not a live "currently
+/// active sessions" view (that would need AuthHex to expose one).
+///
+public class LoginActivity
+{
+ public int LoginActivityId { get; set; }
+ public int UserId { get; set; }
+ public User? User { get; set; }
+
+ public string? IpAddress { get; set; }
+ public string? UserAgent { get; set; }
+ /// Best-effort "Browser on OS" label parsed from for display.
+ public string? DeviceLabel { get; set; }
+
+ public DateTime LoginAt { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/NotificationConfig.cs b/Backend/ERPCore/Domain/Entities/NotificationConfig.cs
new file mode 100644
index 0000000..5c1824e
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/NotificationConfig.cs
@@ -0,0 +1,19 @@
+namespace ERPCore.Domain.Entities;
+
+///
+/// Notification configuration (new settings module) - singleton root row.
+///
+public class NotificationConfig
+{
+ public const int SingletonId = 1;
+
+ public int NotificationConfigId { get; set; }
+
+ public DateTime? UpdatedAt { get; set; }
+ public int? UpdatedBy { get; set; }
+ public User? UpdatedByUser { get; set; }
+
+ public uint RowVersion { get; set; }
+
+ public ICollection Modules { get; set; } = new List();
+}
diff --git a/Backend/ERPCore/Domain/Entities/NotificationConfigModule.cs b/Backend/ERPCore/Domain/Entities/NotificationConfigModule.cs
new file mode 100644
index 0000000..7051721
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/NotificationConfigModule.cs
@@ -0,0 +1,14 @@
+namespace ERPCore.Domain.Entities;
+
+public class NotificationConfigModule
+{
+ public int NotificationConfigModuleId { get; set; }
+
+ public int NotificationConfigId { get; set; }
+ public NotificationConfig? NotificationConfig { get; set; }
+
+ public string ModuleKey { get; set; } = string.Empty;
+ public bool EmailEnabled { get; set; }
+ public bool SmsEnabled { get; set; }
+ public bool InAppEnabled { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/ProductConfig.cs b/Backend/ERPCore/Domain/Entities/ProductConfig.cs
index 4120cdc..ca55f16 100644
--- a/Backend/ERPCore/Domain/Entities/ProductConfig.cs
+++ b/Backend/ERPCore/Domain/Entities/ProductConfig.cs
@@ -1,22 +1,24 @@
namespace ERPCore.Domain.Entities;
///
-/// Product configuration (FR-MD-11) — a singleton row (single-tenant, docs/00-CORE §1)
+/// Product configuration (FR-MD-11) - a singleton row (single-tenant, docs/00-CORE §1)
/// gating optional product master-data features.
///
/// and are enforced
/// server-side: an Item write carrying a subcategory/brand while the flag is off is
-/// rejected with CONFIG_DISABLED. is
-/// advisory only — items carry no item-type reference (see ),
-/// so there is nothing on a write to reject; the frontend honours it by hiding the
-/// builder's type section. Reads are never gated, so existing data stays visible after a
-/// flag is switched off.
+/// rejected with CONFIG_DISABLED. is advisory only
+/// - items carry no item-type reference (see ), so there is nothing
+/// on a write to reject; the frontend honours it by hiding the variant builder.
+/// , ,
+/// and are enforced
+/// on item writes and drive the create/edit screens. Reads are never gated, so existing
+/// data stays visible after a flag is switched off.
///
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
///
public class ProductConfig
{
- /// Always 1 — the singleton row's id.
+ /// Always 1 - the singleton row's id.
public const int SingletonId = 1;
public int ConfigId { get; set; }
@@ -24,6 +26,10 @@ public class ProductConfig
public bool SubcategoriesEnabled { get; set; } = true;
public bool BrandsEnabled { get; set; } = true;
public bool ItemTypesEnabled { get; set; } = true;
+ public bool SellingPricesEnabled { get; set; } = true;
+ public bool SerialNumbersEnabled { get; set; } = true;
+ public bool BatchListsEnabled { get; set; } = true;
+ public bool WarrantyPeriodsEnabled { get; set; } = true;
public DateTime? UpdatedAt { get; set; }
diff --git a/Backend/ERPCore/Domain/Entities/PurchaseInvoice.cs b/Backend/ERPCore/Domain/Entities/PurchaseInvoice.cs
new file mode 100644
index 0000000..2e73d7f
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/PurchaseInvoice.cs
@@ -0,0 +1,50 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// Vendor invoice consolidating one or more Confirmed GRNs for the same vendor
+/// (each linked GRN becomes one , not re-itemized).
+/// Draft is editable (GRNs can be re-selected); Posted reclassifies the GRN clearing
+/// balance to Accounts Payable (see PurchaseInvoiceService.PostAsync); vendor payments
+/// are then recorded against this aggregate rather than against the individual GRNs.
+///
+public class PurchaseInvoice
+{
+ public int PurchaseInvoiceId { get; set; }
+ public string DocNo { get; set; } = string.Empty;
+
+ public string VendorInvoiceNo { get; set; } = string.Empty;
+ public DateTime VendorInvoiceDate { get; set; }
+
+ public int VendorId { get; set; }
+ public Vendor? Vendor { get; set; }
+
+ public string VendorSnapshotName { get; set; } = string.Empty;
+ public string? VendorSnapshotTaxNo { get; set; }
+
+ public PurchaseInvoiceStatus Status { get; set; } = PurchaseInvoiceStatus.Draft;
+
+ public decimal Subtotal { get; set; }
+ public decimal VatTotal { get; set; }
+ public decimal GrandTotal { get; set; }
+ public decimal PaidAmount { get; set; }
+ public decimal BalanceAmount { get; set; }
+
+ /// Journal number of the AP-recognition GL journal entry posted when this
+ /// invoice is Posted (Debit GRN Clearing / Credit Accounts Payable) — see
+ /// PurchaseInvoiceService.PostAsync.
+ public string? GlJournalNo { get; set; }
+ public DateTime? GlPostedAt { get; set; }
+
+ public int CreatedBy { get; set; }
+ public User? Creator { get; set; }
+
+ public DateTime CreatedAt { get; set; }
+ public DateTime? UpdatedAt { get; set; }
+
+ public uint RowVersion { get; set; }
+
+ public ICollection Lines { get; set; } = new List();
+ public ICollection Payments { get; set; } = new List();
+}
diff --git a/Backend/ERPCore/Domain/Entities/PurchaseInvoiceLine.cs b/Backend/ERPCore/Domain/Entities/PurchaseInvoiceLine.cs
new file mode 100644
index 0000000..a652660
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/PurchaseInvoiceLine.cs
@@ -0,0 +1,21 @@
+namespace ERPCore.Domain.Entities;
+
+///
+/// One row per consolidated GRN on a — a straight
+/// consolidation of that GRN's line totals, not a re-itemization (docs: Purchase
+/// Invoice consolidates GRNs, it does not re-enter item lines).
+///
+public class PurchaseInvoiceLine
+{
+ public int PurchaseInvoiceLineId { get; set; }
+
+ public int PurchaseInvoiceId { get; set; }
+ public PurchaseInvoice? PurchaseInvoice { get; set; }
+
+ public int GrnId { get; set; }
+ public Grn? Grn { get; set; }
+
+ public decimal Subtotal { get; set; }
+ public decimal VatTotal { get; set; }
+ public decimal LineTotal { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/PurchaseInvoicePayment.cs b/Backend/ERPCore/Domain/Entities/PurchaseInvoicePayment.cs
new file mode 100644
index 0000000..1a65cac
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/PurchaseInvoicePayment.cs
@@ -0,0 +1,32 @@
+namespace ERPCore.Domain.Entities;
+
+///
+/// A vendor payment made against a Posted purchase invoice's balance (installments
+/// allowed — see PurchaseInvoicePaymentService.PayAsync). Posts its own real GL
+/// journal entry (Debit Accounts Payable / Credit the selected bank-or-cash account)
+/// before being recorded here.
+///
+public class PurchaseInvoicePayment
+{
+ public int PurchaseInvoicePaymentId { get; set; }
+
+ public int PurchaseInvoiceId { get; set; }
+ public PurchaseInvoice? PurchaseInvoice { get; set; }
+
+ public decimal Amount { get; set; }
+ public DateTime PaymentDate { get; set; }
+
+ /// GL's numeric id for the bank/cash account the payment was made from.
+ public long GlBankAccountId { get; set; }
+ /// Snapshot of the account's display name at payment time (GL account lists have no local FK).
+ public string BankAccountName { get; set; } = string.Empty;
+
+ public string? Reference { get; set; }
+
+ /// Journal number of the real GL journal entry this payment posted.
+ public string? GlJournalNo { get; set; }
+
+ public int CreatedBy { get; set; }
+ public User? Creator { get; set; }
+ public DateTime CreatedAt { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/PurchaseReturn.cs b/Backend/ERPCore/Domain/Entities/PurchaseReturn.cs
index a663950..26b3442 100644
--- a/Backend/ERPCore/Domain/Entities/PurchaseReturn.cs
+++ b/Backend/ERPCore/Domain/Entities/PurchaseReturn.cs
@@ -28,5 +28,12 @@ public class PurchaseReturn
public DateTime CreatedAt { get; set; }
+ /// Journal number of the real GL journal entry posted for this return (set on create — it auto-posts).
+ /// Reverses the Inventory/Clearing lines a Grn posts — carries no unit
+ /// price/VAT of its own, so it can't reverse a VAT-recoverable line the way a real credit note would.
+ public string? GlJournalNo { get; set; }
+ public DateTime? GlPostedAt { get; set; }
+
public ICollection Lines { get; set; } = new List();
}
+
diff --git a/Backend/ERPCore/Domain/Entities/SalesDayEnd.cs b/Backend/ERPCore/Domain/Entities/SalesDayEnd.cs
new file mode 100644
index 0000000..63ff8f6
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/SalesDayEnd.cs
@@ -0,0 +1,46 @@
+namespace ERPCore.Domain.Entities;
+
+///
+/// A cashier's end-of-day close-out. Aggregates every the
+/// cashier posted on , freezes them against this record
+/// () so a slip can only ever belong to one day-end,
+/// and posts one consolidated GL journal entry for the day's revenue/tax/COGS —
+/// the sales-side equivalent of how posts per receipt. Unique per
+/// (CashierUserId, BusinessDate): closing twice replays the existing record instead
+/// of creating a second one (same idempotent-replay pattern as Grn.ConfirmAsync).
+///
+public class SalesDayEnd
+{
+ public int SalesDayEndId { get; set; }
+ public string DocNo { get; set; } = string.Empty;
+
+ public int CashierUserId { get; set; }
+ public User? CashierUser { get; set; }
+
+ /// The calendar date being closed (matched against each slip's , UTC).
+ public DateOnly BusinessDate { get; set; }
+
+ public int SlipCount { get; set; }
+ public int BundleCount { get; set; }
+ public decimal Subtotal { get; set; }
+ public decimal DiscountTotal { get; set; }
+ public decimal TaxTotal { get; set; }
+ public decimal GrandTotal { get; set; }
+
+ /// Sum of the FIFO cost consumed for these slips (from ), for the COGS GL lines.
+ public decimal CostOfGoodsSold { get; set; }
+
+ /// Journal number of the real GL journal entry posted for this close (null when there was nothing to post).
+ public string? GlJournalNo { get; set; }
+ public DateTime? GlPostedAt { get; set; }
+
+ public int ClosedBy { get; set; }
+ public User? ClosedByUser { get; set; }
+ public DateTime ClosedAt { get; set; }
+
+ /// PostgreSQL xmin-backed optimistic concurrency token.
+ public uint RowVersion { get; set; }
+
+ public ICollection Slips { get; set; } = new List();
+ public ICollection BundleSales { get; set; } = new List();
+}
diff --git a/Backend/ERPCore/Domain/Entities/SalesInvoice.cs b/Backend/ERPCore/Domain/Entities/SalesInvoice.cs
index 15f40de..60f25cc 100644
--- a/Backend/ERPCore/Domain/Entities/SalesInvoice.cs
+++ b/Backend/ERPCore/Domain/Entities/SalesInvoice.cs
@@ -17,6 +17,11 @@ public class SalesInvoice
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
+ /// Owning branch, set explicitly at creation — warehouses are shared across
+ /// branches, so branch scope can no longer be derived from .
+ public int BranchId { get; set; }
+ public Branch? Branch { get; set; }
+
public SalesInvoiceType InvoiceType { get; set; } = SalesInvoiceType.B2C;
public SalesInvoiceStatus Status { get; set; } = SalesInvoiceStatus.Draft;
@@ -29,6 +34,14 @@ public class SalesInvoice
public decimal PaidAmount { get; set; }
public decimal BalanceAmount { get; set; }
+ /// Journal number of the revenue-recognition GL journal entry posted when this
+ /// invoice is Posted (Debit Accounts Receivable / Credit Sales Revenue + Tax, plus COGS —
+ /// see SalesPostingService.PostInvoiceAsync). Unlike a SalesSlip, an invoice posts its own
+ /// GL entry immediately rather than waiting for Sales Day End, since invoices aren't a
+ /// cashier document and don't go through that close-out.
+ public string? GlJournalNo { get; set; }
+ public DateTime? GlPostedAt { get; set; }
+
public int CreatedBy { get; set; }
public User? Creator { get; set; }
@@ -38,4 +51,5 @@ public class SalesInvoice
public uint RowVersion { get; set; }
public ICollection Lines { get; set; } = new List();
+ public ICollection Payments { get; set; } = new List();
}
diff --git a/Backend/ERPCore/Domain/Entities/SalesInvoicePayment.cs b/Backend/ERPCore/Domain/Entities/SalesInvoicePayment.cs
new file mode 100644
index 0000000..27705b3
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/SalesInvoicePayment.cs
@@ -0,0 +1,32 @@
+namespace ERPCore.Domain.Entities;
+
+///
+/// A customer payment made against a Posted sales invoice's balance (installments
+/// allowed — see SalesInvoicePaymentService.PayAsync). Posts its own real GL journal
+/// entry (Debit the selected bank-or-cash account / Credit Accounts Receivable) before
+/// being recorded here — the mirror image of .
+///
+public class SalesInvoicePayment
+{
+ public int SalesInvoicePaymentId { get; set; }
+
+ public int SalesInvoiceId { get; set; }
+ public SalesInvoice? SalesInvoice { get; set; }
+
+ public decimal Amount { get; set; }
+ public DateTime PaymentDate { get; set; }
+
+ /// GL's numeric id for the bank/cash account the payment was received into.
+ public long GlBankAccountId { get; set; }
+ /// Snapshot of the account's display name at payment time (GL account lists have no local FK).
+ public string BankAccountName { get; set; } = string.Empty;
+
+ public string? Reference { get; set; }
+
+ /// Journal number of the real GL journal entry this payment posted.
+ public string? GlJournalNo { get; set; }
+
+ public int CreatedBy { get; set; }
+ public User? Creator { get; set; }
+ public DateTime CreatedAt { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/SalesReturn.cs b/Backend/ERPCore/Domain/Entities/SalesReturn.cs
index 749870c..e712d80 100644
--- a/Backend/ERPCore/Domain/Entities/SalesReturn.cs
+++ b/Backend/ERPCore/Domain/Entities/SalesReturn.cs
@@ -28,5 +28,11 @@ public class SalesReturn
public DateTime CreatedAt { get; set; }
+ /// Journal number of the real GL journal entry posted for this return (set on create — it auto-posts).
+ /// Reverses Inventory/COGS only — carries no unit price, so revenue/tax aren't
+ /// reversed here (they'd need a per-line price this document doesn't capture).
+ public string? GlJournalNo { get; set; }
+ public DateTime? GlPostedAt { get; set; }
+
public ICollection Lines { get; set; } = new List();
}
diff --git a/Backend/ERPCore/Domain/Entities/SalesSlip.cs b/Backend/ERPCore/Domain/Entities/SalesSlip.cs
index 46f697a..c3d713f 100644
--- a/Backend/ERPCore/Domain/Entities/SalesSlip.cs
+++ b/Backend/ERPCore/Domain/Entities/SalesSlip.cs
@@ -27,6 +27,10 @@ public class SalesSlip
public decimal PaidAmount { get; set; }
public decimal BalanceAmount { get; set; }
+ /// Set once this slip's business date is closed by — a closed slip is frozen (no further edits).
+ public int? DayEndId { get; set; }
+ public SalesDayEnd? DayEnd { get; set; }
+
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
diff --git a/Backend/ERPCore/Domain/Entities/StockAdjustment.cs b/Backend/ERPCore/Domain/Entities/StockAdjustment.cs
index be583d4..bcece95 100644
--- a/Backend/ERPCore/Domain/Entities/StockAdjustment.cs
+++ b/Backend/ERPCore/Domain/Entities/StockAdjustment.cs
@@ -19,6 +19,11 @@ public class StockAdjustment
public int ReasonCodeId { get; set; }
public ReasonCode? ReasonCode { get; set; }
+ /// Owning branch, set explicitly at creation — warehouses are shared across
+ /// branches, so branch scope can no longer be derived from .
+ public int BranchId { get; set; }
+ public Branch? Branch { get; set; }
+
public AdjustmentStatus Status { get; set; } = AdjustmentStatus.Posted;
public int CreatedBy { get; set; }
@@ -27,5 +32,10 @@ public class StockAdjustment
public DateTime CreatedAt { get; set; }
public uint RowVersion { get; set; }
+ /// Journal number of the real GL journal entry posted for this adjustment (set on create — it
+ /// auto-posts). Increases and decreases post separately (never netted) so gains/losses stay visible.
+ public string? GlJournalNo { get; set; }
+ public DateTime? GlPostedAt { get; set; }
+
public ICollection Lines { get; set; } = new List();
}
diff --git a/Backend/ERPCore/Domain/Entities/StockCount.cs b/Backend/ERPCore/Domain/Entities/StockCount.cs
index 2a2a118..0bc47a5 100644
--- a/Backend/ERPCore/Domain/Entities/StockCount.cs
+++ b/Backend/ERPCore/Domain/Entities/StockCount.cs
@@ -16,6 +16,11 @@ public class StockCount
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
+ /// Owning branch, set explicitly at creation — warehouses are shared across
+ /// branches, so branch scope can no longer be derived from .
+ public int BranchId { get; set; }
+ public Branch? Branch { get; set; }
+
public CountType CountType { get; set; }
public CountStatus Status { get; set; } = CountStatus.Draft;
diff --git a/Backend/ERPCore/Domain/Entities/StockTransfer.cs b/Backend/ERPCore/Domain/Entities/StockTransfer.cs
index 0a17c89..ffd6058 100644
--- a/Backend/ERPCore/Domain/Entities/StockTransfer.cs
+++ b/Backend/ERPCore/Domain/Entities/StockTransfer.cs
@@ -19,6 +19,11 @@ public class StockTransfer
public int DestWarehouseId { get; set; }
public Warehouse? DestWarehouse { get; set; }
+ /// Owning branch, set explicitly at creation — warehouses are shared across
+ /// branches, so branch scope can no longer be derived from src/dest warehouse.
+ public int BranchId { get; set; }
+ public Branch? Branch { get; set; }
+
public TransferStatus Status { get; set; } = TransferStatus.Draft;
public int CreatedBy { get; set; }
diff --git a/Backend/ERPCore/Domain/Entities/Warehouse.cs b/Backend/ERPCore/Domain/Entities/Warehouse.cs
index bbcd8bf..2c6a3ab 100644
--- a/Backend/ERPCore/Domain/Entities/Warehouse.cs
+++ b/Backend/ERPCore/Domain/Entities/Warehouse.cs
@@ -9,6 +9,8 @@ public class Warehouse
public int WarehouseId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
+ public int BranchId { get; set; }
+ public Branch? Branch { get; set; }
public ICollection Bins { get; set; } = new List();
}
diff --git a/Backend/ERPCore/Domain/Enums/AuditAction.cs b/Backend/ERPCore/Domain/Enums/AuditAction.cs
index f7644e8..42aba14 100644
--- a/Backend/ERPCore/Domain/Enums/AuditAction.cs
+++ b/Backend/ERPCore/Domain/Enums/AuditAction.cs
@@ -5,5 +5,8 @@ public enum AuditAction
{
Create,
Update,
- Delete
+ Delete,
+ /// A successful sign-in — written explicitly (not via ErpDbContext's automatic
+ /// entity-change capture, since a login mutates nothing) by AuthController.
+ Login
}
diff --git a/Backend/ERPCore/Domain/Enums/PurchaseInvoiceStatus.cs b/Backend/ERPCore/Domain/Enums/PurchaseInvoiceStatus.cs
new file mode 100644
index 0000000..ee85b40
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/PurchaseInvoiceStatus.cs
@@ -0,0 +1,8 @@
+namespace ERPCore.Domain.Enums;
+
+public enum PurchaseInvoiceStatus
+{
+ Draft,
+ Posted,
+ Cancelled
+}
diff --git a/Backend/ERPCore/Domain/Notifications/Modules/NotificationModules.cs b/Backend/ERPCore/Domain/Notifications/Modules/NotificationModules.cs
new file mode 100644
index 0000000..49c4efb
--- /dev/null
+++ b/Backend/ERPCore/Domain/Notifications/Modules/NotificationModules.cs
@@ -0,0 +1,34 @@
+namespace ERPCore.Domain.Notifications.Modules;
+
+[NotificationModule("sale.create", "Sale Created", "Raised when a sales document is created.", 10)]
+public sealed class SaleCreateNotificationModule;
+
+[NotificationModule("sale.posted", "Sale Posted", "Raised when a sales document is posted.", 20)]
+public sealed class SalePostedNotificationModule;
+
+[NotificationModule("grn.created", "GRN Created", "Raised when a goods receipt note is created.", 30)]
+public sealed class GrnCreatedNotificationModule;
+
+[NotificationModule("grn.confirmed", "GRN Confirmed", "Raised when a goods receipt note is confirmed.", 40)]
+public sealed class GrnConfirmedNotificationModule;
+
+[NotificationModule("purchase.order.created", "Purchase Order Created", "Raised when a purchase order is created.", 50)]
+public sealed class PurchaseOrderCreatedNotificationModule;
+
+[NotificationModule("purchase.order.approved", "Purchase Order Approved", "Raised when a purchase order is approved.", 60)]
+public sealed class PurchaseOrderApprovedNotificationModule;
+
+[NotificationModule("purchase.return.created", "Purchase Return Created", "Raised when a purchase return is created.", 70)]
+public sealed class PurchaseReturnCreatedNotificationModule;
+
+[NotificationModule("stock.adjustment.created", "Stock Adjustment Created", "Raised when a stock adjustment is created.", 80)]
+public sealed class StockAdjustmentCreatedNotificationModule;
+
+[NotificationModule("stock.transfer.created", "Stock Transfer Created", "Raised when a stock transfer is created.", 90)]
+public sealed class StockTransferCreatedNotificationModule;
+
+[NotificationModule("item.created", "Item Created", "Raised when an item master record is created.", 100)]
+public sealed class ItemCreatedNotificationModule;
+
+[NotificationModule("item.updated", "Item Updated", "Raised when an item master record is updated.", 110)]
+public sealed class ItemUpdatedNotificationModule;
diff --git a/Backend/ERPCore/Domain/Notifications/NotificationChannel.cs b/Backend/ERPCore/Domain/Notifications/NotificationChannel.cs
new file mode 100644
index 0000000..eae5ae7
--- /dev/null
+++ b/Backend/ERPCore/Domain/Notifications/NotificationChannel.cs
@@ -0,0 +1,8 @@
+namespace ERPCore.Domain.Notifications;
+
+public enum NotificationChannel
+{
+ Email,
+ Sms,
+ InApp
+}
diff --git a/Backend/ERPCore/Domain/Notifications/NotificationModuleAttribute.cs b/Backend/ERPCore/Domain/Notifications/NotificationModuleAttribute.cs
new file mode 100644
index 0000000..c2db649
--- /dev/null
+++ b/Backend/ERPCore/Domain/Notifications/NotificationModuleAttribute.cs
@@ -0,0 +1,18 @@
+namespace ERPCore.Domain.Notifications;
+
+[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
+public sealed class NotificationModuleAttribute : Attribute
+{
+ public string Key { get; }
+ public string Label { get; }
+ public string Description { get; }
+ public int SortOrder { get; }
+
+ public NotificationModuleAttribute(string key, string label, string description, int sortOrder)
+ {
+ Key = key;
+ Label = label;
+ Description = description;
+ SortOrder = sortOrder;
+ }
+}
diff --git a/Backend/ERPCore/Domain/Notifications/NotificationModuleDefinition.cs b/Backend/ERPCore/Domain/Notifications/NotificationModuleDefinition.cs
new file mode 100644
index 0000000..039e7ed
--- /dev/null
+++ b/Backend/ERPCore/Domain/Notifications/NotificationModuleDefinition.cs
@@ -0,0 +1,7 @@
+namespace ERPCore.Domain.Notifications;
+
+public sealed record NotificationModuleDefinition(
+ string Key,
+ string Label,
+ string Description,
+ int SortOrder);
diff --git a/Backend/ERPCore/Dtos/Audit/AuditDtos.cs b/Backend/ERPCore/Dtos/Audit/AuditDtos.cs
index 1691243..bfb5a07 100644
--- a/Backend/ERPCore/Dtos/Audit/AuditDtos.cs
+++ b/Backend/ERPCore/Dtos/Audit/AuditDtos.cs
@@ -10,3 +10,7 @@ public sealed record AuditLogDto(
/// A GL-ready journal stub emitted per stock movement (FR-STK-13).
public sealed record JournalEntryStubDto(
int JournalId, string SourceDocType, int SourceDocId, string DebitAccount, string CreditAccount, decimal Amount);
+
+/// A captured past sign-in — device history, not a live "currently active sessions" view.
+public sealed record LoginActivityDto(
+ int LoginActivityId, string? IpAddress, string? DeviceLabel, DateTime LoginAt);
diff --git a/Backend/ERPCore/Dtos/Config/BranchConfigDtos.cs b/Backend/ERPCore/Dtos/Config/BranchConfigDtos.cs
new file mode 100644
index 0000000..26976c8
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Config/BranchConfigDtos.cs
@@ -0,0 +1,20 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace ERPCore.Dtos.Config;
+
+/// Branch configuration resource — the singleton toggle for whether this deployment
+/// requires a branch on Employee/Warehouse records at all.
+public sealed record BranchConfigDto(bool BranchingEnabled, DateTime? UpdatedAt, int? UpdatedBy);
+
+///
+/// Full replacement of the flag. UpdatedBy is derived from the token, never posted.
+///
+/// is ? deliberately: [Required] on a
+/// non-nullable bool is a no-op, so an omitted flag would otherwise silently bind to
+/// false. Nullable makes the requirement actually bind — an omitted flag is a 400.
+///
+///
+public sealed class UpdateBranchConfigRequest
+{
+ [Required] public bool? BranchingEnabled { get; set; }
+}
diff --git a/Backend/ERPCore/Dtos/Config/ProductConfigDtos.cs b/Backend/ERPCore/Dtos/Config/ProductConfigDtos.cs
index f4e2e71..67d98d1 100644
--- a/Backend/ERPCore/Dtos/Config/ProductConfigDtos.cs
+++ b/Backend/ERPCore/Dtos/Config/ProductConfigDtos.cs
@@ -4,10 +4,14 @@ namespace ERPCore.Dtos.Config;
///
/// Product configuration resource (docs/11-BACKEND-PHASE1.md §2.8). Singleton.
-/// is advisory (frontend-honoured) — see the entity docs.
+/// is advisory (frontend-honoured) - see the entity docs.
+/// , ,
+/// and are enforced
+/// when items are written.
///
public sealed record ProductConfigDto(
bool SubcategoriesEnabled, bool BrandsEnabled, bool ItemTypesEnabled,
+ bool SellingPricesEnabled, bool SerialNumbersEnabled, bool BatchListsEnabled, bool WarrantyPeriodsEnabled,
DateTime? UpdatedAt, int? UpdatedBy);
///
@@ -15,8 +19,8 @@ public sealed record ProductConfigDto(
///
/// The flags are ? deliberately: [Required] on a non-nullable bool
/// is a no-op (it always has a value), so a body of {} would bind every flag to
-/// false and silently switch all three features off. Nullable makes the requirement
-/// actually bind — an omitted flag is a 400, not an accidental disable.
+/// false and silently switch everything off. Nullable makes the requirement actually
+/// bind - an omitted flag is a 400, not an accidental disable.
///
///
public sealed class UpdateProductConfigRequest
@@ -24,4 +28,8 @@ public sealed class UpdateProductConfigRequest
[Required] public bool? SubcategoriesEnabled { get; set; }
[Required] public bool? BrandsEnabled { get; set; }
[Required] public bool? ItemTypesEnabled { get; set; }
+ [Required] public bool? SellingPricesEnabled { get; set; }
+ [Required] public bool? SerialNumbersEnabled { get; set; }
+ [Required] public bool? BatchListsEnabled { get; set; }
+ [Required] public bool? WarrantyPeriodsEnabled { get; set; }
}
diff --git a/Backend/ERPCore/Dtos/Grn/GrnDtos.cs b/Backend/ERPCore/Dtos/Grn/GrnDtos.cs
index 83f2f1c..6cb0c87 100644
--- a/Backend/ERPCore/Dtos/Grn/GrnDtos.cs
+++ b/Backend/ERPCore/Dtos/Grn/GrnDtos.cs
@@ -15,24 +15,31 @@ public sealed record GrnLineDto(
HoldStatus HoldStatus, int? BatchId, IReadOnlyList WarrantyNumbers);
public sealed record GrnDto(
- int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
- int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, IReadOnlyList Lines);
+ int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, int BranchId, GrnStatus Status,
+ int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, string? GlJournalNo,
+ int? PurchaseInvoiceId, string? PurchaseInvoiceNo, IReadOnlyList Lines);
/// Row shape for GET /grns — line count instead of the lines themselves.
public sealed record GrnSummaryDto(
- int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
- int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount);
+ int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, int BranchId, GrnStatus Status,
+ int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount,
+ int? PurchaseInvoiceId, string? PurchaseInvoiceNo);
public sealed record CreatedLayerDto(
int LayerId, int ItemId, int WarehouseId, int? BatchId,
decimal QtyReceived, decimal QtyRemaining, decimal UnitCost, DateTime ReceiptDate);
public sealed record GrnConfirmResultDto(
- int GrnId, GrnStatus Status, DateTime PostedAt,
+ int GrnId, GrnStatus Status, DateTime PostedAt, string GlJournalNo,
IReadOnlyList CreatedLayers, IReadOnlyList LedgerRefs, PurchaseOrderStatus? PoStatus);
public sealed record ReleaseLineResultDto(int GrnLineId, HoldStatus HoldStatus);
+/// A Confirmed, not-yet-invoiced GRN eligible to be consolidated onto a Purchase Invoice.
+public sealed record GrnEligibleForInvoiceDto(
+ int GrnId, string DocNo, DateTime PostedAt, int WarehouseId,
+ int LineCount, decimal Subtotal, decimal VatTotal, decimal GrandTotal);
+
// Requests ----------------------------------------------------------------------
public sealed class BatchInput
@@ -74,6 +81,9 @@ public sealed class CreateGrnRequest
/// Required for a direct receipt (no PO); ignored otherwise (vendor derives from the PO).
public int? VendorId { get; set; }
[Required] public int WarehouseId { get; set; }
+ /// Owning branch for this GRN — warehouses are shared, so this is what actually
+ /// scopes the document (resolved via IBranchConfigService; required when branching is enabled).
+ [Required] public int BranchId { get; set; }
[Required, MinLength(1)] public List Lines { get; set; } = new();
}
diff --git a/Backend/ERPCore/Dtos/Hrm/EmployeeDtos.cs b/Backend/ERPCore/Dtos/Hrm/EmployeeDtos.cs
index 2be6c3a..8c9e445 100644
--- a/Backend/ERPCore/Dtos/Hrm/EmployeeDtos.cs
+++ b/Backend/ERPCore/Dtos/Hrm/EmployeeDtos.cs
@@ -6,7 +6,7 @@ namespace ERPCore.Dtos.Hrm;
public sealed record EmployeeListItemDto(
int EmployeeId, string EmployeeCode, string FullName, string? Email,
int DepartmentId, string? DepartmentName, int DesignationId, string? DesignationName,
- int EmploymentTypeId, string? EmploymentTypeName, int? BranchId, string? BranchName,
+ int EmploymentTypeId, string? EmploymentTypeName, int BranchId, string? BranchName,
EmployeeStatus Status, bool HasUserLink, DateTime HireDate);
public sealed record EmployeeDetailDto(
@@ -16,7 +16,7 @@ public sealed record EmployeeDetailDto(
string? City, string? PostalCode, string? Country,
string? EmergencyContactName, string? EmergencyContactRelationship, string? EmergencyContactPhone,
DateTime HireDate, DateTime? ConfirmationDate, DateTime? LastWorkingDate,
- int DepartmentId, int DesignationId, int EmploymentTypeId, int? BranchId, int WorkShiftId,
+ int DepartmentId, int DesignationId, int EmploymentTypeId, int BranchId, int WorkShiftId,
int? ReportingManagerId, string? EpfNumber, string? EtfNumber, string? TaxIdentificationNumber,
int? UserId, EmployeeStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
@@ -45,7 +45,7 @@ public sealed class CreateEmployeeRequest
[Required] public int DepartmentId { get; set; }
[Required] public int DesignationId { get; set; }
[Required] public int EmploymentTypeId { get; set; }
- public int? BranchId { get; set; }
+ [Required] public int BranchId { get; set; }
[Required] public int WorkShiftId { get; set; }
public int? ReportingManagerId { get; set; }
@@ -82,7 +82,7 @@ public sealed class UpdateEmployeeRequest
[Required] public int DepartmentId { get; set; }
[Required] public int DesignationId { get; set; }
[Required] public int EmploymentTypeId { get; set; }
- public int? BranchId { get; set; }
+ [Required] public int BranchId { get; set; }
[Required] public int WorkShiftId { get; set; }
public int? ReportingManagerId { get; set; }
diff --git a/Backend/ERPCore/Dtos/Hrm/OrgMasterDtos.cs b/Backend/ERPCore/Dtos/Hrm/OrgMasterDtos.cs
index c90c9eb..ba9f667 100644
--- a/Backend/ERPCore/Dtos/Hrm/OrgMasterDtos.cs
+++ b/Backend/ERPCore/Dtos/Hrm/OrgMasterDtos.cs
@@ -7,20 +7,22 @@ namespace ERPCore.Dtos.Hrm;
// ids, timestamps) excluded from create/update requests (02-SECURITY B.6/C.1).
public sealed record BranchDto(
- int BranchId, string Code, string Name, string? Address, EntityStatus Status,
+ int BranchId, string Code, string Name, string? Address, string? PhoneNumber, EntityStatus Status,
DateTime CreatedAt, DateTime? UpdatedAt);
+// Code is server-generated (BranchService.GenerateCodeAsync) — never accepted from the client.
public sealed class CreateBranchRequest
{
- [Required, StringLength(20)] public string Code { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(500)] public string? Address { get; set; }
+ [StringLength(30)] public string? PhoneNumber { get; set; }
}
public sealed class UpdateBranchRequest
{
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(500)] public string? Address { get; set; }
+ [StringLength(30)] public string? PhoneNumber { get; set; }
}
public sealed record DepartmentDto(
diff --git a/Backend/ERPCore/Dtos/Items/ItemDtos.cs b/Backend/ERPCore/Dtos/Items/ItemDtos.cs
index 35f8d62..32a584d 100644
--- a/Backend/ERPCore/Dtos/Items/ItemDtos.cs
+++ b/Backend/ERPCore/Dtos/Items/ItemDtos.cs
@@ -10,7 +10,7 @@ public sealed record ItemListItemDto(
int ItemId, string Sku, string Name, int CategoryId, int? SubCategoryId, int? BrandId,
int BaseUomId, int? DefaultVendorId, StockNature StockNature, TrackingMode TrackingMode,
Warranty Warranty, int? WarrantyPeriodMonths,
- string? TaxClass, decimal? SalePrice,
+ string? TaxClass, decimal? SellingMinPrice, decimal? SellingMaxPrice, decimal? SellingFlatPrice,
decimal? ContentQty, MeasureUnit? ContentUnit,
decimal? ContentBaseQty, MeasureUnit? ContentBaseUnit,
EntityStatus Status);
@@ -31,7 +31,7 @@ public sealed record ItemDetailDto(
int? SubCategoryId, int? BrandId, int BaseUomId, int? DefaultVendorId,
StockNature StockNature, TrackingMode TrackingMode,
Warranty Warranty, int? WarrantyPeriodMonths,
- string? TaxClass, decimal? SalePrice,
+ string? TaxClass, decimal? SellingMinPrice, decimal? SellingMaxPrice, decimal? SellingFlatPrice,
decimal? ContentQty, MeasureUnit? ContentUnit,
decimal? ContentBaseQty, MeasureUnit? ContentBaseUnit,
EntityStatus Status, IReadOnlyList Reorder,
@@ -66,7 +66,9 @@ public sealed class CreateItemRequest
public int? WarrantyPeriodMonths { get; set; }
[StringLength(20)] public string? TaxClass { get; set; }
/// Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value.
- [Range(0, double.MaxValue)] public decimal? SalePrice { get; set; }
+ [Range(0, double.MaxValue)] public decimal? SellingMinPrice { get; set; }
+ [Range(0, double.MaxValue)] public decimal? SellingMaxPrice { get; set; }
+ [Range(0, double.MaxValue)] public decimal? SellingFlatPrice { get; set; }
///
/// How much one pack holds. Supply with or leave both null
@@ -96,7 +98,9 @@ public sealed class UpdateItemRequest
public int? WarrantyPeriodMonths { get; set; }
[StringLength(20)] public string? TaxClass { get; set; }
/// Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value.
- [Range(0, double.MaxValue)] public decimal? SalePrice { get; set; }
+ [Range(0, double.MaxValue)] public decimal? SellingMinPrice { get; set; }
+ [Range(0, double.MaxValue)] public decimal? SellingMaxPrice { get; set; }
+ [Range(0, double.MaxValue)] public decimal? SellingFlatPrice { get; set; }
///
/// How much one pack holds. Supply with or leave both null
diff --git a/Backend/ERPCore/Dtos/Notifications/NotificationConfigDtos.cs b/Backend/ERPCore/Dtos/Notifications/NotificationConfigDtos.cs
new file mode 100644
index 0000000..9233d00
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Notifications/NotificationConfigDtos.cs
@@ -0,0 +1,29 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace ERPCore.Dtos.Notifications;
+
+public sealed record NotificationModuleConfigDto(
+ string ModuleKey,
+ string Label,
+ string Description,
+ bool EmailEnabled,
+ bool SmsEnabled,
+ bool InAppEnabled);
+
+public sealed record NotificationConfigDto(
+ IReadOnlyList Modules,
+ DateTime? UpdatedAt,
+ int? UpdatedBy);
+
+public sealed class UpdateNotificationModuleRequest
+{
+ [Required, StringLength(100)] public string ModuleKey { get; set; } = string.Empty;
+ [Required] public bool? EmailEnabled { get; set; }
+ [Required] public bool? SmsEnabled { get; set; }
+ [Required] public bool? InAppEnabled { get; set; }
+}
+
+public sealed class UpdateNotificationConfigRequest
+{
+ [Required, MinLength(0)] public List Modules { get; set; } = new();
+}
diff --git a/Backend/ERPCore/Dtos/Procurement/PurchaseInvoiceDtos.cs b/Backend/ERPCore/Dtos/Procurement/PurchaseInvoiceDtos.cs
new file mode 100644
index 0000000..d53b078
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Procurement/PurchaseInvoiceDtos.cs
@@ -0,0 +1,50 @@
+using System.ComponentModel.DataAnnotations;
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Dtos.Procurement;
+
+public sealed record PurchaseInvoiceLineDto(
+ int PurchaseInvoiceLineId, int GrnId, string GrnDocNo, DateTime GrnPostedAt,
+ decimal Subtotal, decimal VatTotal, decimal LineTotal);
+
+public sealed record PurchaseInvoiceTotalsDto(
+ decimal Subtotal, decimal VatTotal, decimal GrandTotal, decimal PaidAmount, decimal BalanceAmount);
+
+public sealed record PurchaseInvoiceDto(
+ int PurchaseInvoiceId, string DocNo, string VendorInvoiceNo, DateTime VendorInvoiceDate,
+ int VendorId, string VendorSnapshotName, string? VendorSnapshotTaxNo,
+ PurchaseInvoiceStatus Status, int GrnCount, int CreatedBy, DateTime CreatedAt, DateTime? UpdatedAt,
+ string? GlJournalNo, DateTime? GlPostedAt,
+ PurchaseInvoiceTotalsDto Totals, IReadOnlyList Lines);
+
+public sealed record PurchaseInvoiceSummaryDto(
+ int PurchaseInvoiceId, string DocNo, string VendorInvoiceNo, DateTime VendorInvoiceDate,
+ int VendorId, string VendorSnapshotName, PurchaseInvoiceStatus Status, int GrnCount,
+ PurchaseInvoiceTotalsDto Totals, DateTime CreatedAt, string? GlJournalNo);
+
+public sealed record PurchaseInvoicePaymentDto(
+ int PurchaseInvoicePaymentId, int PurchaseInvoiceId, decimal Amount, DateTime PaymentDate,
+ long GlBankAccountId, string BankAccountName, string? Reference, string? GlJournalNo, DateTime CreatedAt);
+
+public sealed class CreatePurchaseInvoicePaymentRequest
+{
+ [Range(0.01, double.MaxValue)] public decimal Amount { get; set; }
+ [Required] public long GlBankAccountId { get; set; }
+ [StringLength(100)] public string? Reference { get; set; }
+}
+
+public sealed class CreatePurchaseInvoiceRequest
+{
+ [Required] public int VendorId { get; set; }
+ [Required, StringLength(50)] public string VendorInvoiceNo { get; set; } = string.Empty;
+ [Required] public DateTime VendorInvoiceDate { get; set; }
+ [Required, MinLength(1)] public List GrnIds { get; set; } = new();
+}
+
+public sealed class UpdatePurchaseInvoiceRequest
+{
+ [Required] public int VendorId { get; set; }
+ [Required, StringLength(50)] public string VendorInvoiceNo { get; set; } = string.Empty;
+ [Required] public DateTime VendorInvoiceDate { get; set; }
+ [Required, MinLength(1)] public List GrnIds { get; set; } = new();
+}
diff --git a/Backend/ERPCore/Dtos/Procurement/PurchaseReturnDtos.cs b/Backend/ERPCore/Dtos/Procurement/PurchaseReturnDtos.cs
index 5854fbb..d6fc74a 100644
--- a/Backend/ERPCore/Dtos/Procurement/PurchaseReturnDtos.cs
+++ b/Backend/ERPCore/Dtos/Procurement/PurchaseReturnDtos.cs
@@ -9,12 +9,13 @@ public sealed record PurchaseReturnLineDto(int ReturnLineId, int? GrnLineId, int
public sealed record PurchaseReturnDto(
int ReturnId, string DocNo, int VendorId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
- int CreatedBy, DateTime CreatedAt, IReadOnlyList Lines, IReadOnlyList LedgerRefs);
+ int CreatedBy, DateTime CreatedAt, string? GlJournalNo, DateTime? GlPostedAt,
+ IReadOnlyList Lines, IReadOnlyList LedgerRefs);
/// Row shape for GET /purchase-returns — no lines/ledgerRefs (those need a per-row query).
public sealed record PurchaseReturnSummaryDto(
int ReturnId, string DocNo, int VendorId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
- int CreatedBy, DateTime CreatedAt, int LineCount);
+ int CreatedBy, DateTime CreatedAt, int LineCount, string? GlJournalNo);
// Requests ----------------------------------------------------------------------
diff --git a/Backend/ERPCore/Dtos/Production/RunDtos.cs b/Backend/ERPCore/Dtos/Production/RunDtos.cs
index 9c45f1a..1887c68 100644
--- a/Backend/ERPCore/Dtos/Production/RunDtos.cs
+++ b/Backend/ERPCore/Dtos/Production/RunDtos.cs
@@ -70,6 +70,35 @@ public sealed record RunEventDto(
int EventId, int? RunStageId, RunStageEventType EventType, string? Note,
JsonElement? Payload, int UserId, DateTime CreatedAt);
+///
+/// One row of the produced-items report (GET /production-runs/produced) — the
+/// terminal output of a run that has produced something, costed the same way
+/// PostReceiptAsync costs the finished layer at approve (FR-MFG-13):
+/// unitCost = (Σ ConsumedValue − Σ ReturnedValue) / (ProducedQty − ScrappedQty).
+/// One row per run — server validation ( via
+/// FR-MFG-05) guarantees exactly one item-bearing output per run, always the terminal
+/// one, so run and produced line coincide 1:1.
+/// UnitCost/TotalCost are final only once Status is
+/// Completed — a leftover return can still shrink the pool between the terminal
+/// stage's Done and its Approve, same caveat as .
+///
+public sealed record ProducedLineDto(
+ int RunId, string DocNo, ProductionRunStatus Status,
+ int TemplateId, string TemplateName,
+ int WarehouseId, string WarehouseName,
+ int ItemId, string ItemName,
+ decimal ProducedQty, decimal ScrappedQty, decimal GoodQty,
+ decimal UnitCost, decimal TotalCost,
+ DateTime CreatedAt, DateTime? CompletedAt);
+
+/// Aggregate footer for the produced-items report — totals over every row the filter
+/// matched, not just the current page (there is no pagination here, matching the Sales report
+/// convention of flat, filter-bounded rows).
+public sealed record ProducedSummaryDto(
+ int RunCount, decimal TotalProducedQty, decimal TotalScrappedQty, decimal TotalGoodQty, decimal TotalCost);
+
+public sealed record ProducedListResponseDto(IReadOnlyList Items, ProducedSummaryDto Summary);
+
/// Full run graph (docs/30 §D.2 GET /production-runs/{id}).
public sealed record RunGraphDto(
int RunId, string DocNo, int TemplateId, string TemplateName,
@@ -99,8 +128,10 @@ public sealed class CreateRunRequest
}
///
-/// Per-run scaling override (FR-MFG-08). Only accepted while the stage has not started
-/// (409 STAGE_NOT_EDITABLE).
+/// Per-run scaling override (FR-MFG-08). Accepted before the stage starts, and — for a Stock
+/// input only — while it is running too: raising
+/// draws the extra amount from FIFO, lowering it returns the excess (409
+/// STAGE_NOT_EDITABLE for every other status).
///
public sealed class UpdateStageQuantitiesRequest
{
@@ -116,4 +147,11 @@ public sealed class StageQuantityLine
[Range(0.0001, double.MaxValue)]
public decimal PlannedQty { get; set; }
+
+ ///
+ /// Required only when this line lowers a running Stock input below what it has already
+ /// consumed — the excess is returned to stock the same way ReturnLeftoverAsync
+ /// does, and that always needs a reason (FR-MFG-14). Ignored otherwise.
+ ///
+ public int? ReasonCodeId { get; set; }
}
diff --git a/Backend/ERPCore/Dtos/Rbac/MeDtos.cs b/Backend/ERPCore/Dtos/Rbac/MeDtos.cs
index 9396bcf..f56c32f 100644
--- a/Backend/ERPCore/Dtos/Rbac/MeDtos.cs
+++ b/Backend/ERPCore/Dtos/Rbac/MeDtos.cs
@@ -2,5 +2,13 @@ namespace ERPCore.Dtos.Rbac;
/// Response for `GET /api/v1/auth/me` — the frontend's authoritative source
/// for the current user's role and permitted sidebar nav codes (replaces trusting
-/// the stale, client-only `roleId` cached in localStorage).
-public sealed record MeResponseDto(string? RoleCode, string? RoleName, IReadOnlyList NavCodes);
+/// the stale, client-only `roleId` cached in localStorage). /
+/// come from the caller's linked Employee record, when
+/// one exists — null for a login with no Employee link. gates
+/// branch pickers client-side only (no server-side branch enforcement exists yet,
+/// see docs/BRANCH_ISOLATION_PLAN.md). mirrors the
+/// singleton BranchConfig toggle — when false the frontend hides branch pickers
+/// entirely rather than merely restricting them by role.
+public sealed record MeResponseDto(
+ string? RoleCode, string? RoleName, IReadOnlyList NavCodes,
+ bool IsAdmin, int? BranchId, string? BranchName, bool BranchingEnabled);
diff --git a/Backend/ERPCore/Dtos/Sales/BundleSaleDtos.cs b/Backend/ERPCore/Dtos/Sales/BundleSaleDtos.cs
index a31d3d4..9ab6b6b 100644
--- a/Backend/ERPCore/Dtos/Sales/BundleSaleDtos.cs
+++ b/Backend/ERPCore/Dtos/Sales/BundleSaleDtos.cs
@@ -51,7 +51,6 @@ public sealed class CreateBundleSaleTemplateLineRequest
public sealed class CreateBundleSaleTemplateRequest
{
- [Required] public string TemplateCode { get; set; } = string.Empty;
[Required] public string TemplateName { get; set; } = string.Empty;
public string? Description { get; set; }
[Required, MinLength(1)] public List Lines { get; set; } = new();
diff --git a/Backend/ERPCore/Dtos/Sales/SalesDayEndDtos.cs b/Backend/ERPCore/Dtos/Sales/SalesDayEndDtos.cs
new file mode 100644
index 0000000..398e266
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Sales/SalesDayEndDtos.cs
@@ -0,0 +1,51 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace ERPCore.Dtos.Sales;
+
+// Responses -----------------------------------------------------------------------
+
+/// Qty/revenue sold for one item across a closed (or about-to-close) day, for the day-end report.
+/// Merges sales-slip and bundle-sale lines for the same item into one row.
+public sealed record SalesDayEndItemLineDto(int ItemId, string Sku, string Name, decimal Qty, decimal Revenue);
+
+/// Read-only visibility into that cashier's Sales Invoices for the date — informational
+/// only, appended to and .
+/// Invoices post their own GL entry on Confirm (SalesPostingService.PostInvoiceAsync) and have no
+/// DayEndId, so they're never locked against a close: these fields are computed live from current
+/// data on every read, unlike the frozen Slip/Bundle totals alongside them.
+public sealed record SalesDayEndInvoiceSummaryDto(
+ int InvoiceCount, decimal InvoiceSubtotal, decimal InvoiceDiscountTotal, decimal InvoiceTaxTotal,
+ decimal InvoiceGrandTotal, IReadOnlyList InvoiceNumbers);
+
+public sealed record SalesDayEndDto(
+ int SalesDayEndId, string DocNo, int CashierUserId, DateOnly BusinessDate,
+ int SlipCount, int BundleCount, decimal Subtotal, decimal DiscountTotal, decimal TaxTotal, decimal GrandTotal,
+ decimal CostOfGoodsSold, string? GlJournalNo, DateTime? GlPostedAt, int ClosedBy, DateTime ClosedAt,
+ IReadOnlyList SlipNumbers, IReadOnlyList BundleNumbers, IReadOnlyList ItemBreakdown,
+ SalesDayEndInvoiceSummaryDto Invoices);
+
+public sealed record SalesDayEndSummaryDto(
+ int SalesDayEndId, string DocNo, int CashierUserId, DateOnly BusinessDate,
+ int SlipCount, int BundleCount, decimal GrandTotal, string? GlJournalNo, DateTime ClosedAt);
+
+/// What closing right now would include — call before POST /sales-day-end so the
+/// cashier can see the day's totals, and any still-Draft slips/bundles blocking the close.
+public sealed record SalesDayEndPreviewDto(
+ int CashierUserId, DateOnly BusinessDate, bool AlreadyClosed,
+ int SlipCount, int BundleCount, decimal Subtotal, decimal DiscountTotal, decimal TaxTotal, decimal GrandTotal,
+ IReadOnlyList SlipNumbers, IReadOnlyList BundleNumbers,
+ IReadOnlyList DraftSlipsBlockingClose,
+ IReadOnlyList DraftBundleSalesBlockingClose,
+ SalesDayEndInvoiceSummaryDto Invoices);
+
+public sealed record DraftSlipBlockingCloseDto(int SalesSlipId, string SlipNo, decimal GrandTotal);
+public sealed record DraftBundleBlockingCloseDto(int BundleSaleId, string BundleNo, decimal GrandTotal);
+
+// Requests ------------------------------------------------------------------------
+
+public sealed class CreateSalesDayEndRequest
+{
+ [Required] public int CashierUserId { get; set; }
+ /// Defaults to today (UTC) when omitted.
+ public DateOnly? BusinessDate { get; set; }
+}
diff --git a/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs b/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs
index 6682933..cd037f8 100644
--- a/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs
+++ b/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs
@@ -15,14 +15,26 @@ public sealed record SalesInvoiceTotalsDto(
public sealed record SalesInvoiceDto(
int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId,
- string CustomerSnapshotName, string? CustomerSnapshotTaxNo, int WarehouseId,
+ string CustomerSnapshotName, string? CustomerSnapshotTaxNo, int WarehouseId, int BranchId,
SalesInvoiceType InvoiceType, SalesInvoiceStatus Status, int CreatedBy, DateTime CreatedAt,
- DateTime? UpdatedAt, SalesInvoiceTotalsDto Totals, IReadOnlyList Lines);
+ DateTime? UpdatedAt, string? GlJournalNo, DateTime? GlPostedAt,
+ SalesInvoiceTotalsDto Totals, IReadOnlyList Lines);
public sealed record SalesInvoiceSummaryDto(
int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId,
- string CustomerSnapshotName, int WarehouseId, SalesInvoiceType InvoiceType,
- SalesInvoiceStatus Status, SalesInvoiceTotalsDto Totals, DateTime CreatedAt);
+ string CustomerSnapshotName, int WarehouseId, int BranchId, SalesInvoiceType InvoiceType,
+ SalesInvoiceStatus Status, SalesInvoiceTotalsDto Totals, DateTime CreatedAt, string? GlJournalNo);
+
+public sealed record SalesInvoicePaymentDto(
+ int SalesInvoicePaymentId, int SalesInvoiceId, decimal Amount, DateTime PaymentDate,
+ long GlBankAccountId, string BankAccountName, string? Reference, string? GlJournalNo, DateTime CreatedAt);
+
+public sealed class CreateSalesInvoicePaymentRequest
+{
+ [Range(0.01, double.MaxValue)] public decimal Amount { get; set; }
+ [Required] public long GlBankAccountId { get; set; }
+ [StringLength(100)] public string? Reference { get; set; }
+}
public sealed record SalesInvoicePostingIssueDto(
int SalesInvoiceLineId, int ItemId, string ItemSku, string ItemName, int WarehouseId,
@@ -53,6 +65,9 @@ public sealed class CreateSalesInvoiceRequest
{
[Required] public int CustomerId { get; set; }
[Required] public int WarehouseId { get; set; }
+ /// Owning branch — warehouses are shared, so this is what actually scopes the
+ /// document (resolved via IBranchConfigService).
+ [Required] public int BranchId { get; set; }
[Required, EnumDataType(typeof(SalesInvoiceType))] public SalesInvoiceType InvoiceType { get; set; } = SalesInvoiceType.B2C;
[Required, MinLength(1)] public List Lines { get; set; } = new();
}
@@ -61,6 +76,9 @@ public sealed class UpdateSalesInvoiceRequest
{
[Required] public int CustomerId { get; set; }
[Required] public int WarehouseId { get; set; }
+ /// Owning branch — warehouses are shared, so this is what actually scopes the
+ /// document (resolved via IBranchConfigService).
+ [Required] public int BranchId { get; set; }
[Required, EnumDataType(typeof(SalesInvoiceType))] public SalesInvoiceType InvoiceType { get; set; } = SalesInvoiceType.B2C;
[Required, MinLength(1)] public List Lines { get; set; } = new();
}
diff --git a/Backend/ERPCore/Dtos/Sales/SalesPromotionSuggestionDtos.cs b/Backend/ERPCore/Dtos/Sales/SalesPromotionSuggestionDtos.cs
index dffd1c8..cdd19b4 100644
--- a/Backend/ERPCore/Dtos/Sales/SalesPromotionSuggestionDtos.cs
+++ b/Backend/ERPCore/Dtos/Sales/SalesPromotionSuggestionDtos.cs
@@ -4,7 +4,7 @@ public sealed record SalesFreeIssueRewardOptionDto(
int ItemId,
string Sku,
string Name,
- decimal? SalePrice);
+ decimal? SellingFlatPrice);
public sealed record SalesFreeIssueSuggestionLineDto(
int SalesSlipLineId,
@@ -20,4 +20,4 @@ public sealed record SalesFreeIssueSuggestionDto(
int SalesSlipId,
string SlipNo,
DateTime SlipDate,
- IReadOnlyList Lines);
\ No newline at end of file
+ IReadOnlyList Lines);
diff --git a/Backend/ERPCore/Dtos/Sales/SalesReturnDtos.cs b/Backend/ERPCore/Dtos/Sales/SalesReturnDtos.cs
index 4aab60b..b619d4f 100644
--- a/Backend/ERPCore/Dtos/Sales/SalesReturnDtos.cs
+++ b/Backend/ERPCore/Dtos/Sales/SalesReturnDtos.cs
@@ -9,12 +9,13 @@ public sealed record SalesReturnLineDto(int ReturnLineId, int? SalesInvoiceLineI
public sealed record SalesReturnDto(
int ReturnId, string DocNo, int CustomerId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
- int CreatedBy, DateTime CreatedAt, IReadOnlyList Lines, IReadOnlyList LedgerRefs);
+ int CreatedBy, DateTime CreatedAt, string? GlJournalNo, DateTime? GlPostedAt,
+ IReadOnlyList Lines, IReadOnlyList LedgerRefs);
/// Row shape for GET /sales-returns — no lines/ledgerRefs (those need a per-row query).
public sealed record SalesReturnSummaryDto(
int ReturnId, string DocNo, int CustomerId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
- int CreatedBy, DateTime CreatedAt, int LineCount, decimal TotalQty);
+ int CreatedBy, DateTime CreatedAt, int LineCount, decimal TotalQty, string? GlJournalNo);
///
/// Remaining returnable qty for one sales invoice line — the invoiced qty minus
diff --git a/Backend/ERPCore/Dtos/Stock/AdjustmentDtos.cs b/Backend/ERPCore/Dtos/Stock/AdjustmentDtos.cs
index d7b97ce..0163c66 100644
--- a/Backend/ERPCore/Dtos/Stock/AdjustmentDtos.cs
+++ b/Backend/ERPCore/Dtos/Stock/AdjustmentDtos.cs
@@ -8,13 +8,14 @@ namespace ERPCore.Dtos.Stock;
public sealed record AdjustmentLineDto(int AdjLineId, int ItemId, int? BinId, int? BatchId, decimal QtyDelta);
public sealed record AdjustmentDto(
- int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status,
- int CreatedBy, DateTime CreatedAt, IReadOnlyList Lines, IReadOnlyList LedgerRefs);
+ int AdjustmentId, string DocNo, int WarehouseId, int BranchId, int ReasonCodeId, AdjustmentStatus Status,
+ int CreatedBy, DateTime CreatedAt, string? GlJournalNo, DateTime? GlPostedAt,
+ IReadOnlyList Lines, IReadOnlyList LedgerRefs);
/// Row shape for GET /stock-adjustments — no lines/ledgerRefs (those need a per-row query).
public sealed record AdjustmentSummaryDto(
- int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status,
- int CreatedBy, DateTime CreatedAt, int LineCount);
+ int AdjustmentId, string DocNo, int WarehouseId, int BranchId, int ReasonCodeId, AdjustmentStatus Status,
+ int CreatedBy, DateTime CreatedAt, int LineCount, string? GlJournalNo);
// Requests ----------------------------------------------------------------------
@@ -30,6 +31,9 @@ public sealed class CreateAdjustmentLineInput
public sealed class CreateAdjustmentRequest
{
[Required] public int WarehouseId { get; set; }
+ /// Owning branch — warehouses are shared, so this is what actually scopes the
+ /// document (resolved via IBranchConfigService).
+ [Required] public int BranchId { get; set; }
/// Nullable so an omitted value is a distinct REASON_CODE_REQUIRED error, not 0.
public int? ReasonCodeId { get; set; }
[Required, MinLength(1)] public List Lines { get; set; } = new();
diff --git a/Backend/ERPCore/Dtos/Stock/CountDtos.cs b/Backend/ERPCore/Dtos/Stock/CountDtos.cs
index a75a262..f330163 100644
--- a/Backend/ERPCore/Dtos/Stock/CountDtos.cs
+++ b/Backend/ERPCore/Dtos/Stock/CountDtos.cs
@@ -8,21 +8,24 @@ namespace ERPCore.Dtos.Stock;
public sealed record CountLineDto(int CountLineId, int ItemId, int? BinId, decimal SystemQty, decimal? CountedQty, decimal? Variance);
public sealed record CountDto(
- int CountId, string DocNo, int WarehouseId, CountType CountType, CountStatus Status,
+ int CountId, string DocNo, int WarehouseId, int BranchId, CountType CountType, CountStatus Status,
int CreatedBy, DateTime CreatedAt, IReadOnlyList Lines);
/// Row shape for GET /stock-counts — line count instead of the lines themselves.
public sealed record CountSummaryDto(
- int CountId, string DocNo, int WarehouseId, CountType CountType, CountStatus Status,
+ int CountId, string DocNo, int WarehouseId, int BranchId, CountType CountType, CountStatus Status,
int CreatedBy, DateTime CreatedAt, int LineCount);
-public sealed record CountPostResultDto(int CountId, CountStatus Status, int? AdjustmentId, IReadOnlyList LedgerRefs);
+public sealed record CountPostResultDto(int CountId, CountStatus Status, int? AdjustmentId, string? GlJournalNo, IReadOnlyList LedgerRefs);
// Requests ----------------------------------------------------------------------
public sealed class CreateCountRequest
{
[Required] public int WarehouseId { get; set; }
+ /// Owning branch — warehouses are shared, so this is what actually scopes the
+ /// document (resolved via IBranchConfigService).
+ [Required] public int BranchId { get; set; }
[Required, EnumDataType(typeof(CountType))] public CountType CountType { get; set; }
[Required, MinLength(1)] public List ItemIds { get; set; } = new();
}
diff --git a/Backend/ERPCore/Dtos/Stock/TransferDtos.cs b/Backend/ERPCore/Dtos/Stock/TransferDtos.cs
index 5d4d1d7..08cf57f 100644
--- a/Backend/ERPCore/Dtos/Stock/TransferDtos.cs
+++ b/Backend/ERPCore/Dtos/Stock/TransferDtos.cs
@@ -9,12 +9,12 @@ public sealed record TransferLineDto(
int TransferLineId, int ItemId, int? SrcBinId, int? DestBinId, int? BatchId, decimal Qty, decimal QtyReceived);
public sealed record TransferDto(
- int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId,
+ int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId, int BranchId,
TransferStatus Status, int CreatedBy, DateTime CreatedAt, IReadOnlyList Lines);
/// Row shape for GET /stock-transfers — line count instead of the lines themselves.
public sealed record TransferSummaryDto(
- int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId,
+ int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId, int BranchId,
TransferStatus Status, int CreatedBy, DateTime CreatedAt, int LineCount);
public sealed record ConsumedLayerDto(int LayerId, decimal QtyConsumed, decimal UnitCost);
@@ -42,6 +42,9 @@ public sealed class CreateTransferRequest
{
[Required] public int SrcWarehouseId { get; set; }
[Required] public int DestWarehouseId { get; set; }
+ /// Owning branch for this transfer — warehouses are shared, so this is what
+ /// actually scopes the document (resolved via IBranchConfigService).
+ [Required] public int BranchId { get; set; }
[Required, MinLength(1)] public List Lines { get; set; } = new();
}
diff --git a/Backend/ERPCore/Dtos/Users/UserDtos.cs b/Backend/ERPCore/Dtos/Users/UserDtos.cs
index 2de2099..c8d9ead 100644
--- a/Backend/ERPCore/Dtos/Users/UserDtos.cs
+++ b/Backend/ERPCore/Dtos/Users/UserDtos.cs
@@ -30,6 +30,22 @@ public sealed class CreateUserRequest
/// email-lookup — never automatic, even on an exact email match (docs/12-BACKEND-HRM.md A.5).
///
public int? LinkEmployeeId { get; set; }
+
+ ///
+ /// Required, together with //
+ /// //, whenever
+ /// the assigned Role is not Admin and is not supplied: creates
+ /// the linked Employee record whose BranchId is what actually scopes this account to one
+ /// branch (see BranchAccessService.EnsureBranchVisibleAsync).
+ ///
+ public string? EmployeeCode { get; set; }
+ public int? BranchId { get; set; }
+ public int? DepartmentId { get; set; }
+ public int? DesignationId { get; set; }
+ public int? EmploymentTypeId { get; set; }
+ public int? WorkShiftId { get; set; }
+ /// Defaults to today when omitted.
+ public DateTime? HireDate { get; set; }
}
public sealed class UpdateUserRoleRequest
@@ -37,5 +53,10 @@ public sealed class UpdateUserRoleRequest
[Required] public int RoleId { get; set; }
}
+public sealed class UpdateUserStatusRequest
+{
+ [Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
+}
+
/// AuthHex UserType lookup, for populating the create-user form's select (no local shadow — read-only passthrough).
public sealed record UserTypeOptionDto(Guid UserTypeId, string? Code, string? Description);
diff --git a/Backend/ERPCore/Dtos/Warehouses/WarehouseDtos.cs b/Backend/ERPCore/Dtos/Warehouses/WarehouseDtos.cs
index 37dea5c..83e15f9 100644
--- a/Backend/ERPCore/Dtos/Warehouses/WarehouseDtos.cs
+++ b/Backend/ERPCore/Dtos/Warehouses/WarehouseDtos.cs
@@ -2,8 +2,10 @@ using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Warehouses;
-/// Warehouse resource (docs/11-BACKEND-PHASE1.md §2.5).
-public sealed record WarehouseDto(int WarehouseId, string Code, string Name);
+/// Warehouse resource (docs/11-BACKEND-PHASE1.md §2.5). is
+/// the anchor `docs/BRANCH_ISOLATION_PLAN.md` uses to branch-scope everything that carries
+/// a `warehouseId` (Sales, GRN, stock, ...) without a column on every one of those entities.
+public sealed record WarehouseDto(int WarehouseId, string Code, string Name, int BranchId, string? BranchName);
/// Bin/location resource (docs/11 §2.5).
public sealed record BinDto(int BinId, int WarehouseId, string Code, string? BinType);
@@ -12,6 +14,7 @@ public sealed class CreateWarehouseRequest
{
[Required, StringLength(50)] public string Code { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
+ [Required] public int BranchId { get; set; }
}
public sealed class CreateBinRequest
diff --git a/Backend/ERPCore/Infra/Auth/CurrentUser.cs b/Backend/ERPCore/Infra/Auth/CurrentUser.cs
index 9e0a0b1..1d7340a 100644
--- a/Backend/ERPCore/Infra/Auth/CurrentUser.cs
+++ b/Backend/ERPCore/Infra/Auth/CurrentUser.cs
@@ -31,4 +31,8 @@ public sealed class CurrentUser : ICurrentUser
}
public int AuditUserId => int.TryParse(UserId, out var id) ? id : User.SystemUserId;
+
+ public string? RoleCode => _accessor.HttpContext?.User?.FindFirstValue(AuthHexClaims.RoleCode);
+
+ public bool IsAdmin => string.Equals(RoleCode?.Trim(), "Admin", StringComparison.OrdinalIgnoreCase);
}
diff --git a/Backend/ERPCore/Infra/Auth/ICurrentUser.cs b/Backend/ERPCore/Infra/Auth/ICurrentUser.cs
index 1c103a2..78da05e 100644
--- a/Backend/ERPCore/Infra/Auth/ICurrentUser.cs
+++ b/Backend/ERPCore/Infra/Auth/ICurrentUser.cs
@@ -19,4 +19,14 @@ public interface ICurrentUser
/// True when the request carries an authenticated principal.
bool IsAuthenticated { get; }
+
+ /// The AuthHex RoleCode claim verbatim, or null if absent/unauthenticated.
+ string? RoleCode { get; }
+
+ ///
+ /// True when is "Admin" (case-insensitive — AuthHex mints the claim
+ /// independently of ERPCore's stored casing). Mirrors RoleService.GetMeAsync's check;
+ /// the only role that bypasses branch-scoping (see ).
+ ///
+ bool IsAdmin { get; }
}
diff --git a/Backend/ERPCore/Infra/Auth/UserAgentLabeler.cs b/Backend/ERPCore/Infra/Auth/UserAgentLabeler.cs
new file mode 100644
index 0000000..78817b7
--- /dev/null
+++ b/Backend/ERPCore/Infra/Auth/UserAgentLabeler.cs
@@ -0,0 +1,39 @@
+namespace ERPCore.Infra.Auth;
+
+///
+/// Best-effort "Browser on OS" label from a raw User-Agent string, for display in the
+/// login-device history — not a full parsing library, just enough to be readable
+/// (docs call this out: exact device model detection is out of scope).
+///
+public static class UserAgentLabeler
+{
+ public static string Label(string? userAgent)
+ {
+ if (string.IsNullOrWhiteSpace(userAgent)) return "Unknown device";
+
+ var os = DetectOs(userAgent);
+ var browser = DetectBrowser(userAgent);
+ return $"{browser} on {os}";
+ }
+
+ private static string DetectOs(string ua)
+ {
+ if (ua.Contains("Windows", StringComparison.OrdinalIgnoreCase)) return "Windows";
+ if (ua.Contains("Mac OS X", StringComparison.OrdinalIgnoreCase) || ua.Contains("Macintosh", StringComparison.OrdinalIgnoreCase)) return "macOS";
+ if (ua.Contains("Android", StringComparison.OrdinalIgnoreCase)) return "Android";
+ if (ua.Contains("iPhone", StringComparison.OrdinalIgnoreCase) || ua.Contains("iPad", StringComparison.OrdinalIgnoreCase)) return "iOS";
+ if (ua.Contains("Linux", StringComparison.OrdinalIgnoreCase)) return "Linux";
+ return "an unknown OS";
+ }
+
+ private static string DetectBrowser(string ua)
+ {
+ // Order matters: Edge/OPR/Chrome all also contain "Safari"/"Chrome" tokens.
+ if (ua.Contains("Edg/", StringComparison.OrdinalIgnoreCase)) return "Edge";
+ if (ua.Contains("OPR/", StringComparison.OrdinalIgnoreCase) || ua.Contains("Opera", StringComparison.OrdinalIgnoreCase)) return "Opera";
+ if (ua.Contains("Firefox", StringComparison.OrdinalIgnoreCase)) return "Firefox";
+ if (ua.Contains("CriOS", StringComparison.OrdinalIgnoreCase) || ua.Contains("Chrome", StringComparison.OrdinalIgnoreCase)) return "Chrome";
+ if (ua.Contains("Safari", StringComparison.OrdinalIgnoreCase)) return "Safari";
+ return "an unknown browser";
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Auditing/AuditScribe.cs b/Backend/ERPCore/Infra/Persistence/Auditing/AuditScribe.cs
index 8f231b0..2df34ab 100644
--- a/Backend/ERPCore/Infra/Persistence/Auditing/AuditScribe.cs
+++ b/Backend/ERPCore/Infra/Persistence/Auditing/AuditScribe.cs
@@ -21,7 +21,7 @@ public static class AuditScribe
private static readonly HashSet Excluded =
[
typeof(AuditLog), typeof(JournalEntryStub), typeof(NumberSequence),
- typeof(StockLedger), typeof(StockLayer),
+ typeof(StockLedger), typeof(StockLayer), typeof(LoginActivity),
];
private static readonly JsonSerializerOptions Json = new()
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/BranchConfigConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/BranchConfigConfiguration.cs
new file mode 100644
index 0000000..e60df23
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/BranchConfigConfiguration.cs
@@ -0,0 +1,33 @@
+using ERPCore.Domain.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace ERPCore.Infra.Persistence.Configurations;
+
+///
+/// Configures the singleton branch-configuration row. The check constraint is what makes
+/// "singleton" a database guarantee rather than a convention (mirrors ProductConfig).
+///
+public sealed class BranchConfigConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("branch_config", t =>
+ t.HasCheckConstraint("ck_branch_config_singleton", $"\"ConfigId\" = {BranchConfig.SingletonId}"));
+
+ builder.HasKey(c => c.ConfigId);
+
+ // The id is fixed, never generated — there is exactly one row, seeded by DataSeeder.
+ builder.Property(c => c.ConfigId).ValueGeneratedNever();
+
+ builder.Property(c => c.BranchingEnabled).IsRequired().HasDefaultValue(true);
+
+ // PostgreSQL xmin system column as the optimistic concurrency token (ETag).
+ builder.Property(c => c.RowVersion).IsRowVersion();
+
+ builder.HasOne(c => c.UpdatedByUser)
+ .WithMany()
+ .HasForeignKey(c => c.UpdatedBy)
+ .OnDelete(DeleteBehavior.Restrict);
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/BranchConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/BranchConfiguration.cs
index 660539e..daaad30 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/BranchConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/BranchConfiguration.cs
@@ -16,6 +16,7 @@ public sealed class BranchConfiguration : IEntityTypeConfiguration
builder.HasIndex(b => b.Code).IsUnique();
builder.Property(b => b.Name).IsRequired().HasMaxLength(200);
builder.Property(b => b.Address).HasMaxLength(500);
+ builder.Property(b => b.PhoneNumber).HasMaxLength(30);
builder.Property(b => b.Status)
.HasConversion().HasMaxLength(20).IsRequired()
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs
index b003f20..b1c7a27 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs
@@ -16,15 +16,19 @@ public sealed class GrnConfiguration : IEntityTypeConfiguration
builder.Property(g => g.Status).HasConversion().HasMaxLength(20).IsRequired();
builder.Property(g => g.CreatedAt).IsRequired();
+ builder.Property(g => g.GlJournalNo).HasMaxLength(30);
builder.Property(g => g.RowVersion).IsRowVersion();
builder.HasOne(g => g.PurchaseOrder).WithMany().HasForeignKey(g => g.PoId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(g => g.Vendor).WithMany().HasForeignKey(g => g.VendorId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(g => g.Warehouse).WithMany().HasForeignKey(g => g.WarehouseId).OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne(g => g.Branch).WithMany().HasForeignKey(g => g.BranchId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(g => g.Creator).WithMany().HasForeignKey(g => g.CreatedBy).OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne(g => g.PurchaseInvoice).WithMany().HasForeignKey(g => g.PurchaseInvoiceId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(g => g.Status);
builder.HasIndex(g => g.PoId);
+ builder.HasIndex(g => g.BranchId);
}
}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/ItemConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/ItemConfiguration.cs
index b14cb0e..79d409e 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/ItemConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/ItemConfiguration.cs
@@ -20,7 +20,9 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration-
builder.Property(i => i.TaxClass).HasMaxLength(20);
// Sales-only fixed selling price; nullable (null ⇒ sell at stock/FIFO value).
- builder.Property(i => i.SalePrice).HasPrecision(18, 4);
+ builder.Property(i => i.SellingMinPrice).HasPrecision(18, 4);
+ builder.Property(i => i.SellingMaxPrice).HasPrecision(18, 4);
+ builder.Property(i => i.SellingFlatPrice).HasPrecision(18, 4);
// Optional content size (how much one stocked pack holds). All four are nullable
// together: null ⇒ the item has no measurable content. The base pair is derived
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/LoginActivityConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/LoginActivityConfiguration.cs
new file mode 100644
index 0000000..5cb0d37
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/LoginActivityConfiguration.cs
@@ -0,0 +1,23 @@
+using ERPCore.Domain.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace ERPCore.Infra.Persistence.Configurations;
+
+public sealed class LoginActivityConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("login_activities");
+ builder.HasKey(l => l.LoginActivityId);
+
+ builder.Property(l => l.IpAddress).HasMaxLength(64);
+ builder.Property(l => l.UserAgent).HasMaxLength(500);
+ builder.Property(l => l.DeviceLabel).HasMaxLength(200);
+ builder.Property(l => l.LoginAt).IsRequired();
+
+ builder.HasOne(l => l.User).WithMany().HasForeignKey(l => l.UserId).OnDelete(DeleteBehavior.Cascade);
+
+ builder.HasIndex(l => new { l.UserId, l.LoginAt });
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/NavItemConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/NavItemConfiguration.cs
index afc2771..b5ab141 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/NavItemConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/NavItemConfiguration.cs
@@ -29,7 +29,8 @@ public sealed class NavItemConfiguration : IEntityTypeConfiguration
builder.HasData(
new NavItem { NavItemId = 1, Code = "dashboard", Label = "Dashboard", Href = "/dashboard", SortOrder = 1 },
new NavItem { NavItemId = 2, Code = "products", Label = "Products", Href = "/dashboard/products", SortOrder = 2 },
- new NavItem { NavItemId = 3, Code = "vendors", Label = "Vendors", Href = "/dashboard/vendors", SortOrder = 3 },
+ // NavItemId 3 ("vendors") retired 2026-09-09 — Vendors moved under Procurement as a
+ // sub-item (see SubNavItemConfiguration/PermissionConfiguration), matching the sidebar.
new NavItem { NavItemId = 4, Code = "procurement", Label = "Procurement", Href = "/dashboard/procurement", SortOrder = 4 },
new NavItem { NavItemId = 5, Code = "receiving", Label = "Receiving", Href = "/dashboard/receiving/grn", SortOrder = 5 },
new NavItem { NavItemId = 6, Code = "stock", Label = "Stock", Href = "/dashboard/stock", SortOrder = 6 },
@@ -39,7 +40,8 @@ public sealed class NavItemConfiguration : IEntityTypeConfiguration
new NavItem { NavItemId = 10, Code = "help", Label = "Help", Href = "/dashboard/help", SortOrder = 10 },
new NavItem { NavItemId = 11, Code = "ledgers", Label = "Ledgers", Href = "/dashboard/ledgers", SortOrder = 11 },
new NavItem { NavItemId = 12, Code = "accounts", Label = "Accounts", Href = "/dashboard/accounts", SortOrder = 12 },
- new NavItem { NavItemId = 13, Code = "sales", Label = "Sales", Href = "/dashboard/sales", SortOrder = 13 }
+ new NavItem { NavItemId = 13, Code = "sales", Label = "Sales", Href = "/dashboard/sales", SortOrder = 13 },
+ new NavItem { NavItemId = 14, Code = "production", Label = "Production", Href = "/dashboard/production", SortOrder = 14 }
);
}
}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/NotificationConfigConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/NotificationConfigConfiguration.cs
new file mode 100644
index 0000000..710b1c5
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/NotificationConfigConfiguration.cs
@@ -0,0 +1,29 @@
+using ERPCore.Domain.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace ERPCore.Infra.Persistence.Configurations;
+
+public sealed class NotificationConfigConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("notification_config", t =>
+ t.HasCheckConstraint("ck_notification_config_singleton", $"\"NotificationConfigId\" = {NotificationConfig.SingletonId}"));
+
+ builder.HasKey(c => c.NotificationConfigId);
+ builder.Property(c => c.NotificationConfigId).ValueGeneratedNever();
+
+ builder.Property(c => c.RowVersion).IsRowVersion();
+
+ builder.HasMany(c => c.Modules)
+ .WithOne(m => m.NotificationConfig)
+ .HasForeignKey(m => m.NotificationConfigId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ builder.HasOne(c => c.UpdatedByUser)
+ .WithMany()
+ .HasForeignKey(c => c.UpdatedBy)
+ .OnDelete(DeleteBehavior.Restrict);
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/NotificationConfigModuleConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/NotificationConfigModuleConfiguration.cs
new file mode 100644
index 0000000..2c598bf
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/NotificationConfigModuleConfiguration.cs
@@ -0,0 +1,21 @@
+using ERPCore.Domain.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace ERPCore.Infra.Persistence.Configurations;
+
+public sealed class NotificationConfigModuleConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("notification_config_modules");
+ builder.HasKey(m => m.NotificationConfigModuleId);
+
+ builder.Property(m => m.ModuleKey).IsRequired().HasMaxLength(100);
+ builder.HasIndex(m => m.ModuleKey).IsUnique();
+
+ builder.Property(m => m.EmailEnabled).IsRequired().HasDefaultValue(false);
+ builder.Property(m => m.SmsEnabled).IsRequired().HasDefaultValue(false);
+ builder.Property(m => m.InAppEnabled).IsRequired().HasDefaultValue(false);
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/PermissionConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/PermissionConfiguration.cs
index 47bec28..2b1bd42 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/PermissionConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/PermissionConfiguration.cs
@@ -26,7 +26,9 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration c.SubcategoriesEnabled).IsRequired().HasDefaultValue(true);
builder.Property(c => c.BrandsEnabled).IsRequired().HasDefaultValue(true);
builder.Property(c => c.ItemTypesEnabled).IsRequired().HasDefaultValue(true);
+ builder.Property(c => c.SellingPricesEnabled).IsRequired().HasDefaultValue(true);
+ builder.Property(c => c.SerialNumbersEnabled).IsRequired().HasDefaultValue(true);
+ builder.Property(c => c.BatchListsEnabled).IsRequired().HasDefaultValue(true);
+ builder.Property(c => c.WarrantyPeriodsEnabled).IsRequired().HasDefaultValue(true);
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(c => c.RowVersion).IsRowVersion();
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/PurchaseInvoiceConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/PurchaseInvoiceConfiguration.cs
new file mode 100644
index 0000000..c0b6906
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/PurchaseInvoiceConfiguration.cs
@@ -0,0 +1,97 @@
+using ERPCore.Domain.Entities;
+using ERPCore.Domain.Enums;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace ERPCore.Infra.Persistence.Configurations;
+
+public sealed class PurchaseInvoiceConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("purchase_invoices");
+ builder.HasKey(x => x.PurchaseInvoiceId);
+
+ builder.Property(x => x.DocNo).IsRequired().HasMaxLength(30);
+ builder.HasIndex(x => x.DocNo).IsUnique();
+
+ builder.Property(x => x.VendorInvoiceNo).IsRequired().HasMaxLength(50);
+ builder.Property(x => x.VendorInvoiceDate).IsRequired();
+ // A vendor invoice number is only meaningful per-vendor; the service also excludes
+ // Cancelled invoices from the duplicate check, so this composite index is a coarse
+ // backstop (it does not distinguish Cancelled rows) rather than the sole enforcement.
+ builder.HasIndex(x => new { x.VendorId, x.VendorInvoiceNo });
+
+ builder.HasOne(x => x.Vendor)
+ .WithMany()
+ .HasForeignKey(x => x.VendorId)
+ .OnDelete(DeleteBehavior.Restrict);
+
+ builder.Property(x => x.VendorSnapshotName).IsRequired().HasMaxLength(200);
+ builder.Property(x => x.VendorSnapshotTaxNo).HasMaxLength(50);
+
+ builder.Property(x => x.Status)
+ .HasConversion().HasMaxLength(20).IsRequired()
+ .HasDefaultValue(PurchaseInvoiceStatus.Draft);
+
+ foreach (var p in new[] { nameof(PurchaseInvoice.Subtotal), nameof(PurchaseInvoice.VatTotal), nameof(PurchaseInvoice.GrandTotal), nameof(PurchaseInvoice.PaidAmount), nameof(PurchaseInvoice.BalanceAmount) })
+ builder.Property(p).HasPrecision(18, 4);
+ builder.Property(x => x.GlJournalNo).HasMaxLength(30);
+
+ builder.HasOne(x => x.Creator).WithMany().HasForeignKey(x => x.CreatedBy).OnDelete(DeleteBehavior.Restrict);
+ builder.Property(x => x.CreatedAt).IsRequired();
+ builder.Property(x => x.RowVersion).IsRowVersion();
+
+ builder.HasIndex(x => x.Status);
+ builder.HasIndex(x => x.VendorId);
+
+ builder.HasMany(x => x.Lines)
+ .WithOne(x => x.PurchaseInvoice)
+ .HasForeignKey(x => x.PurchaseInvoiceId)
+ .OnDelete(DeleteBehavior.Cascade);
+ }
+}
+
+public sealed class PurchaseInvoiceLineConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("purchase_invoice_lines");
+ builder.HasKey(x => x.PurchaseInvoiceLineId);
+
+ builder.Property(x => x.Subtotal).HasPrecision(18, 4);
+ builder.Property(x => x.VatTotal).HasPrecision(18, 4);
+ builder.Property(x => x.LineTotal).HasPrecision(18, 4);
+
+ builder.HasOne(x => x.Grn)
+ .WithMany()
+ .HasForeignKey(x => x.GrnId)
+ .OnDelete(DeleteBehavior.Restrict);
+
+ // A GRN can be consolidated onto at most one active line at a time — cancelling the
+ // invoice removes its lines and frees the GRN (PurchaseInvoiceService.CancelAsync), so
+ // this stays enforceable rather than blocking a later re-invoice of the same GRN.
+ builder.HasIndex(x => x.GrnId).IsUnique();
+ }
+}
+
+public sealed class PurchaseInvoicePaymentConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("purchase_invoice_payments");
+ builder.HasKey(p => p.PurchaseInvoicePaymentId);
+
+ builder.Property(p => p.Amount).HasPrecision(18, 4);
+ builder.Property(p => p.PaymentDate).IsRequired();
+ builder.Property(p => p.BankAccountName).IsRequired().HasMaxLength(200);
+ builder.Property(p => p.Reference).HasMaxLength(100);
+ builder.Property(p => p.GlJournalNo).HasMaxLength(30);
+ builder.Property(p => p.CreatedAt).IsRequired();
+
+ builder.HasOne(p => p.PurchaseInvoice).WithMany(x => x.Payments).HasForeignKey(p => p.PurchaseInvoiceId).OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne(p => p.Creator).WithMany().HasForeignKey(p => p.CreatedBy).OnDelete(DeleteBehavior.Restrict);
+
+ builder.HasIndex(p => p.PurchaseInvoiceId);
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/PurchaseReturnConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/PurchaseReturnConfiguration.cs
index 00add0a..15d7135 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/PurchaseReturnConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/PurchaseReturnConfiguration.cs
@@ -16,6 +16,7 @@ public sealed class PurchaseReturnConfiguration : IEntityTypeConfiguration r.Status).HasConversion().HasMaxLength(20).IsRequired();
builder.Property(r => r.CreatedAt).IsRequired();
+ builder.Property(r => r.GlJournalNo).HasMaxLength(30);
builder.HasOne(r => r.Vendor).WithMany().HasForeignKey(r => r.VendorId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(r => r.Warehouse).WithMany().HasForeignKey(r => r.WarehouseId).OnDelete(DeleteBehavior.Restrict);
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/SalesDayEndConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/SalesDayEndConfiguration.cs
new file mode 100644
index 0000000..89211e4
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/SalesDayEndConfiguration.cs
@@ -0,0 +1,44 @@
+using ERPCore.Domain.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace ERPCore.Infra.Persistence.Configurations;
+
+public sealed class SalesDayEndConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("sales_day_ends");
+ builder.HasKey(x => x.SalesDayEndId);
+
+ builder.Property(x => x.DocNo).IsRequired().HasMaxLength(30);
+ builder.HasIndex(x => x.DocNo).IsUnique();
+
+ builder.Property(x => x.BusinessDate).IsRequired();
+
+ foreach (var p in new[] { nameof(SalesDayEnd.Subtotal), nameof(SalesDayEnd.DiscountTotal), nameof(SalesDayEnd.TaxTotal), nameof(SalesDayEnd.GrandTotal), nameof(SalesDayEnd.CostOfGoodsSold) })
+ builder.Property(p).HasPrecision(18, 4);
+ builder.Property(x => x.BundleCount).HasDefaultValue(0);
+
+ builder.Property(x => x.GlJournalNo).HasMaxLength(30);
+ builder.Property(x => x.ClosedAt).IsRequired();
+ builder.Property(x => x.RowVersion).IsRowVersion();
+
+ builder.HasOne(x => x.CashierUser).WithMany().HasForeignKey(x => x.CashierUserId).OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne(x => x.ClosedByUser).WithMany().HasForeignKey(x => x.ClosedBy).OnDelete(DeleteBehavior.Restrict);
+
+ // A cashier can only close a given business date once (CloseAsync replays this
+ // row idempotently on a second call instead of erroring — see SalesDayEndService).
+ builder.HasIndex(x => new { x.CashierUserId, x.BusinessDate }).IsUnique();
+
+ builder.HasMany(x => x.Slips)
+ .WithOne(x => x.DayEnd)
+ .HasForeignKey(x => x.DayEndId)
+ .OnDelete(DeleteBehavior.Restrict);
+
+ builder.HasMany(x => x.BundleSales)
+ .WithOne(x => x.DayEnd)
+ .HasForeignKey(x => x.DayEndId)
+ .OnDelete(DeleteBehavior.Restrict);
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/SalesInvoiceConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/SalesInvoiceConfiguration.cs
index 1d723a4..7f20c4b 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/SalesInvoiceConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/SalesInvoiceConfiguration.cs
@@ -30,6 +30,11 @@ public sealed class SalesInvoiceConfiguration : IEntityTypeConfiguration x.WarehouseId)
.OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne(x => x.Branch)
+ .WithMany()
+ .HasForeignKey(x => x.BranchId)
+ .OnDelete(DeleteBehavior.Restrict);
+
builder.Property(x => x.InvoiceType)
.HasConversion().HasMaxLength(20).IsRequired()
.HasDefaultValue(SalesInvoiceType.B2C);
@@ -40,12 +45,14 @@ public sealed class SalesInvoiceConfiguration : IEntityTypeConfiguration(p).HasPrecision(18, 4);
+ builder.Property(x => x.GlJournalNo).HasMaxLength(30);
builder.Property(x => x.CreatedAt).IsRequired();
builder.Property(x => x.RowVersion).IsRowVersion();
builder.HasIndex(x => x.Status);
builder.HasIndex(x => x.InvoiceDate);
+ builder.HasIndex(x => x.BranchId);
builder.HasMany(x => x.Lines)
.WithOne(x => x.SalesInvoice)
@@ -54,6 +61,27 @@ public sealed class SalesInvoiceConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("sales_invoice_payments");
+ builder.HasKey(p => p.SalesInvoicePaymentId);
+
+ builder.Property(p => p.Amount).HasPrecision(18, 4);
+ builder.Property(p => p.PaymentDate).IsRequired();
+ builder.Property(p => p.BankAccountName).IsRequired().HasMaxLength(200);
+ builder.Property(p => p.Reference).HasMaxLength(100);
+ builder.Property(p => p.GlJournalNo).HasMaxLength(30);
+ builder.Property(p => p.CreatedAt).IsRequired();
+
+ builder.HasOne(p => p.SalesInvoice).WithMany(x => x.Payments).HasForeignKey(p => p.SalesInvoiceId).OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne(p => p.Creator).WithMany().HasForeignKey(p => p.CreatedBy).OnDelete(DeleteBehavior.Restrict);
+
+ builder.HasIndex(p => p.SalesInvoiceId);
+ }
+}
+
public sealed class SalesInvoiceLineConfiguration : IEntityTypeConfiguration
{
public void Configure(EntityTypeBuilder builder)
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/SalesReturnConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/SalesReturnConfiguration.cs
index d835cbb..f6d0c34 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/SalesReturnConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/SalesReturnConfiguration.cs
@@ -16,6 +16,7 @@ public sealed class SalesReturnConfiguration : IEntityTypeConfiguration r.Status).HasConversion().HasMaxLength(20).IsRequired();
builder.Property(r => r.CreatedAt).IsRequired();
+ builder.Property(r => r.GlJournalNo).HasMaxLength(30);
builder.HasOne(r => r.Customer).WithMany().HasForeignKey(r => r.CustomerId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(r => r.Warehouse).WithMany().HasForeignKey(r => r.WarehouseId).OnDelete(DeleteBehavior.Restrict);
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/StockAdjustmentConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/StockAdjustmentConfiguration.cs
index d50a0e6..1a05216 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/StockAdjustmentConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/StockAdjustmentConfiguration.cs
@@ -16,11 +16,15 @@ public sealed class StockAdjustmentConfiguration : IEntityTypeConfiguration a.Status).HasConversion().HasMaxLength(20).IsRequired();
builder.Property(a => a.CreatedAt).IsRequired();
+ builder.Property(a => a.GlJournalNo).HasMaxLength(30);
builder.Property(a => a.RowVersion).IsRowVersion();
builder.HasOne(a => a.Warehouse).WithMany().HasForeignKey(a => a.WarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(a => a.ReasonCode).WithMany().HasForeignKey(a => a.ReasonCodeId).OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne(a => a.Branch).WithMany().HasForeignKey(a => a.BranchId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(a => a.Creator).WithMany().HasForeignKey(a => a.CreatedBy).OnDelete(DeleteBehavior.Restrict);
+
+ builder.HasIndex(a => a.BranchId);
}
}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/StockCountConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/StockCountConfiguration.cs
index fa44f42..1aa2979 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/StockCountConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/StockCountConfiguration.cs
@@ -20,9 +20,11 @@ public sealed class StockCountConfiguration : IEntityTypeConfiguration c.RowVersion).IsRowVersion();
builder.HasOne(c => c.Warehouse).WithMany().HasForeignKey(c => c.WarehouseId).OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne(c => c.Branch).WithMany().HasForeignKey(c => c.BranchId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(c => c.Creator).WithMany().HasForeignKey(c => c.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(c => c.Status);
+ builder.HasIndex(c => c.BranchId);
}
}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/StockTransferConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/StockTransferConfiguration.cs
index 2aae8bb..24f5440 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/StockTransferConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/StockTransferConfiguration.cs
@@ -20,9 +20,11 @@ public sealed class StockTransferConfiguration : IEntityTypeConfiguration t.SrcWarehouse).WithMany().HasForeignKey(t => t.SrcWarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(t => t.DestWarehouse).WithMany().HasForeignKey(t => t.DestWarehouseId).OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne(t => t.Branch).WithMany().HasForeignKey(t => t.BranchId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(t => t.Creator).WithMany().HasForeignKey(t => t.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(t => t.Status);
+ builder.HasIndex(t => t.BranchId);
}
}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/SubNavItemConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/SubNavItemConfiguration.cs
index 4282211..8eca2f4 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/SubNavItemConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/SubNavItemConfiguration.cs
@@ -31,16 +31,28 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration
builder.HasIndex(w => w.Code).IsUnique();
builder.Property(w => w.Name).IsRequired().HasMaxLength(200);
+
+ builder.HasOne(w => w.Branch).WithMany()
+ .HasForeignKey(w => w.BranchId).OnDelete(DeleteBehavior.Restrict);
+ builder.HasIndex(w => w.BranchId);
}
}
diff --git a/Backend/ERPCore/Infra/Persistence/DataSeeder.cs b/Backend/ERPCore/Infra/Persistence/DataSeeder.cs
index ff802f6..eea8688 100644
--- a/Backend/ERPCore/Infra/Persistence/DataSeeder.cs
+++ b/Backend/ERPCore/Infra/Persistence/DataSeeder.cs
@@ -43,8 +43,10 @@ public static class DataSeeder
{
var dirty = await SeedReasonCodesAsync(db, ct);
dirty |= await SeedItemTypesAsync(db, ct);
- // dirty |= await SeedCompanyProfileAsync(db, ct);
+ // dirty |= await SeedCompanyProfileAsync(db, ct);
dirty |= await SeedProductConfigAsync(db, ct);
+ dirty |= await SeedBranchConfigAsync(db, ct);
+ dirty |= await SeedNotificationConfigAsync(db, ct);
dirty |= await SeedSalesMastersAsync(db, ct);
dirty |= await SeedSalesStockAsync(db, ct);
dirty |= await SeedSalesAsync(db, ct);
@@ -100,7 +102,39 @@ public static class DataSeeder
ConfigId = ProductConfig.SingletonId,
SubcategoriesEnabled = true,
BrandsEnabled = true,
- ItemTypesEnabled = true
+ ItemTypesEnabled = true,
+ SellingPricesEnabled = true,
+ SerialNumbersEnabled = true,
+ BatchListsEnabled = true,
+ WarrantyPeriodsEnabled = true
+ });
+ return true;
+ }
+
+ ///
+ /// Ensures the singleton branch-config row exists. New deployments start with branching
+ /// on — today's mandatory-branch behaviour — so nothing changes for anyone until an
+ /// admin deliberately switches it off from Settings.
+ ///
+ private static async Task SeedBranchConfigAsync(ErpDbContext db, CancellationToken ct)
+ {
+ if (await db.BranchConfig.AnyAsync(c => c.ConfigId == BranchConfig.SingletonId, ct)) return false;
+
+ db.BranchConfig.Add(new BranchConfig
+ {
+ ConfigId = BranchConfig.SingletonId,
+ BranchingEnabled = true
+ });
+ return true;
+ }
+
+ private static async Task SeedNotificationConfigAsync(ErpDbContext db, CancellationToken ct)
+ {
+ if (await db.NotificationConfig.AnyAsync(c => c.NotificationConfigId == NotificationConfig.SingletonId, ct)) return false;
+
+ db.NotificationConfig.Add(new NotificationConfig
+ {
+ NotificationConfigId = NotificationConfig.SingletonId
});
return true;
}
@@ -182,7 +216,7 @@ public static class DataSeeder
WarehouseId = warehouse.WarehouseId,
QtyReceived = 100m,
QtyRemaining = 100m,
- UnitCost = item.SalePrice.GetValueOrDefault() > 0m ? item.SalePrice.GetValueOrDefault() / 2m : 25m,
+ UnitCost = item.SellingFlatPrice.GetValueOrDefault() > 0m ? item.SellingFlatPrice.GetValueOrDefault() / 2m : 25m,
ReceiptDate = DateTime.UtcNow.AddDays(-7)
});
db.StockLayers.Add(new StockLayer
@@ -191,7 +225,7 @@ public static class DataSeeder
WarehouseId = warehouse.WarehouseId,
QtyReceived = 5m,
QtyRemaining = 5m,
- UnitCost = secondItem.SalePrice.GetValueOrDefault() > 0m ? secondItem.SalePrice.GetValueOrDefault() / 2m : 15m,
+ UnitCost = secondItem.SellingFlatPrice.GetValueOrDefault() > 0m ? secondItem.SellingFlatPrice.GetValueOrDefault() / 2m : 15m,
ReceiptDate = DateTime.UtcNow.AddDays(-6)
});
return true;
@@ -278,7 +312,7 @@ public static class DataSeeder
BaseUomId = uom.UomId,
StockNature = StockNature.Stocked,
TrackingMode = TrackingMode.None,
- SalePrice = 100m,
+ SellingFlatPrice = 100m,
Status = EntityStatus.Active,
CreatedAt = now
},
@@ -292,7 +326,7 @@ public static class DataSeeder
BaseUomId = uom.UomId,
StockNature = StockNature.Stocked,
TrackingMode = TrackingMode.None,
- SalePrice = 50m,
+ SellingFlatPrice = 50m,
ContentQty = 500m,
ContentUnit = MeasureUnit.Ml,
ContentBaseQty = 500m,
@@ -445,7 +479,7 @@ public static class DataSeeder
ItemId = items[0].ItemId,
WarehouseId = warehouse.WarehouseId,
Qty = 1m,
- UnitPrice = items[0].SalePrice.GetValueOrDefault(),
+ UnitPrice = items[0].SellingFlatPrice.GetValueOrDefault(),
IncludeInBundle = true,
SortOrder = 1
},
@@ -454,7 +488,7 @@ public static class DataSeeder
ItemId = items[1].ItemId,
WarehouseId = warehouse.WarehouseId,
Qty = 1m,
- UnitPrice = items[1].SalePrice.GetValueOrDefault(),
+ UnitPrice = items[1].SellingFlatPrice.GetValueOrDefault(),
IncludeInBundle = true,
SortOrder = 2
}
@@ -478,10 +512,10 @@ public static class DataSeeder
BundleName = "Demo Bundle Draft",
BundleCode = "BND-DEMO-001",
Status = BundleSaleStatus.Draft,
- ComponentSubtotal = items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault(),
+ ComponentSubtotal = items[0].SellingFlatPrice.GetValueOrDefault() + items[1].SellingFlatPrice.GetValueOrDefault(),
BundlePrice = 0m,
- MarginAmount = -(items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault()),
- DiscountTotal = items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault(),
+ MarginAmount = -(items[0].SellingFlatPrice.GetValueOrDefault() + items[1].SellingFlatPrice.GetValueOrDefault()),
+ DiscountTotal = items[0].SellingFlatPrice.GetValueOrDefault() + items[1].SellingFlatPrice.GetValueOrDefault(),
TaxTotal = 0m,
GrandTotal = 0m,
CreatedAt = now.AddDays(-2),
@@ -493,8 +527,8 @@ public static class DataSeeder
Description = items[0].Name,
Qty = 1m,
WarehouseId = warehouse.WarehouseId,
- UnitPrice = items[0].SalePrice.GetValueOrDefault(),
- LineTotal = items[0].SalePrice.GetValueOrDefault(),
+ UnitPrice = items[0].SellingFlatPrice.GetValueOrDefault(),
+ LineTotal = items[0].SellingFlatPrice.GetValueOrDefault(),
IncludeInBundle = true,
IsComponent = true
},
@@ -504,8 +538,8 @@ public static class DataSeeder
Description = items[1].Name,
Qty = 1m,
WarehouseId = warehouse.WarehouseId,
- UnitPrice = items[1].SalePrice.GetValueOrDefault(),
- LineTotal = items[1].SalePrice.GetValueOrDefault(),
+ UnitPrice = items[1].SellingFlatPrice.GetValueOrDefault(),
+ LineTotal = items[1].SellingFlatPrice.GetValueOrDefault(),
IncludeInBundle = true,
IsComponent = true
}
@@ -523,12 +557,12 @@ public static class DataSeeder
BundleName = "Demo Bundle Posted",
BundleCode = "BND-DEMO-001",
Status = BundleSaleStatus.Posted,
- ComponentSubtotal = items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault(),
- BundlePrice = (items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault()) - 10m,
+ ComponentSubtotal = items[0].SellingFlatPrice.GetValueOrDefault() + items[1].SellingFlatPrice.GetValueOrDefault(),
+ BundlePrice = (items[0].SellingFlatPrice.GetValueOrDefault() + items[1].SellingFlatPrice.GetValueOrDefault()) - 10m,
MarginAmount = -10m,
DiscountTotal = 10m,
TaxTotal = 0m,
- GrandTotal = (items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault()) - 10m,
+ GrandTotal = (items[0].SellingFlatPrice.GetValueOrDefault() + items[1].SellingFlatPrice.GetValueOrDefault()) - 10m,
CreatedAt = now.AddDays(-1),
UpdatedAt = now.AddHours(-2),
Lines =
@@ -539,8 +573,8 @@ public static class DataSeeder
Description = items[0].Name,
Qty = 1m,
WarehouseId = warehouse.WarehouseId,
- UnitPrice = items[0].SalePrice.GetValueOrDefault(),
- LineTotal = items[0].SalePrice.GetValueOrDefault(),
+ UnitPrice = items[0].SellingFlatPrice.GetValueOrDefault(),
+ LineTotal = items[0].SellingFlatPrice.GetValueOrDefault(),
IncludeInBundle = true,
IsComponent = true
},
@@ -550,8 +584,8 @@ public static class DataSeeder
Description = items[1].Name,
Qty = 1m,
WarehouseId = warehouse.WarehouseId,
- UnitPrice = items[1].SalePrice.GetValueOrDefault(),
- LineTotal = items[1].SalePrice.GetValueOrDefault(),
+ UnitPrice = items[1].SellingFlatPrice.GetValueOrDefault(),
+ LineTotal = items[1].SellingFlatPrice.GetValueOrDefault(),
IncludeInBundle = true,
IsComponent = true
}
@@ -569,12 +603,12 @@ public static class DataSeeder
BundleName = "Demo Bundle Cancelled",
BundleCode = "BND-DEMO-001",
Status = BundleSaleStatus.Cancelled,
- ComponentSubtotal = items[0].SalePrice.GetValueOrDefault(),
- BundlePrice = items[0].SalePrice.GetValueOrDefault(),
+ ComponentSubtotal = items[0].SellingFlatPrice.GetValueOrDefault(),
+ BundlePrice = items[0].SellingFlatPrice.GetValueOrDefault(),
MarginAmount = 0m,
DiscountTotal = 0m,
TaxTotal = 0m,
- GrandTotal = items[0].SalePrice.GetValueOrDefault(),
+ GrandTotal = items[0].SellingFlatPrice.GetValueOrDefault(),
CreatedAt = now,
UpdatedAt = now,
Lines =
@@ -585,8 +619,8 @@ public static class DataSeeder
Description = items[0].Name,
Qty = 1m,
WarehouseId = secondaryWarehouse.WarehouseId,
- UnitPrice = items[0].SalePrice.GetValueOrDefault(),
- LineTotal = items[0].SalePrice.GetValueOrDefault(),
+ UnitPrice = items[0].SellingFlatPrice.GetValueOrDefault(),
+ LineTotal = items[0].SellingFlatPrice.GetValueOrDefault(),
IncludeInBundle = true,
IsComponent = true
}
diff --git a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs
index 47e402c..cf5832b 100644
--- a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs
+++ b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs
@@ -38,6 +38,11 @@ public class ErpDbContext : DbContext
/// Singleton row (FR-MD-11).
public DbSet ProductConfig => Set();
+ /// Singleton row gating whether this deployment uses branches at all.
+ public DbSet BranchConfig => Set();
+ /// Singleton row for notification delivery preferences.
+ public DbSet NotificationConfig => Set();
+ public DbSet NotificationConfigModules => Set();
// --- Cross-cutting (docs/10 Part C.7) ---
public DbSet Users => Set();
@@ -64,6 +69,11 @@ public class ErpDbContext : DbContext
public DbSet Grns => Set();
public DbSet GrnLines => Set();
+ // --- Purchase Invoices (vendor invoices consolidating Confirmed GRNs) ---
+ public DbSet PurchaseInvoices => Set();
+ public DbSet PurchaseInvoiceLines => Set();
+ public DbSet PurchaseInvoicePayments => Set();
+
// --- Batch / Serial (docs/10 Part C.4) ---
public DbSet Batches => Set();
public DbSet Serials => Set();
@@ -87,8 +97,10 @@ public class ErpDbContext : DbContext
// --- Sales (Phase 1) ---
public DbSet SalesInvoices => Set();
public DbSet SalesInvoiceLines => Set();
+ public DbSet SalesInvoicePayments => Set();
public DbSet SalesSlips => Set();
public DbSet SalesSlipLines => Set();
+ public DbSet SalesDayEnds => Set();
public DbSet BundleSaleTemplates => Set();
public DbSet BundleSaleTemplateLines => Set();
public DbSet BundleSales => Set();
@@ -102,6 +114,7 @@ public class ErpDbContext : DbContext
// --- Cross-cutting: audit trail + GL-ready journal (docs/10 Part C.7) ---
public DbSet AuditLogs => Set();
public DbSet JournalEntryStubs => Set();
+ public DbSet LoginActivities => Set();
// --- HRM: org masters (docs/12-BACKEND-HRM.md Part C.1) ---
public DbSet Branches => Set();
diff --git a/Backend/ERPCore/Migrations/20260812091117_AddGrnGlPostingAndPayments.Designer.cs b/Backend/ERPCore/Migrations/20260812091117_AddGrnGlPostingAndPayments.Designer.cs
new file mode 100644
index 0000000..07170f5
--- /dev/null
+++ b/Backend/ERPCore/Migrations/20260812091117_AddGrnGlPostingAndPayments.Designer.cs
@@ -0,0 +1,7188 @@
+//
+using System;
+using ERPCore.Infra.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace ERPCore.Migrations
+{
+ [DbContext(typeof(ErpDbContext))]
+ [Migration("20260812091117_AddGrnGlPostingAndPayments")]
+ partial class AddGrnGlPostingAndPayments
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.9")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.AttendanceRecord", b =>
+ {
+ b.Property("AttendanceRecordId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AttendanceRecordId"));
+
+ b.Property("AttendanceDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("AttendanceStatus")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("AttendanceUploadBatchId")
+ .HasColumnType("integer");
+
+ b.Property("CheckIn")
+ .HasColumnType("interval");
+
+ b.Property("CheckOut")
+ .HasColumnType("interval");
+
+ b.Property("DuplicateOfAttendanceRecordId")
+ .HasColumnType("integer");
+
+ b.Property("EarlyLeaveMinutes")
+ .HasColumnType("integer");
+
+ b.Property("EditedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EditedBy")
+ .HasColumnType("integer");
+
+ b.Property("EmployeeId")
+ .HasColumnType("integer");
+
+ b.Property("IsManualOverride")
+ .HasColumnType("boolean");
+
+ b.Property("LateMinutes")
+ .HasColumnType("integer");
+
+ b.Property("Notes")
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)");
+
+ b.Property("OvertimeMinutes")
+ .HasColumnType("integer");
+
+ b.Property("RowValidationStatus")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("WorkShiftId")
+ .HasColumnType("integer");
+
+ b.Property("WorkingMinutes")
+ .HasColumnType("integer");
+
+ b.HasKey("AttendanceRecordId");
+
+ b.HasIndex("AttendanceUploadBatchId");
+
+ b.HasIndex("WorkShiftId");
+
+ b.HasIndex("EmployeeId", "AttendanceDate");
+
+ b.ToTable("hr_attendance_records", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.AttendanceUploadBatch", b =>
+ {
+ b.Property("AttendanceUploadBatchId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AttendanceUploadBatchId"));
+
+ b.Property("ConfirmedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ConfirmedBy")
+ .HasColumnType("integer");
+
+ b.Property("DocNo")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("OriginalFileName")
+ .HasMaxLength(260)
+ .HasColumnType("character varying(260)");
+
+ b.Property("PeriodEnd")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property