Compare commits

...

63 Commits

Author SHA1 Message Date
ImanThiyanga 4efd5bda75 Merge branch 'Dev' into feat/mysql-migration 2026-10-05 12:10:58 +00:00
ImanThiyanga dbf9e841f8 update on production db 2026-10-05 17:40:04 +05:30
ImanThiyanga c1405da8d2 Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 11m11s
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 14m58s
2026-10-05 11:47:00 +00:00
ImanThiyanga cc83a3e59f Merge pull request 'Feat/mysql migration' (#99) from feat/mysql-migration into Dev
Reviewed-on: #99
2026-10-05 11:46:12 +00:00
ImanThiyanga 8706c2557b initiating db migration 2026-10-05 17:15:08 +05:30
ImanThiyanga 9503ea5fe8 fix after migrations 2026-10-05 17:14:42 +05:30
ImanThiyanga 65f58c4000 adding complete db migrations from posgres 2026-10-05 16:11:35 +05:30
ImanThiyanga 9d710349c2 Refactor code structure for improved readability and maintainability; removed redundant code blocks and optimized function calls. 2026-10-05 16:03:31 +05:30
kalana_Thilakarathna 535616c48f Merge pull request 'Dev' (#98) from Dev into production
Reviewed-on: #98
2026-10-05 10:07:55 +00:00
kalana_Thilakarathna c6c9c6a9dd Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 13m53s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 15m25s
2026-10-05 10:07:50 +00:00
kalana_Thilakarathna dedb42365e Merge pull request 'Day end sales fixers' (#97) from day-end-sales-fixers into Dev
Reviewed-on: #97
2026-10-05 10:07:34 +00:00
Sasanka f4ec3b4c65 fixers of day end sales and sales 2026-10-05 13:11:01 +05:30
ashan_rusiru 6c707d88b1 Merge pull request 'sales & grn fixers' (#94) from sales-&-GRN-fixers into production
Reviewed-on: #94
2026-10-04 11:42:29 +00:00
ashan_rusiru 759c512281 Merge branch 'production' into sales-&-GRN-fixers
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 10m50s
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 14m14s
2026-10-04 11:42:19 +00:00
Sasanka 3d3dbbbb59 sales & grn fixers 2026-10-04 15:31:59 +05:30
ashan_rusiru f4591a193a Merge pull request 'product and sales fixers' (#93) from product-and-sales-fixers into production
Reviewed-on: #93
2026-10-04 06:57:46 +00:00
ashan_rusiru 791d853c72 Merge branch 'production' into product-and-sales-fixers
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 13m51s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 15m33s
2026-10-04 06:57:30 +00:00
Sasanka cea433e320 product and sales fixers 2026-09-30 17:06:28 +05:30
ImanThiyanga cba2f91689 Merge pull request 'Dev' (#91) from Dev into production
Reviewed-on: #91
2026-09-25 09:04:19 +00:00
ImanThiyanga 9fd290e256 Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 12m38s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 14m1s
2026-09-25 09:03:39 +00:00
ImanThiyanga 20ecfdc0b2 Merge pull request 'fixing recipts in sales' (#90) from sales-changes into Dev
Reviewed-on: #90
2026-09-25 09:03:21 +00:00
ImanThiyanga dd82f5b5c4 Merge branch 'Dev' into sales-changes 2026-09-25 09:03:15 +00:00
ImanThiyanga ce55d647b5 Merge pull request 'fron build failure fix' (#89) from Dev into production
Reviewed-on: #89
2026-09-25 06:58:55 +00:00
ImanThiyanga 268b6562dc Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 12m50s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 14m24s
2026-09-25 06:58:48 +00:00
ImanThiyanga b389c01dfe fron build failure fix 2026-09-25 12:28:20 +05:30
Sasanka d24072e20a fixing recipts in sales 2026-09-24 17:02:33 +05:30
ashan_rusiru 9185d39c39 Merge pull request 'Dev' (#88) from Dev into production
Reviewed-on: #88
2026-09-24 07:12:59 +00:00
ashan_rusiru a2790c13bb Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 13m26s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 15m18s
2026-09-24 07:12:52 +00:00
ashan_rusiru 940583552a Merge pull request 'Ui fixers and responsivenes' (#87) from ui-fixers-and-responsivenes into Dev
Reviewed-on: #87
2026-09-24 07:09:52 +00:00
Sasanka 049c98298a Ui fixings 2026-09-24 12:30:21 +05:30
Sasanka f5e8e0e5b9 responsiveness and ui fixers 2026-09-23 16:55:46 +05:30
ashan_rusiru f35439e364 Merge pull request 'Dev' (#86) from Dev into production
Reviewed-on: #86
2026-09-21 19:03:14 +00:00
ashan_rusiru 4c5e9b347a Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 13m41s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 15m5s
2026-09-21 19:03:08 +00:00
Dhananjaya99 fdd104284e feat: add ItemOverviewDto for enriched item data in items list
feat: implement CustomerDetailPage for detailed customer view and editing

feat: create CustomersPage for listing and managing customers with filters

feat: add ColumnsMenu component for customizable table columns visibility

feat: implement CSV export functionality for data download

feat: add customer code generation logic for unique customer identifiers
2026-09-22 00:32:40 +05:30
ImanThiyanga 72819e5f7b production change 2026-09-21 18:02:55 +05:30
kalana_Thilakarathna edd1011330 Merge pull request 'chore: update next.js version to ^16.3.5 in package.json' (#85) from Dev into production
Reviewed-on: #85
2026-09-20 12:41:34 +00:00
Kalana B. Thilakarathna 3ae14fad72 chore: update next.js version to ^16.3.5 in package.json
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 11m45s
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 15m23s
2026-09-20 18:11:04 +05:30
kalana_Thilakarathna 84d3318f76 Merge pull request 'Dev' (#84) from Dev into production
Reviewed-on: #84
2026-09-20 12:27:45 +00:00
kalana_Thilakarathna bfb8e83a82 Update .gitea/workflows/ci-cd.yml
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 10m33s
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 14m9s
2026-09-20 12:27:31 +00:00
kalana_Thilakarathna 40c1d6da27 Update Frontend/erp-system/Dockerfile 2026-09-20 12:26:57 +00:00
kalana_Thilakarathna 5aa709db17 Merge pull request 'Update .gitea/workflows/ci-cd.yml' (#83) from Dev into production
Reviewed-on: #83
2026-09-20 12:03:21 +00:00
kalana_Thilakarathna cf964a19a9 Update .gitea/workflows/ci-cd.yml
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 13m37s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 14m59s
2026-09-20 12:03:07 +00:00
kalana_Thilakarathna a40c6bf598 Merge pull request 'Update .gitea/workflows/ci-cd.yml' (#82) from Dev into production
Reviewed-on: #82
2026-09-20 11:57:04 +00:00
kalana_Thilakarathna f951700b58 Update .gitea/workflows/ci-cd.yml
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 11m16s
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 14m55s
2026-09-20 11:56:50 +00:00
kalana_Thilakarathna b3939e0685 Merge pull request 'Update .gitea/workflows/ci-cd.yml' (#81) from Dev into production
Reviewed-on: #81
2026-09-20 11:50:14 +00:00
kalana_Thilakarathna af6f02023d Update .gitea/workflows/ci-cd.yml
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 11m43s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 13m5s
2026-09-20 11:49:55 +00:00
kalana_Thilakarathna 9d569a1504 Merge pull request 'Update .gitea/workflows/ci-cd.yml' (#80) from Dev into production
Reviewed-on: #80
2026-09-20 11:41:23 +00:00
kalana_Thilakarathna 5a44702cf3 Update .gitea/workflows/ci-cd.yml
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 10m33s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 11m56s
2026-09-20 11:41:10 +00:00
kalana_Thilakarathna 2a5dee3fef Merge pull request 'Update .gitea/workflows/ci-cd.yml' (#79) from Dev into production
Reviewed-on: #79
2026-09-20 11:34:43 +00:00
kalana_Thilakarathna 2584ab8715 Update .gitea/workflows/ci-cd.yml
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 12m8s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 13m36s
2026-09-20 11:34:29 +00:00
ImanThiyanga 6b9d191bf8 Merge pull request 'fixes on ci-cd' (#78) from Dev into production
Reviewed-on: #78
2026-09-18 19:32:33 +00:00
ImanThiyanga 283fa21e87 Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 10m46s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Failing after 14m22s
2026-09-18 19:32:25 +00:00
ImanThiyanga 97d19d83ab fixes on ci-cd 2026-09-19 01:01:30 +05:30
ImanThiyanga 01d5159d27 Merge pull request 'changed front needs' (#77) from Dev into production
Reviewed-on: #77
2026-09-18 19:21:39 +00:00
ImanThiyanga 80899ed88c Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Failing after 10m18s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 11m40s
2026-09-18 19:21:32 +00:00
ImanThiyanga cfe2466fec changed front needs 2026-09-19 00:51:11 +05:30
ImanThiyanga 9a124c8c63 Merge pull request 'fixed ci-cd' (#76) from Dev into production
Reviewed-on: #76
2026-09-18 19:05:55 +00:00
ImanThiyanga 42ab7e5964 Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 12m25s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Failing after 14m58s
2026-09-18 19:05:48 +00:00
ImanThiyanga e7735135ca fixed ci-cd 2026-09-19 00:35:05 +05:30
ImanThiyanga f61d1f95b8 Merge pull request 'Dev' (#75) from Dev into production
Reviewed-on: #75
2026-09-18 18:54:54 +00:00
ImanThiyanga f632c5fc99 Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 13m26s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Failing after 15m0s
2026-09-18 18:54:46 +00:00
ImanThiyanga 70179e1abc Merge pull request 'new updates' (#74) from new-updates into Dev
Reviewed-on: #74
2026-09-18 18:53:13 +00:00
ImanThiyanga 7274b89b28 Merge pull request 'migrations fix' (#72) from Dev into production
Reviewed-on: #72
2026-09-18 18:23:44 +00:00
398 changed files with 28896 additions and 291835 deletions
+39 -39
View File
@@ -1,4 +1,3 @@
# test
name: CI/CD - ERP Core Monorepo
on:
@@ -9,8 +8,8 @@ on:
- closed
jobs:
backend-build-and-deploy:
name: Build & Deploy ERP Core Backend
build-and-deploy-backend:
name: Build & Deploy Backend
runs-on: linux
if: github.event.pull_request.merged == true
steps:
@@ -21,69 +20,72 @@ jobs:
--branch production \
https://oauth2:${{ secrets.ERPCORE }}@gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git \
.
- name: Restore backend
working-directory: .gitea/workflows
run: dotnet restore ../../Backend/ERPCore.sln
- name: Publish backend
working-directory: .gitea/workflows
- name: Restore
run: dotnet restore Backend/ERPCore.sln
- name: Publish
run: |
rm -rf /tmp/erpcore-deploy
mkdir -p /tmp/erpcore-deploy
dotnet publish ../../Backend/ERPCore/ERPCore.csproj \
dotnet publish Backend/ERPCore/ERPCore.csproj \
-c Release \
-r linux-x64 \
--self-contained true \
-o /tmp/erpcore-deploy
- name: Stop backend service
- name: Stop service
run: systemctl stop erpcore.service
- name: Copy backend to deploy folder
- name: Copy to deploy folder
run: |
mkdir -p /home/erp/core/publish
rm -rf /home/erp/core/publish/*
cp -r /tmp/erpcore-deploy/* /home/erp/core/publish/
- name: Fix backend ownership and permissions
- name: Fix ownership and permissions
run: |
chown -R erpuser:erpuser /home/erp/core/publish
find /home/erp/core/publish -type d -exec chmod 755 {} +
find /home/erp/core/publish -type f -exec chmod 644 {} +
chmod +x /home/erp/core/publish/ERPCore
- name: Restart backend service
- name: Restart service
# ConnectionStrings__DefaultConnection is supplied by the systemd unit on the host
# (Environment= or EnvironmentFile=), deliberately not committed: appsettings.json ships
# the key empty and appsettings.Production.json carries no ConnectionStrings section at
# all. If the unit does not set it, startup throws before serving and the Verify step
# below fails the job.
run: systemctl restart erpcore.service
- name: Verify backend
- name: Verify
run: |
systemctl is-active erpcore.service && echo "Backend service running" || echo "Backend service failed"
# `is-active ... || echo` always exited 0, so a crashed service still reported success.
# Wait a few seconds first: a startup failure (bad or missing connection string) happens
# during MigrateAsync, after systemctl restart has already returned.
sleep 5
if ! systemctl is-active --quiet erpcore.service; then
echo "❌ erpcore.service is not running:"
journalctl -u erpcore.service -n 50 --no-pager
exit 1
fi
echo "✅ Service running"
frontend-build-and-deploy:
name: Build & Deploy ERP Core Frontend
build-and-deploy-frontend:
name: Build & Deploy Frontend
runs-on: linux
needs: backend-build-and-deploy
if: github.event.pull_request.merged == true
steps:
- name: Checkout code
run: |
git clone \
--depth 1 \
--branch Production \
--branch production \
https://oauth2:${{ secrets.ERPCORE }}@gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git \
.
- name: Build frontend Docker image
working-directory: .gitea/workflows
run: docker build -t erpcore-frontend:latest ../../Frontend/erp-system
- name: Stop & remove existing frontend container
- name: Build Docker image
run: |
docker build --progress=plain --no-cache \
-t erpcore-frontend:latest \
./Frontend/erp-system
- name: Stop & remove existing container
run: |
docker stop erpcore-frontend 2>/dev/null || true
docker rm erpcore-frontend 2>/dev/null || true
- name: Start frontend container
- name: Start container
run: |
docker run -d \
--name erpcore-frontend \
@@ -96,11 +98,9 @@ jobs:
--cap-drop ALL \
--security-opt no-new-privileges \
erpcore-frontend:latest
- name: Remove old frontend images
- name: Remove old images
run: docker image prune -f
- name: Verify frontend
- name: Verify
run: |
docker ps --filter name=erpcore-frontend --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" \
| grep erpcore-frontend && echo "Frontend container running" || echo "Frontend container failed"
| grep erpcore-frontend && echo "✅ Container running" || echo "❌ Container failed"
+1 -1
View File
@@ -8,7 +8,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.20" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
@@ -30,7 +30,7 @@ public class NotificationConfigServiceTests
var result = await service.GetAsync();
Assert.Equal(7u, result.RowVersion);
Assert.Equal(1u, result.RowVersion);
Assert.Contains(result.Value.Modules, m => m.ModuleKey == "sale.create" && m.EmailEnabled && !m.SmsEnabled && m.InAppEnabled);
Assert.Contains(result.Value.Modules, m => m.ModuleKey == "grn.confirmed" && !m.EmailEnabled && !m.SmsEnabled && !m.InAppEnabled);
}
@@ -55,9 +55,9 @@ public class NotificationConfigServiceTests
}).ToList()
};
var updated = await service.UpdateAsync(request, 7);
var updated = await service.UpdateAsync(request, 1);
Assert.Equal(7u, updated.RowVersion);
Assert.Equal(2u, updated.RowVersion);
Assert.Contains(updated.Value.Modules, m => m.ModuleKey == catalog.GetAll()[0].Key && m.EmailEnabled && !m.SmsEnabled && !m.InAppEnabled);
Assert.Contains(updated.Value.Modules, m => m.ModuleKey == catalog.GetAll()[1].Key && !m.EmailEnabled && m.SmsEnabled && m.InAppEnabled);
@@ -133,7 +133,7 @@ public class NotificationConfigServiceTests
}).ToList()
};
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(request, 7));
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(request, 1));
Assert.Equal(ErrorCodes.ConcurrencyConflict, ex.Code);
Assert.Equal(412, ex.StatusCode);
}
@@ -0,0 +1,124 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Production;
using ERPCore.Infra.Auth;
using ERPCore.Infra.Persistence;
using ERPCore.Repositories;
using ERPCore.Services.Production;
using ERPCore.System.Errors;
using ERPCore.Tests.TestSupport;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
namespace ERPCore.Tests;
/// <summary>
/// Covers the 412 translation on the two production services, which save through
/// <c>ExecuteInTransactionAsync</c> rather than a bare <c>SaveChangesAsync</c> and so guard
/// concurrency differently from the inline pattern the other services use.
/// </summary>
public class ProductionConcurrencyTests
{
private static readonly InMemoryDatabaseRoot Root = new();
[Fact]
public async Task TemplateUpdateAsync_MapsConcurrencyFailuresToDomainException()
{
await using var db = CreateContext();
// Validation runs before the transaction opens, so the referenced item has to exist.
db.Items.Add(new Item
{
ItemId = 1,
Sku = "FG-1",
Name = "Finished good",
CategoryId = 1,
BaseUomId = 1,
Status = EntityStatus.Active
});
await db.SaveChangesAsync();
var service = CreateTemplateService(db);
var ex = await Assert.ThrowsAsync<DomainException>(
() => service.UpdateAsync(1, SingleStageTemplate(), expectedRowVersion: 1));
Assert.Equal(ErrorCodes.ConcurrencyConflict, ex.Code);
Assert.Equal(412, ex.StatusCode);
}
[Fact]
public async Task RunUpdateStageQuantitiesAsync_MapsConcurrencyFailuresToDomainException()
{
await using var db = CreateContext();
var service = CreateRunService(db);
var ex = await Assert.ThrowsAsync<DomainException>(
() => service.UpdateStageQuantitiesAsync(1, 1, new UpdateStageQuantitiesRequest()));
Assert.Equal(ErrorCodes.ConcurrencyConflict, ex.Code);
Assert.Equal(412, ex.StatusCode);
}
/// <summary>
/// The smallest graph the validator accepts: one stage (both entry and terminal) with a
/// stock input and a single item-bearing output, and no edges.
/// </summary>
private static SaveTemplateRequest SingleStageTemplate() => new()
{
Code = "TPL-1",
Name = "Template 1",
Stages =
[
new SaveStageRequest
{
Key = "tmp-1",
Name = "Mix",
Inputs =
[
new SaveInputRequest
{
Source = StageInputSource.Stock,
ItemId = 1,
QtyUnit = StageQtyUnit.Pack,
QtyPerBatch = 1m
}
],
Outputs =
[
new SaveOutputRequest { Key = "tmp-o1", Name = "Output", ItemId = 1, QtyPerBatch = 1m }
]
}
]
};
private static ErpDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ErpDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString("N"), Root)
.Options;
// The real CurrentBranchContext over an unauthenticated user yields IsActive=false,
// which leaves the branch query filters off: what a unit test wants.
var currentUser = new TestCurrentUser();
return new ErpDbContext(options, currentUser, new CurrentBranchContext(currentUser));
}
private static ProductionTemplateService CreateTemplateService(ErpDbContext db)
=> new(
new Repository<ProductionTemplate>(db), new Repository<TemplateStage>(db),
new Repository<StageInput>(db), new Repository<StageOutput>(db),
new Repository<StageEdge>(db), new Repository<Item>(db),
new Repository<Uom>(db), new Repository<ProductionRun>(db),
new ThrowingTransactionUnitOfWork(), new TestCurrentUser());
// The costing, measurement and numbering collaborators are only ever touched inside the
// transaction lambda, which ThrowingTransactionUnitOfWork never invokes.
private static ProductionRunService CreateRunService(ErpDbContext db)
=> new(
new Repository<ProductionRun>(db), new Repository<ProductionTemplate>(db),
new Repository<Warehouse>(db), new Repository<Bin>(db),
new Repository<Item>(db), new Repository<ReasonCode>(db),
fifo: null!, measure: null!, numbers: null!,
new ThrowingTransactionUnitOfWork(), new TestCurrentUser());
}
@@ -11,6 +11,12 @@ internal sealed class TestCurrentUser : ICurrentUser
public string UserId => "system";
public int AuditUserId => User.SystemUserId;
public bool IsAuthenticated => false;
// Unauthenticated, so no role claim and no branch scoping: the three members the
// AuthHex integration added to ICurrentUser.
public string? RoleCode => null;
public bool IsAdmin => false;
public int? BranchId => null;
}
internal sealed class ThrowingUnitOfWork : ERPCore.Infra.UoW.IUnitOfWork
@@ -25,6 +31,25 @@ internal sealed class ThrowingUnitOfWork : ERPCore.Infra.UoW.IUnitOfWork
=> throw new NotSupportedException();
}
/// <summary>
/// Fails the transactional path the way a lost optimistic-concurrency race does, so the
/// services that save through <c>ExecuteInTransactionAsync</c> can be covered too. The
/// lambda is never invoked, which is the point: the real <c>UnitOfWork</c> issues its own
/// <c>SaveChangesAsync</c> after the lambda returns, so the catch under test has to sit
/// outside it.
/// </summary>
internal sealed class ThrowingTransactionUnitOfWork : ERPCore.Infra.UoW.IUnitOfWork
{
public Task<int> SaveChangesAsync(CancellationToken ct = default)
=> throw new DbUpdateConcurrencyException("Test concurrency conflict.");
public Task ExecuteInTransactionAsync(Func<CancellationToken, Task> action, CancellationToken ct = default)
=> throw new DbUpdateConcurrencyException("Test concurrency conflict.");
public Task<T> ExecuteInTransactionAsync<T>(Func<CancellationToken, Task<T>> action, CancellationToken ct = default)
=> throw new DbUpdateConcurrencyException("Test concurrency conflict.");
}
internal static class NotificationConfigTestSupport
{
private static readonly InMemoryDatabaseRoot Root = new();
@@ -35,7 +60,10 @@ internal static class NotificationConfigTestSupport
.UseInMemoryDatabase(name, Root)
.Options;
return new ErpDbContext(options, new TestCurrentUser());
// The real CurrentBranchContext over an unauthenticated user yields IsActive=false,
// which leaves the branch query filters off: what a unit test wants.
var currentUser = new TestCurrentUser();
return new ErpDbContext(options, currentUser, new CurrentBranchContext(currentUser));
}
public static async Task SeedAsync(ErpDbContext db, IEnumerable<NotificationConfigModule>? modules = null)
@@ -52,7 +80,8 @@ internal static class NotificationConfigTestSupport
NotificationConfigId = NotificationConfig.SingletonId,
UpdatedBy = User.SystemUserId,
UpdatedAt = DateTime.UtcNow,
RowVersion = 7,
// No RowVersion here on purpose: ErpDbContext.BumpRowVersions stamps every
// inserted row with 1, so any seeded value is overwritten on save.
};
if (modules is not null)
@@ -23,8 +23,10 @@ public sealed class CustomersController : ApiControllerBase
[FromQuery] PageQuery query,
[FromQuery] EntityStatus? status,
[FromQuery] CustomerType? customerType,
CancellationToken ct)
=> Ok(await _customers.ListAsync(query, status, customerType, ct));
CancellationToken ct,
[FromQuery] bool includeCashCustomer = false,
[FromQuery] int? branchId = null)
=> Ok(await _customers.ListAsync(query, status, customerType, includeCashCustomer, branchId, ct));
[HttpGet("{customerId:int}")]
[ProducesResponseType(typeof(CustomerDto), StatusCodes.Status200OK)]
@@ -0,0 +1,45 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Config;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// GRN configuration endpoints — warehouse selection and the default receiving warehouse.
/// <para>
/// <b>Authorization:</b> gated on the Receiving nav policy (the New GRN form has to read it), same
/// Phase-1 posture as <see cref="SalesConfigController"/>.
/// </para>
/// </summary>
[Route("api/v1/grn-config")]
[Authorize(Policy = NavPolicies.Prefix + "receiving")]
public sealed class GrnConfigController : ApiControllerBase
{
private readonly IGrnConfigService _config;
public GrnConfigController(IGrnConfigService config) => _config = config;
[HttpGet]
[ProducesResponseType(typeof(GrnConfigDto), StatusCodes.Status200OK)]
public async Task<ActionResult<GrnConfigDto>> Get(CancellationToken ct)
{
var result = await _config.GetAsync(ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPut]
[ProducesResponseType(typeof(GrnConfigDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<GrnConfigDto>> Update(
[FromBody] UpdateGrnConfigRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _config.UpdateAsync(request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
}
@@ -0,0 +1,64 @@
using ERPCore.Dtos.Items;
using ERPCore.Infra.Auth;
using ERPCore.Services;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Item photos. Same access as the item master itself.</summary>
[Route("api/v1/items/{itemId:int}/images")]
[Authorize(Policy = NavPolicies.Prefix + "products")]
public sealed class ItemImagesController : ApiControllerBase
{
private readonly IItemImageService _images;
private readonly ICurrentUser _currentUser;
public ItemImagesController(IItemImageService images, ICurrentUser currentUser)
{
_images = images;
_currentUser = currentUser;
}
[HttpGet]
[ProducesResponseType(typeof(List<ItemImageDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<List<ItemImageDto>>> List(int itemId, CancellationToken ct)
=> Ok(await _images.ListAsync(itemId, ct));
/// <summary>Upload one image (multipart <c>file</c>). 422 CONFIG_DISABLED while item images are off.</summary>
[HttpPost]
[RequestSizeLimit(ItemImageService.MaxSizeBytes + 64 * 1024)]
[ProducesResponseType(typeof(ItemImageDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status413PayloadTooLarge)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<ItemImageDto>> Upload(int itemId, IFormFile file, CancellationToken ct)
{
await using var stream = file.OpenReadStream();
var result = await _images.UploadAsync(itemId, stream, file.FileName, file.ContentType, _currentUser.AuditUserId, ct);
return Created($"/api/v1/items/{itemId}/images/{result.ItemImageId}", result);
}
/// <summary>The image bytes, served inline so an &lt;img&gt; can point straight at it.</summary>
[HttpGet("{itemImageId:int}/content")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Content(int itemId, int itemImageId, CancellationToken ct)
{
var (content, contentType) = await _images.OpenAsync(itemId, itemImageId, ct);
// Stored files are immutable (new upload ⇒ new id), so the browser may cache them.
Response.Headers.CacheControl = "private, max-age=86400";
return File(content, contentType);
}
[HttpDelete("{itemImageId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(int itemId, int itemImageId, CancellationToken ct)
{
await _images.DeleteAsync(itemId, itemImageId, ct);
return NoContent();
}
}
@@ -30,6 +30,22 @@ public sealed class ItemsController : ApiControllerBase
CancellationToken ct)
=> Ok(await _items.ListAsync(query, status, categoryId, subCategoryId, brandId, trackingMode, ct));
/// <summary>Items list enriched with stock value, low-stock, last-purchase and
/// trailing-30-day sales aggregates, for the Items overview table.</summary>
[HttpGet("overview")]
[ProducesResponseType(typeof(PagedResponse<ItemOverviewDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<ItemOverviewDto>>> Overview(
[FromQuery] PageQuery query,
[FromQuery] EntityStatus? status,
[FromQuery] int? categoryId,
[FromQuery] int? subCategoryId,
[FromQuery] int? brandId,
[FromQuery] TrackingMode? trackingMode,
[FromQuery] StockNature? stockNature,
[FromQuery] ItemStockFilter? stock,
CancellationToken ct)
=> Ok(await _items.GetOverviewAsync(query, status, categoryId, subCategoryId, brandId, trackingMode, stockNature, stock, ct));
/// <summary>Barcode/RFID lookup — matches an item's barcode/SKU, or a batch/serial
/// barcode/no (optionally scoped to <paramref name="itemId"/> first).</summary>
[HttpGet("scan")]
@@ -45,5 +45,4 @@ public sealed class ProductConfigController : ApiControllerBase
var result = await _config.UpdateAsync(request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
}
}}
@@ -56,6 +56,7 @@ public sealed class ProductionRunsController : ApiControllerBase
[ProducesResponseType(typeof(RunGraphDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<RunGraphDto>> Create([FromBody] CreateRunRequest request, CancellationToken ct)
{
var result = await _runs.CreateAsync(request, ct);
@@ -69,6 +70,7 @@ public sealed class ProductionRunsController : ApiControllerBase
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<RunStageDto>> UpdateQuantities(
int runId, int runStageId, [FromBody] UpdateStageQuantitiesRequest request, CancellationToken ct)
=> Ok(await _runs.UpdateStageQuantitiesAsync(runId, runStageId, request, ct));
@@ -86,6 +88,7 @@ public sealed class ProductionRunsController : ApiControllerBase
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<StartStageResultDto>> Start(
int runId, int runStageId,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
@@ -98,6 +101,7 @@ public sealed class ProductionRunsController : ApiControllerBase
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<RunStageDto>> Complete(
int runId, int runStageId, [FromBody] CompleteStageRequest request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
@@ -109,6 +113,7 @@ public sealed class ProductionRunsController : ApiControllerBase
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<ApproveStageResultDto>> Approve(
int runId, int runStageId, [FromBody] ApproveStageRequest? request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
@@ -120,6 +125,7 @@ public sealed class ProductionRunsController : ApiControllerBase
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<TransferResultDto>> Transfer(
int runId, int runStageId, [FromBody] TransferRemainderRequest request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
@@ -132,6 +138,7 @@ public sealed class ProductionRunsController : ApiControllerBase
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<ReturnLeftoverResultDto>> ReturnLeftover(
int runId, int runInputId, [FromBody] ReturnLeftoverRequest request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
@@ -142,6 +149,7 @@ public sealed class ProductionRunsController : ApiControllerBase
[ProducesResponseType(typeof(RejectIntakeResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<RejectIntakeResultDto>> RejectIntake(
int runId, int runStageId, [FromBody] RejectRequest? request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
@@ -153,6 +161,7 @@ public sealed class ProductionRunsController : ApiControllerBase
[ProducesResponseType(typeof(TerminalRejectResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<TerminalRejectResultDto>> Reject(
int runId, int runStageId, [FromBody] RejectRequest? request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
@@ -165,6 +174,7 @@ public sealed class ProductionRunsController : ApiControllerBase
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<CancelRunResultDto>> Cancel(
int runId, [FromBody] CancelRunRequest request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
@@ -0,0 +1,46 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Config;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Sales configuration endpoints — the singleton "allow cash customer" toggle.
/// <para>
/// <b>Authorization:</b> gated on the Sales nav policy (the New Sale form has to read it), same
/// Phase-1 posture as <see cref="BranchConfigController"/> — any Sales user can flip the flag
/// until per-endpoint RBAC lands.
/// </para>
/// </summary>
[Route("api/v1/sales-config")]
[Authorize(Policy = NavPolicies.Prefix + "sales")]
public sealed class SalesConfigController : ApiControllerBase
{
private readonly ISalesConfigService _config;
public SalesConfigController(ISalesConfigService config) => _config = config;
[HttpGet]
[ProducesResponseType(typeof(SalesConfigDto), StatusCodes.Status200OK)]
public async Task<ActionResult<SalesConfigDto>> Get(CancellationToken ct)
{
var result = await _config.GetAsync(ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPut]
[ProducesResponseType(typeof(SalesConfigDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<SalesConfigDto>> Update(
[FromBody] UpdateSalesConfigRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _config.UpdateAsync(request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
}
@@ -14,11 +14,70 @@ public sealed class SalesInvoicesController : ApiControllerBase
{
private readonly ISalesInvoiceService _invoices;
private readonly ISalesInvoicePaymentService _payments;
private readonly ISalesPricingService _pricing;
private readonly ISalesDomainService _sales;
private readonly IStockService _stock;
public SalesInvoicesController(ISalesInvoiceService invoices, ISalesInvoicePaymentService payments)
public SalesInvoicesController(
ISalesInvoiceService invoices, ISalesInvoicePaymentService payments, ISalesPricingService pricing,
ISalesDomainService sales, IStockService stock)
{
_invoices = invoices;
_payments = payments;
_pricing = pricing;
_sales = sales;
_stock = stock;
}
// Lets the New Sale form warn, per line, when qty exceeds the stock a sale can issue — under
// the "sales" policy, since sales users may not hold the "stock" nav permission. Non-stocked
// items (services) are never short, so they report IsStocked = false and no limit.
[HttpGet("stock-availability")]
[ProducesResponseType(typeof(SalesStockAvailabilityDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesStockAvailabilityDto>> StockAvailability(
[FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
{
bool isStocked;
try
{
isStocked = await _sales.IsStockedItemAsync(itemId, ct);
}
catch (InvalidOperationException)
{
return NotFound();
}
var available = isStocked ? (await _stock.GetOnHandAsync(itemId, warehouseId, ct)).Available : 0m;
return Ok(new SalesStockAvailabilityDto(itemId, warehouseId, isStocked, available));
}
// Feeds New Sale's "Add item" dropdown with each item's issuable stock at the sale's warehouse.
// Under "sales" for the same reason as above; items absent from the list have no stock there.
[HttpGet("stock-summary")]
[ProducesResponseType(typeof(IReadOnlyList<SalesItemStockDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<SalesItemStockDto>>> StockSummary([FromQuery] int warehouseId, CancellationToken ct)
{
var available = await _stock.GetAvailableByItemAsync(warehouseId, ct);
return Ok(available.Select(kv => new SalesItemStockDto(kv.Key, kv.Value)).ToList());
}
// Lets the New Sale form pre-fill Unit price for items with no flat selling price, using the
// same resolution the server applies on save.
[HttpGet("suggested-price")]
[ProducesResponseType(typeof(SalesSuggestedPriceDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesSuggestedPriceDto>> SuggestedPrice(
[FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
{
try
{
var price = await _pricing.ResolveAsync(itemId, warehouseId, null, false, ct);
return Ok(new SalesSuggestedPriceDto(itemId, warehouseId, price.UnitPrice, price.PriceSource));
}
catch (InvalidOperationException)
{
return NotFound();
}
}
[HttpGet]
@@ -29,8 +88,12 @@ public sealed class SalesInvoicesController : ApiControllerBase
[FromQuery] int? customerId,
[FromQuery] int? warehouseId,
[FromQuery] int? branchId,
[FromQuery] DateOnly? from,
[FromQuery] DateOnly? to,
[FromQuery] int? createdBy,
[FromQuery] bool? paid,
CancellationToken ct)
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, ct));
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, from, to, createdBy, paid, ct));
[HttpGet("{salesInvoiceId:int}")]
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
@@ -92,6 +155,18 @@ public sealed class SalesInvoicesController : ApiControllerBase
return Created($"/api/v1/sales-invoices/{salesInvoiceId}/payments/{dto.SalesInvoicePaymentId}", dto);
}
/// <summary>
/// Pay several Posted invoices of one (non-cash) customer at once, split across payment lines;
/// applied oldest invoice first under a single GL journal entry. Returns one payment per split.
/// </summary>
[HttpPost("payments/bulk")]
[ProducesResponseType(typeof(IReadOnlyList<SalesInvoicePaymentDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<IReadOnlyList<SalesInvoicePaymentDto>>> PayBulk([FromBody] CreateBulkSalesInvoicePaymentRequest request, CancellationToken ct)
=> Ok(await _payments.PayBulkAsync(request, ct));
/// <summary>Payment history for this invoice, newest first.</summary>
[HttpGet("{salesInvoiceId:int}/payments")]
[ProducesResponseType(typeof(IReadOnlyList<SalesInvoicePaymentDto>), StatusCodes.Status200OK)]
@@ -0,0 +1,44 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Items;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Custom warranty period master — managed under Products like Item Variants.</summary>
[Route("api/v1/warranty-periods")]
[Authorize(Policy = NavPolicies.Prefix + "products")]
public sealed class WarrantyPeriodsController : ApiControllerBase
{
private readonly IWarrantyPeriodService _periods;
public WarrantyPeriodsController(IWarrantyPeriodService periods) => _periods = periods;
/// <summary>Custom periods only (defaults are fixed); filter <c>status=Active</c> for selectable rows.</summary>
[HttpGet]
[ProducesResponseType(typeof(List<WarrantyPeriodDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<WarrantyPeriodDto>>> List([FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _periods.ListAsync(status, ct));
[HttpPost]
[ProducesResponseType(typeof(WarrantyPeriodDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<WarrantyPeriodDto>> Create([FromBody] CreateWarrantyPeriodRequest request, CancellationToken ct)
{
var result = await _periods.CreateAsync(request.Months!.Value, ct);
return Created($"/api/v1/warranty-periods/{result.WarrantyPeriodId}", result);
}
/// <summary>Deactivate/reactivate. Never hard-deleted (FR-MD-08).</summary>
[HttpPatch("{warrantyPeriodId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int warrantyPeriodId, [FromBody] UpdateWarrantyPeriodStatusRequest request, CancellationToken ct)
{
await _periods.SetStatusAsync(warrantyPeriodId, request.Status, ct);
return NoContent();
}
}
+12
View File
@@ -9,6 +9,18 @@ namespace ERPCore.Domain.Entities;
/// </summary>
public class Branch
{
/// <summary>
/// Code of the deployment's default branch. Every branch-scoped entity has a
/// non-nullable <c>BranchId</c> with a Restrict FK, so this row must exist before any
/// of them can be written — see <c>DataSeeder.EnsureDefaultBranchAsync</c>, which
/// creates it at startup, and <c>BranchConfigService.ResolveDefaultBranchIdAsync</c>,
/// which is the lazy runtime fallback.
/// </summary>
public const string DefaultCode = "BR-000";
/// <summary>Display name paired with <see cref="DefaultCode"/>.</summary>
public const string DefaultName = "Main Branch";
public int BranchId { get; set; }
/// <summary>Server-generated on create ("BR-001", "BR-002", ...) — never accepted from the client.</summary>
@@ -27,7 +27,7 @@ public class BundleSale
public decimal GrandTotal { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public int ConcurrencyStamp { get; set; }
public uint RowVersion { get; set; }
/// <summary>Set once this bundle's business date is closed by <see cref="SalesDayEnd"/> — a
/// closed bundle sale is frozen (no further edits). Same role as <see cref="SalesSlip.DayEndId"/>;
@@ -11,7 +11,7 @@ public class BundleSaleTemplate
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public int ConcurrencyStamp { get; set; }
public uint RowVersion { get; set; }
public ICollection<BundleSaleTemplateLine> Lines { get; set; } = new List<BundleSaleTemplateLine>();
}
@@ -0,0 +1,41 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// GRN (goods receipt) configuration — a singleton row, same shape as <see cref="SalesConfig"/>.
/// Controls which warehouse goods are received into.
/// </summary>
public class GrnConfig
{
/// <summary>Always 1 - the singleton row's id.</summary>
public const int SingletonId = 1;
public int ConfigId { get; set; }
/// <summary>When on (default) and no <see cref="DefaultWarehouseId"/> is saved, New GRN shows
/// a Warehouse picker. When off, a default must be saved.</summary>
public bool AllowWarehouseSelection { get; set; } = true;
/// <summary>Default receiving warehouse. Whenever set, New GRN shows only this warehouse (locked)
/// and every GRN is forced onto it server-side (see GrnConfigService.GetForcedWarehouseIdAsync).
/// Required while <see cref="AllowWarehouseSelection"/> is off; clearing it brings the picker back.</summary>
public int? DefaultWarehouseId { get; set; }
public Warehouse? DefaultWarehouse { get; set; }
/// <summary>When on (default), GRN lines may carry a discount % (the New GRN "Disc %" column and
/// header rate); when off, they are hidden and a discount above zero is rejected server-side
/// (see GrnService.CreateAsync).</summary>
public bool AllowDiscounts { get; set; } = true;
/// <summary>When on (default), GRN lines carry VAT (entered, or the item's tax rate when left
/// empty); when off, the VAT columns are hidden, every line is received at 0% VAT, and a VAT
/// above zero is rejected server-side (see GrnService.CreateAsync).</summary>
public bool AllowVat { get; set; } = true;
public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; }
public User? UpdatedByUser { get; set; }
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
public uint RowVersion { get; set; }
}
@@ -0,0 +1,27 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// A product photo attached to an item. Like <see cref="EmployeeDocument"/>, the file lives
/// behind <see cref="Infra.Storage.IFileStorageService"/> under a server-generated name and is
/// only reachable through the authenticated items API — never a static path. Only uploadable
/// while <see cref="ProductConfig.ItemImagesEnabled"/> is on; existing images stay readable
/// after the flag is switched off (reads are never gated).
/// </summary>
public class ItemImage
{
public int ItemImageId { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public string OriginalFileName { get; set; } = string.Empty;
public string StoredFileName { get; set; } = string.Empty;
public string RelativePath { get; set; } = string.Empty;
public string ContentType { get; set; } = string.Empty;
public long SizeBytes { get; set; }
/// <summary>Display order; the lowest is the item's main image.</summary>
public int SortOrder { get; set; }
public int UploadedBy { get; set; }
public DateTime UploadedAt { get; set; }
}
@@ -31,6 +31,26 @@ public class ProductConfig
public bool BatchListsEnabled { get; set; } = true;
public bool WarrantyPeriodsEnabled { get; set; } = true;
/// <summary>
/// Lets the item create screen add a new item variant (item type) on the spot, instead
/// of only picking from the Item Variants master. Advisory like <see cref="ItemTypesEnabled"/>
/// - it gates a UI shortcut; the Item Variants master itself stays available either way.
/// </summary>
public bool CustomVariantsEnabled { get; set; }
/// <summary>
/// Allows photos to be uploaded to items (<see cref="ItemImage"/>). Enforced on upload
/// (<c>CONFIG_DISABLED</c>); images added earlier stay viewable when switched off.
/// </summary>
public bool ItemImagesEnabled { get; set; }
/// <summary>
/// Lets items use the active <see cref="WarrantyPeriod"/> master rows alongside the default
/// <see cref="Enums.WarrantyPeriods.AllowedMonths"/>. Enforced on item writes. The periods
/// themselves are managed under Products, not here.
/// </summary>
public bool CustomWarrantyPeriodsEnabled { get; set; }
public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; }
@@ -0,0 +1,61 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Sales configuration — a singleton row (single-tenant, docs/00-CORE §1), same shape as
/// <see cref="BranchConfig"/>. When <see cref="AllowCashCustomer"/> is on, sales documents may
/// be raised against the generic "Cash Customer" (<see cref="CashCustomerId"/>) and the New Sale
/// form defaults to it; when off, a real customer must be picked and the cash customer is
/// rejected server-side (see SalesDomainService.ValidateSalesHeaderAsync).
/// </summary>
public class SalesConfig
{
/// <summary>Always 1 - the singleton row's id.</summary>
public const int SingletonId = 1;
public int ConfigId { get; set; }
public bool AllowCashCustomer { get; set; }
/// <summary>The Customer row used for cash (walk-in, unnamed) sales. Created on first
/// enabling <see cref="AllowCashCustomer"/> and kept afterwards, so switching the option
/// off and on again reuses the same customer (and its sales history).</summary>
public int? CashCustomerId { get; set; }
public Customer? CashCustomer { get; set; }
/// <summary>When on, sales invoice lines may carry a free quantity (the New Sale "Free"
/// column); when off, the column is hidden and a free qty above zero is rejected server-side
/// (see SalesInvoiceService.BuildLinesAsync). Defaults on, matching the behaviour before
/// this option existed.</summary>
public bool AllowFreeItems { get; set; } = true;
/// <summary>When on, sales invoice lines may carry a line discount (the New Sale "Disc %"
/// column); when off, the column is hidden and a discount above zero is rejected server-side
/// (see SalesInvoiceService.BuildLinesAsync). Defaults on, matching the behaviour before
/// this option existed.</summary>
public bool AllowDiscounts { get; set; } = true;
/// <summary>When on, posting a sale (invoice, slip, bundle) may issue more than the stock on
/// hand — the shortfall drives on-hand negative (see FifoCostingService.ConsumeAsync) and the
/// pre-post check reports it as a warning only. When off (default), posting is blocked with
/// STOCK_NEGATIVE_BLOCKED until stock covers every line.</summary>
public bool AllowOverStockSale { get; set; }
/// <summary>When on (default) and no <see cref="DefaultWarehouseId"/> is saved, New Sale shows
/// a Warehouse picker. When off, a default must be saved.</summary>
public bool AllowWarehouseSelection { get; set; } = true;
/// <summary>Default sales warehouse. Whenever set, New Sale shows only this warehouse (locked)
/// and every sales invoice (header and lines) is forced onto it server-side (see
/// SalesDomainService.GetForcedSalesWarehouseIdAsync). Required while
/// <see cref="AllowWarehouseSelection"/> is off; clearing it brings the picker back.</summary>
public int? DefaultWarehouseId { get; set; }
public Warehouse? DefaultWarehouse { get; set; }
public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; }
public User? UpdatedByUser { get; set; }
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
public uint RowVersion { get; set; }
}
+6
View File
@@ -15,6 +15,12 @@ public class User
/// <summary>Seeded fallback actor for unauthenticated/system operations.</summary>
public const int SystemUserId = 1;
/// <summary>
/// Username prefix of a permanently deleted login whose row is kept only because history
/// (audit log, documents) still references it. Such rows are hidden from user management.
/// </summary>
public const string DeletedUsernamePrefix = "deleted-user-";
public int UserId { get; set; }
public string Username { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
@@ -0,0 +1,23 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Custom warranty period master — a coverage length offered to items alongside the fixed
/// defaults in <see cref="WarrantyPeriods.AllowedMonths"/>, while
/// <see cref="ProductConfig.CustomWarrantyPeriodsEnabled"/> is on.
/// <para>
/// Always in months (a "2 year" entry is stored as 24). Like <see cref="ItemType"/> it is
/// deliberately unlinked: an item stores the number of months itself, not a reference, so
/// deactivating a period only stops it being offered — items already using it keep it.
/// The length is immutable (no PUT); a different length is a different row.
/// </para>
/// </summary>
public class WarrantyPeriod
{
public int WarrantyPeriodId { get; set; }
public int Months { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
}
@@ -0,0 +1,22 @@
using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Config;
/// <summary>GRN configuration resource.</summary>
public sealed record GrnConfigDto(
bool AllowWarehouseSelection, int? DefaultWarehouseId, string? DefaultWarehouseName, bool AllowDiscounts, bool AllowVat,
DateTime? UpdatedAt, int? UpdatedBy);
/// <summary>
/// Full replacement of the flags. <c>UpdatedBy</c> is derived from the token, never posted.
/// Nullable bool so an omitted flag is a 400 rather than silently binding to <c>false</c>
/// (same reasoning as <see cref="UpdateSalesConfigRequest"/>).
/// </summary>
public sealed class UpdateGrnConfigRequest
{
[Required] public bool? AllowWarehouseSelection { get; set; }
/// <summary>Required when <see cref="AllowWarehouseSelection"/> is false.</summary>
public int? DefaultWarehouseId { get; set; }
[Required] public bool? AllowDiscounts { get; set; }
[Required] public bool? AllowVat { get; set; }
}
@@ -12,6 +12,8 @@ namespace ERPCore.Dtos.Config;
public sealed record ProductConfigDto(
bool SubcategoriesEnabled, bool BrandsEnabled, bool ItemTypesEnabled,
bool SellingPricesEnabled, bool SerialNumbersEnabled, bool BatchListsEnabled, bool WarrantyPeriodsEnabled,
bool CustomVariantsEnabled, bool ItemImagesEnabled,
bool CustomWarrantyPeriodsEnabled,
DateTime? UpdatedAt, int? UpdatedBy);
/// <summary>
@@ -32,4 +34,7 @@ public sealed class UpdateProductConfigRequest
[Required] public bool? SerialNumbersEnabled { get; set; }
[Required] public bool? BatchListsEnabled { get; set; }
[Required] public bool? WarrantyPeriodsEnabled { get; set; }
[Required] public bool? CustomVariantsEnabled { get; set; }
[Required] public bool? ItemImagesEnabled { get; set; }
[Required] public bool? CustomWarrantyPeriodsEnabled { get; set; }
}
@@ -0,0 +1,25 @@
using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Config;
/// <summary>Sales configuration resource. <c>CashCustomerId</c>/<c>CashCustomerName</c> identify
/// the generic cash customer (null until the option has been switched on at least once).</summary>
public sealed record SalesConfigDto(
bool AllowCashCustomer, int? CashCustomerId, string? CashCustomerName, bool AllowFreeItems, bool AllowDiscounts, bool AllowOverStockSale,
bool AllowWarehouseSelection, int? DefaultWarehouseId, string? DefaultWarehouseName, DateTime? UpdatedAt, int? UpdatedBy);
/// <summary>
/// Full replacement of the flags. <c>UpdatedBy</c> is derived from the token, never posted.
/// Nullable bool so an omitted flag is a 400 rather than silently binding to <c>false</c>
/// (same reasoning as <see cref="UpdateBranchConfigRequest"/>).
/// </summary>
public sealed class UpdateSalesConfigRequest
{
[Required] public bool? AllowCashCustomer { get; set; }
[Required] public bool? AllowFreeItems { get; set; }
[Required] public bool? AllowDiscounts { get; set; }
[Required] public bool? AllowOverStockSale { get; set; }
[Required] public bool? AllowWarehouseSelection { get; set; }
/// <summary>Required when <see cref="AllowWarehouseSelection"/> is false.</summary>
public int? DefaultWarehouseId { get; set; }
}
@@ -23,7 +23,12 @@ public sealed record DashboardStatsDto(
int PendingCounts,
int OpenRfqs,
int PendingLeaveRequests,
int OverdueSalesInvoices);
int OverdueSalesInvoices,
IReadOnlyList<MonthlyPurchasesVsSalesDto> MonthlyPurchasesVsSales);
/// <summary>One bar in the Stock Valuation chart — total FIFO layer value for a warehouse.</summary>
public sealed record WarehouseValuationDto(int WarehouseId, decimal Total);
/// <summary>One month in the Monthly Purchases chart (Purchase Invoices page) — GrandTotal
/// of Posted purchase invoices vs Posted sales invoices. <c>Month</c> is "yyyy-MM".</summary>
public sealed record MonthlyPurchasesVsSalesDto(string Month, decimal Purchases, decimal Sales);
+1 -1
View File
@@ -22,7 +22,7 @@ public sealed record GrnDto(
/// <summary>Row shape for <c>GET /grns</c> — line count instead of the lines themselves.</summary>
public sealed record GrnSummaryDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, int BranchId, GrnStatus Status,
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount,
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount, decimal TotalValue,
int? PurchaseInvoiceId, string? PurchaseInvoiceNo);
public sealed record CreatedLayerDto(
+2 -1
View File
@@ -133,5 +133,6 @@ public sealed class ReorderSettingInput
public sealed class UpdateReorderRequest
{
[Required, MinLength(1)] public List<ReorderSettingInput> Settings { get; set; } = new();
/// <summary>The complete set — an empty list removes every rule for the item.</summary>
[Required] public List<ReorderSettingInput> Settings { get; set; } = new();
}
@@ -0,0 +1,7 @@
namespace ERPCore.Dtos.Items;
/// <summary>An item photo's metadata. The bytes are served by
/// <c>GET /items/{itemId}/images/{itemImageId}/content</c>.</summary>
public sealed record ItemImageDto(
int ItemImageId, int ItemId, string FileName, string ContentType, long SizeBytes,
int SortOrder, DateTime UploadedAt);
@@ -0,0 +1,27 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Items;
/// <summary>
/// Item row enriched with cross-module aggregates (current FIFO stock value, low-stock
/// flag, last purchase, trailing-30-day sales) for the Items list page. Deliberately a
/// separate endpoint from <see cref="ItemListItemDto"/>'s <c>GET /items</c> — that list
/// also backs the GRN/PO/Requisition/RFQ item pickers, which don't need these aggregates
/// computed on every keystroke.
/// </summary>
public sealed record ItemOverviewDto(
int ItemId, string Sku, string Name,
int CategoryId, int? SubCategoryId, int? BrandId,
EntityStatus Status,
decimal StockQty, decimal StockValue, bool IsLowStock,
DateTime? LastPurchaseDate, int? LastSupplierId, string? LastSupplierName,
decimal SalesQtyLast30Days);
/// <summary>Stock-state filter for the Items overview. <c>LowStock</c> uses the same rule
/// as <see cref="ItemOverviewDto.IsLowStock"/>: on-hand at or below any warehouse's reorder point.</summary>
public enum ItemStockFilter
{
InStock,
OutOfStock,
LowStock
}
@@ -0,0 +1,17 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Items;
public sealed record WarrantyPeriodDto(int WarrantyPeriodId, int Months, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
/// <summary>A new custom length, always in months (the client converts years).</summary>
public sealed class CreateWarrantyPeriodRequest
{
[Required] public int? Months { get; set; }
}
public sealed class UpdateWarrantyPeriodStatusRequest
{
[Required] public EntityStatus Status { get; set; }
}
+35 -1
View File
@@ -23,7 +23,7 @@ public sealed record SalesInvoiceDto(
public sealed record SalesInvoiceSummaryDto(
int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId,
string CustomerSnapshotName, int WarehouseId, int BranchId, SalesInvoiceType InvoiceType,
SalesInvoiceStatus Status, SalesInvoiceTotalsDto Totals, DateTime CreatedAt, string? GlJournalNo);
SalesInvoiceStatus Status, SalesInvoiceTotalsDto Totals, DateTime CreatedAt, string? GlJournalNo, int CreatedBy);
public sealed record SalesInvoicePaymentDto(
int SalesInvoicePaymentId, int SalesInvoiceId, decimal Amount, DateTime PaymentDate,
@@ -34,6 +34,28 @@ 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; }
/// <summary>When the customer paid; also the GL entry date. Omitted ⇒ today. Never in the future.</summary>
public DateOnly? PaymentDate { get; set; }
}
/// <summary>One way the customer paid in a multi-invoice payment: an amount into a cash/bank account.</summary>
public sealed class BulkSalesInvoicePaymentLine
{
[Range(0.01, double.MaxValue)] public decimal Amount { get; set; }
[Required] public long GlBankAccountId { get; set; }
}
/// <summary>
/// Pay several Posted invoices of one customer at once (never the generic cash customer). The lines'
/// total is applied oldest invoice first and may be less than the invoices' combined balance.
/// </summary>
public sealed class CreateBulkSalesInvoicePaymentRequest
{
[Required, MinLength(1)] public List<int> SalesInvoiceIds { get; set; } = [];
[Required, MinLength(1)] public List<BulkSalesInvoicePaymentLine> Lines { get; set; } = [];
[StringLength(100)] public string? Reference { get; set; }
/// <summary>When the customer paid; also the GL entry date. Omitted ⇒ today. Never in the future.</summary>
public DateOnly? PaymentDate { get; set; }
}
public sealed record SalesInvoicePostingIssueDto(
@@ -44,6 +66,18 @@ public sealed record SalesInvoicePostingCheckDto(
int SalesInvoiceId, string InvoiceNo, SalesInvoiceStatus Status, bool CanPost,
IReadOnlyList<SalesInvoicePostingIssueDto> Issues);
/// <summary>The unit price a new line would get if left blank — same rule as on save
/// (SALE_PRICE, else GRN_WEIGHTED_AVG for the warehouse, else FIFO_AVG).</summary>
public sealed record SalesSuggestedPriceDto(int ItemId, int WarehouseId, decimal UnitPrice, string PriceSource);
/// <summary>Issuable stock for one item at one warehouse (on-hand less on-hold), for New Sale's
/// per-line shortage warning. <c>IsStocked</c> false = non-stock item, never limited.</summary>
public sealed record SalesStockAvailabilityDto(int ItemId, int WarehouseId, bool IsStocked, decimal Available);
/// <summary>Issuable stock (on-hand less on-hold) for one item at a warehouse — one row per item
/// that has ever had stock there, for New Sale's "Add item" dropdown.</summary>
public sealed record SalesItemStockDto(int ItemId, decimal Available);
public sealed class CreateSalesInvoiceLineRequest
{
[Required] public int ItemId { get; set; }
+6 -6
View File
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
@@ -10,18 +10,18 @@
<ItemGroup>
<PackageReference Include="ClosedXML" Version="0.105.0" />
<PackageReference Include="CsvHelper" Version="33.1.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.20" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.20">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9">
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.20">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.20" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
@@ -78,7 +78,11 @@ public static class AuditScribe
foreach (var p in entry.Properties)
{
if (p.Metadata.IsPrimaryKey()) continue;
if (p.Metadata.Name == nameof(Item.RowVersion)) continue;
// Semantic rather than name-matched: covers every concurrency token automatically
// and survives a rename. (This used to be `Name == nameof(Item.RowVersion)`, which
// silently missed BundleSale/BundleSaleTemplate's differently-named token and so
// leaked it into every bundle-sale change set.)
if (p.Metadata.IsConcurrencyToken) continue;
switch (action)
{
@@ -22,8 +22,6 @@ public sealed class AttendanceRecordConfiguration : IEntityTypeConfiguration<Att
builder.HasOne(r => r.WorkShift).WithMany()
.HasForeignKey(r => r.WorkShiftId).OnDelete(DeleteBehavior.Restrict);
builder.Property(r => r.RowVersion).IsRowVersion();
builder.HasIndex(r => new { r.EmployeeId, r.AttendanceDate });
builder.HasIndex(r => r.AttendanceUploadBatchId);
}
@@ -19,7 +19,6 @@ public sealed class AttendanceUploadBatchConfiguration : IEntityTypeConfiguratio
builder.Property(b => b.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(b => b.UploadedAt).IsRequired();
builder.Property(b => b.RowVersion).IsRowVersion();
builder.HasIndex(b => b.Status);
builder.HasIndex(b => new { b.PeriodStart, b.PeriodEnd });
@@ -13,7 +13,7 @@ public sealed class AuditLogConfiguration : IEntityTypeConfiguration<AuditLog>
builder.Property(a => a.EntityType).IsRequired().HasMaxLength(80);
builder.Property(a => a.Action).HasConversion<string>().HasMaxLength(10).IsRequired();
builder.Property(a => a.ChangeSet).IsRequired().HasColumnType("jsonb");
builder.Property(a => a.ChangeSet).IsRequired().HasColumnType("longtext");
builder.Property(a => a.CreatedAt).IsRequired();
builder.HasOne<User>().WithMany().HasForeignKey(a => a.UserId).OnDelete(DeleteBehavior.Restrict);
@@ -13,7 +13,7 @@ public sealed class BranchConfigConfiguration : IEntityTypeConfiguration<BranchC
public void Configure(EntityTypeBuilder<BranchConfig> builder)
{
builder.ToTable("branch_config", t =>
t.HasCheckConstraint("ck_branch_config_singleton", $"\"ConfigId\" = {BranchConfig.SingletonId}"));
t.HasCheckConstraint("ck_branch_config_singleton", $"`ConfigId` = {BranchConfig.SingletonId}"));
builder.HasKey(c => c.ConfigId);
@@ -22,9 +22,6 @@ public sealed class BranchConfigConfiguration : IEntityTypeConfiguration<BranchC
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)
@@ -23,7 +23,6 @@ public sealed class BranchConfiguration : IEntityTypeConfiguration<Branch>
.HasDefaultValue(EntityStatus.Active);
builder.Property(b => b.CreatedAt).IsRequired();
builder.Property(b => b.RowVersion).IsRowVersion();
builder.HasIndex(b => b.Status);
}
@@ -21,9 +21,6 @@ public sealed class BrandConfiguration : IEntityTypeConfiguration<Brand>
builder.Property(b => b.CreatedAt).IsRequired();
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(b => b.RowVersion).IsRowVersion();
builder.HasIndex(b => b.Status);
}
}
@@ -27,11 +27,6 @@ public sealed class BundleSaleConfiguration : IEntityTypeConfiguration<BundleSal
builder.Property(x => x.TaxTotal).HasPrecision(18, 4);
builder.Property(x => x.GrandTotal).HasPrecision(18, 4);
builder.Property(x => x.CreatedAt).IsRequired();
builder.Property(x => x.ConcurrencyStamp)
.IsRequired()
.HasColumnType("integer")
.HasDefaultValue(0)
.IsConcurrencyToken();
builder.HasOne(x => x.Customer).WithMany().HasForeignKey(x => x.CustomerId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Warehouse).WithMany().HasForeignKey(x => x.WarehouseId).OnDelete(DeleteBehavior.Restrict);
@@ -20,11 +20,6 @@ public sealed class BundleSaleTemplateConfiguration : IEntityTypeConfiguration<B
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.Property(x => x.CreatedAt).IsRequired();
builder.Property(x => x.ConcurrencyStamp)
.IsRequired()
.HasColumnType("integer")
.HasDefaultValue(0)
.IsConcurrencyToken();
builder.HasMany(x => x.Lines)
.WithOne(x => x.BundleSaleTemplate)
@@ -21,9 +21,6 @@ public sealed class CategoryConfiguration : IEntityTypeConfiguration<Category>
builder.Property(c => c.CreatedAt).IsRequired();
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(c => c.RowVersion).IsRowVersion();
builder.HasIndex(c => c.Status);
}
}
@@ -42,7 +42,6 @@ public sealed class CustomerConfiguration : IEntityTypeConfiguration<Customer>
.HasDefaultValue(EntityStatus.Active);
builder.Property(c => c.CreatedAt).IsRequired();
builder.Property(c => c.RowVersion).IsRowVersion();
builder.HasIndex(c => c.Status);
builder.HasIndex(c => c.CustomerType);
@@ -32,7 +32,6 @@ public sealed class DepartmentConfiguration : IEntityTypeConfiguration<Departmen
.HasForeignKey(d => d.BranchId).OnDelete(DeleteBehavior.Restrict);
builder.Property(d => d.CreatedAt).IsRequired();
builder.Property(d => d.RowVersion).IsRowVersion();
builder.HasIndex(d => d.Status);
builder.HasIndex(d => d.ParentDepartmentId);
@@ -21,7 +21,6 @@ public sealed class DesignationConfiguration : IEntityTypeConfiguration<Designat
.HasDefaultValue(EntityStatus.Active);
builder.Property(d => d.CreatedAt).IsRequired();
builder.Property(d => d.RowVersion).IsRowVersion();
builder.HasIndex(d => d.Status);
}
@@ -26,7 +26,6 @@ public sealed class EmployeeBankDetailConfiguration : IEntityTypeConfiguration<E
.HasForeignKey(b => b.EmployeeId).OnDelete(DeleteBehavior.Cascade);
builder.Property(b => b.CreatedAt).IsRequired();
builder.Property(b => b.RowVersion).IsRowVersion();
builder.HasIndex(b => b.EmployeeId);
}
@@ -63,7 +63,6 @@ public sealed class EmployeeConfiguration : IEntityTypeConfiguration<Employee>
.HasDefaultValue(EmployeeStatus.Active);
builder.Property(e => e.CreatedAt).IsRequired();
builder.Property(e => e.RowVersion).IsRowVersion();
builder.HasIndex(e => e.Status);
builder.HasIndex(e => e.DepartmentId);
@@ -28,7 +28,6 @@ public sealed class EmployeeDocumentConfiguration : IEntityTypeConfiguration<Emp
.HasForeignKey(d => d.HrDocumentTypeId).OnDelete(DeleteBehavior.Restrict);
builder.Property(d => d.UploadedAt).IsRequired();
builder.Property(d => d.RowVersion).IsRowVersion();
builder.HasIndex(d => d.EmployeeId);
builder.HasIndex(d => d.ExpiryDate);
@@ -28,7 +28,6 @@ public sealed class EmployeeLoanConfiguration : IEntityTypeConfiguration<Employe
.HasForeignKey(i => i.EmployeeLoanId).OnDelete(DeleteBehavior.Cascade);
builder.Property(l => l.CreatedAt).IsRequired();
builder.Property(l => l.RowVersion).IsRowVersion();
builder.HasIndex(l => l.EmployeeId);
builder.HasIndex(l => l.Status);
@@ -49,8 +48,6 @@ public sealed class LoanInstallmentConfiguration : IEntityTypeConfiguration<Loan
builder.HasOne(i => i.PayrollRun).WithMany()
.HasForeignKey(i => i.PayrollRunId).OnDelete(DeleteBehavior.Restrict);
builder.Property(i => i.RowVersion).IsRowVersion();
builder.HasIndex(i => new { i.DueYear, i.DueMonth });
}
}
@@ -22,7 +22,6 @@ public sealed class EmployeeSalaryStructureConfiguration : IEntityTypeConfigurat
.HasForeignKey(l => l.EmployeeSalaryStructureId).OnDelete(DeleteBehavior.Cascade);
builder.Property(s => s.CreatedAt).IsRequired();
builder.Property(s => s.RowVersion).IsRowVersion();
builder.HasIndex(s => new { s.EmployeeId, s.EffectiveTo });
}
@@ -21,7 +21,6 @@ public sealed class EmploymentTypeConfiguration : IEntityTypeConfiguration<Emplo
.HasDefaultValue(EntityStatus.Active);
builder.Property(e => e.CreatedAt).IsRequired();
builder.Property(e => e.RowVersion).IsRowVersion();
builder.HasIndex(e => e.Status);
}
@@ -0,0 +1,42 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
/// <summary>
/// Configures the singleton GRN-configuration row. The check constraint is what makes
/// "singleton" a database guarantee rather than a convention (mirrors SalesConfig).
/// </summary>
public sealed class GrnConfigConfiguration : IEntityTypeConfiguration<GrnConfig>
{
public void Configure(EntityTypeBuilder<GrnConfig> builder)
{
builder.ToTable("grn_config", t =>
t.HasCheckConstraint("ck_grn_config_singleton", $"\"ConfigId\" = {GrnConfig.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();
// Sentinel = the CLR initializer (true), so an explicit false is still written on insert
// instead of being swallowed by the database default.
builder.Property(c => c.AllowWarehouseSelection).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.Property(c => c.AllowDiscounts).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.Property(c => c.AllowVat).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.HasOne(c => c.DefaultWarehouse)
.WithMany()
.HasForeignKey(c => c.DefaultWarehouseId)
.OnDelete(DeleteBehavior.SetNull);
// 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);
}
}
@@ -17,7 +17,6 @@ public sealed class GrnConfiguration : IEntityTypeConfiguration<Grn>
builder.Property(g => g.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(g => g.CreatedAt).IsRequired();
builder.Property(g => g.GlJournalNo).HasMaxLength(30);
builder.Property(g => g.RowVersion).IsRowVersion();
builder.HasOne(g => g.PurchaseOrder).WithMany().HasForeignKey(g => g.PoId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(g => g.Vendor).WithMany().HasForeignKey(g => g.VendorId).OnDelete(DeleteBehavior.Restrict);
@@ -22,7 +22,6 @@ public sealed class HrDocumentTypeConfiguration : IEntityTypeConfiguration<HrDoc
.HasDefaultValue(EntityStatus.Active);
builder.Property(t => t.CreatedAt).IsRequired();
builder.Property(t => t.RowVersion).IsRowVersion();
builder.HasIndex(t => t.Status);
}
@@ -51,9 +51,6 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration<Item>
builder.Property(i => i.CreatedAt).IsRequired();
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(i => i.RowVersion).IsRowVersion();
builder.HasOne(i => i.Category)
.WithMany()
.HasForeignKey(i => i.CategoryId)
@@ -0,0 +1,25 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class ItemImageConfiguration : IEntityTypeConfiguration<ItemImage>
{
public void Configure(EntityTypeBuilder<ItemImage> builder)
{
builder.ToTable("item_images");
builder.HasKey(i => i.ItemImageId);
builder.Property(i => i.OriginalFileName).IsRequired().HasMaxLength(260);
builder.Property(i => i.StoredFileName).IsRequired().HasMaxLength(260);
builder.Property(i => i.RelativePath).IsRequired().HasMaxLength(500);
builder.Property(i => i.ContentType).IsRequired().HasMaxLength(200);
builder.Property(i => i.UploadedAt).IsRequired();
builder.HasOne(i => i.Item).WithMany()
.HasForeignKey(i => i.ItemId).OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(i => new { i.ItemId, i.SortOrder });
}
}
@@ -30,9 +30,6 @@ public sealed class ItemTypeConfiguration : IEntityTypeConfiguration<ItemType>
builder.Property(t => t.CreatedAt).IsRequired();
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(t => t.RowVersion).IsRowVersion();
builder.HasIndex(t => t.Status);
}
}
@@ -21,8 +21,6 @@ public sealed class LeaveBalanceConfiguration : IEntityTypeConfiguration<LeaveBa
builder.HasOne(b => b.LeaveType).WithMany()
.HasForeignKey(b => b.LeaveTypeId).OnDelete(DeleteBehavior.Restrict);
builder.Property(b => b.RowVersion).IsRowVersion();
builder.HasIndex(b => new { b.EmployeeId, b.LeaveTypeId, b.Year }).IsUnique();
}
}
@@ -26,7 +26,6 @@ public sealed class LeaveRequestConfiguration : IEntityTypeConfiguration<LeaveRe
.HasForeignKey(r => r.LeaveTypeId).OnDelete(DeleteBehavior.Restrict);
builder.Property(r => r.CreatedAt).IsRequired();
builder.Property(r => r.RowVersion).IsRowVersion();
builder.HasIndex(r => r.EmployeeId);
builder.HasIndex(r => r.Status);
@@ -22,7 +22,6 @@ public sealed class LeaveTypeConfiguration : IEntityTypeConfiguration<LeaveType>
.HasDefaultValue(EntityStatus.Active);
builder.Property(t => t.CreatedAt).IsRequired();
builder.Property(t => t.RowVersion).IsRowVersion();
builder.HasIndex(t => t.Status);
}
@@ -9,13 +9,11 @@ public sealed class NotificationConfigConfiguration : IEntityTypeConfiguration<N
public void Configure(EntityTypeBuilder<NotificationConfig> builder)
{
builder.ToTable("notification_config", t =>
t.HasCheckConstraint("ck_notification_config_singleton", $"\"NotificationConfigId\" = {NotificationConfig.SingletonId}"));
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)
@@ -20,7 +20,7 @@ public sealed class PayrollLineConfiguration : IEntityTypeConfiguration<PayrollL
nameof(PayrollLine.NetSalary)
})
{
builder.Property(money).HasColumnType("numeric(18,2)");
builder.Property(money).HasPrecision(18, 2);
}
builder.HasOne(l => l.Employee).WithMany()
@@ -29,8 +29,6 @@ public sealed class PayrollLineConfiguration : IEntityTypeConfiguration<PayrollL
builder.HasMany(l => l.Components).WithOne(c => c.PayrollLine!)
.HasForeignKey(c => c.PayrollLineId).OnDelete(DeleteBehavior.Cascade);
builder.Property(l => l.RowVersion).IsRowVersion();
builder.HasIndex(l => new { l.PayrollRunId, l.EmployeeId }).IsUnique();
}
}
@@ -23,7 +23,6 @@ public sealed class PayrollRunConfiguration : IEntityTypeConfiguration<PayrollRu
.HasForeignKey(l => l.PayrollRunId).OnDelete(DeleteBehavior.Cascade);
builder.Property(r => r.GeneratedAt).IsRequired();
builder.Property(r => r.RowVersion).IsRowVersion();
builder.HasIndex(r => new { r.PeriodYear, r.PeriodMonth, r.BranchId });
builder.HasIndex(r => r.Status);
@@ -17,7 +17,6 @@ public sealed class PayrollStatutorySettingConfiguration : IEntityTypeConfigurat
builder.Property(s => s.OtMultiplierDefault).HasPrecision(6, 2);
builder.Property(s => s.CreatedAt).IsRequired();
builder.Property(s => s.RowVersion).IsRowVersion();
builder.HasIndex(s => s.EffectiveFrom);
}
@@ -35,7 +34,6 @@ public sealed class TaxSlabConfiguration : IEntityTypeConfiguration<TaxSlab>
builder.Property(s => s.Rate).HasPrecision(6, 4);
builder.Property(s => s.CreatedAt).IsRequired();
builder.Property(s => s.RowVersion).IsRowVersion();
builder.HasIndex(s => s.EffectiveFrom);
}
@@ -15,7 +15,7 @@ public sealed class ProductConfigConfiguration : IEntityTypeConfiguration<Produc
// The column is created as quoted PascalCase ("ConfigId"), so the constraint must
// quote it too — an unquoted config_id would fold to a column that does not exist.
builder.ToTable("product_config", t =>
t.HasCheckConstraint("ck_product_config_singleton", $"\"ConfigId\" = {ProductConfig.SingletonId}"));
t.HasCheckConstraint("ck_product_config_singleton", $"`ConfigId` = {ProductConfig.SingletonId}"));
builder.HasKey(c => c.ConfigId);
@@ -29,6 +29,9 @@ public sealed class ProductConfigConfiguration : IEntityTypeConfiguration<Produc
builder.Property(c => c.SerialNumbersEnabled).IsRequired().HasDefaultValue(true);
builder.Property(c => c.BatchListsEnabled).IsRequired().HasDefaultValue(true);
builder.Property(c => c.WarrantyPeriodsEnabled).IsRequired().HasDefaultValue(true);
builder.Property(c => c.CustomVariantsEnabled).IsRequired().HasDefaultValue(false);
builder.Property(c => c.ItemImagesEnabled).IsRequired().HasDefaultValue(false);
builder.Property(c => c.CustomWarrantyPeriodsEnabled).IsRequired().HasDefaultValue(false);
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(c => c.RowVersion).IsRowVersion();
@@ -32,13 +32,10 @@ public sealed class ProductionTemplateConfiguration : IEntityTypeConfiguration<P
// Same jsonb-as-string mapping as TemplateStage.FieldDefs and AuditLog.ChangeSet: a
// typed/owned mapping would make AuditScribe emit audit rows for the nested entries.
builder.Property(t => t.Annotations).HasColumnType("jsonb");
builder.Property(t => t.Annotations).HasColumnType("longtext");
builder.Property(t => t.CreatedAt).IsRequired();
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(t => t.RowVersion).IsRowVersion();
builder.HasOne(t => t.Creator).WithMany().HasForeignKey(t => t.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(t => t.Status);
@@ -56,7 +53,7 @@ public sealed class TemplateStageConfiguration : IEntityTypeConfiguration<Templa
builder.Property(s => s.RoleLabel).HasMaxLength(60);
builder.Property(s => s.PosX).HasPrecision(18, 4);
builder.Property(s => s.PosY).HasPrecision(18, 4);
builder.Property(s => s.FieldDefs).IsRequired().HasColumnType("jsonb");
builder.Property(s => s.FieldDefs).IsRequired().HasColumnType("longtext");
builder.HasOne(s => s.Template).WithMany(t => t.Stages)
.HasForeignKey(s => s.TemplateId).OnDelete(DeleteBehavior.Cascade);
@@ -135,7 +132,6 @@ public sealed class ProductionRunConfiguration : IEntityTypeConfiguration<Produc
builder.Property(r => r.ScaleFactor).HasPrecision(18, 6);
builder.Property(r => r.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(r => r.CreatedAt).IsRequired();
builder.Property(r => r.RowVersion).IsRowVersion();
builder.HasOne(r => r.Template).WithMany(t => t.Runs)
.HasForeignKey(r => r.TemplateId).OnDelete(DeleteBehavior.Restrict);
@@ -162,9 +158,8 @@ public sealed class RunStageConfiguration : IEntityTypeConfiguration<RunStage>
builder.Property(s => s.PosX).HasPrecision(18, 4);
builder.Property(s => s.PosY).HasPrecision(18, 4);
builder.Property(s => s.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(s => s.FieldDefs).IsRequired().HasColumnType("jsonb");
builder.Property(s => s.FieldValues).HasColumnType("jsonb");
builder.Property(s => s.RowVersion).IsRowVersion();
builder.Property(s => s.FieldDefs).IsRequired().HasColumnType("longtext");
builder.Property(s => s.FieldValues).HasColumnType("longtext");
builder.HasOne(s => s.Run).WithMany(r => r.Stages)
.HasForeignKey(s => s.RunId).OnDelete(DeleteBehavior.Cascade);
@@ -258,7 +253,7 @@ public sealed class RunStageEventConfiguration : IEntityTypeConfiguration<RunSta
builder.Property(e => e.EventType).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(e => e.Note).HasMaxLength(500);
builder.Property(e => e.Payload).HasColumnType("jsonb");
builder.Property(e => e.Payload).HasColumnType("longtext");
builder.Property(e => e.CreatedAt).IsRequired();
builder.HasOne(e => e.Run).WithMany(r => r.Events)
@@ -40,7 +40,6 @@ public sealed class PurchaseInvoiceConfiguration : IEntityTypeConfiguration<Purc
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);
@@ -18,9 +18,6 @@ public sealed class PurchaseOrderConfiguration : IEntityTypeConfiguration<Purcha
.HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(p => p.CreatedAt).IsRequired();
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(p => p.RowVersion).IsRowVersion();
builder.HasOne(p => p.Vendor)
.WithMany()
.HasForeignKey(p => p.VendorId)
@@ -27,7 +27,5 @@ public sealed class RoleConfiguration : IEntityTypeConfiguration<Role>
builder.Property(r => r.CreatedAt).IsRequired();
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(r => r.RowVersion).IsRowVersion();
}
}
@@ -22,7 +22,6 @@ public sealed class SalaryComponentConfiguration : IEntityTypeConfiguration<Sala
.HasDefaultValue(EntityStatus.Active);
builder.Property(c => c.CreatedAt).IsRequired();
builder.Property(c => c.RowVersion).IsRowVersion();
builder.HasIndex(c => c.Status);
}
@@ -0,0 +1,49 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
/// <summary>
/// Configures the singleton sales-configuration row. The check constraint is what makes
/// "singleton" a database guarantee rather than a convention (mirrors BranchConfig).
/// </summary>
public sealed class SalesConfigConfiguration : IEntityTypeConfiguration<SalesConfig>
{
public void Configure(EntityTypeBuilder<SalesConfig> builder)
{
builder.ToTable("sales_config", t =>
t.HasCheckConstraint("ck_sales_config_singleton", $"\"ConfigId\" = {SalesConfig.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.AllowCashCustomer).IsRequired().HasDefaultValue(false);
// Sentinel = the CLR initializer (true), so an explicit false is still written on insert
// instead of being swallowed by the database default.
builder.Property(c => c.AllowFreeItems).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.Property(c => c.AllowDiscounts).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.Property(c => c.AllowOverStockSale).IsRequired().HasDefaultValue(false);
builder.Property(c => c.AllowWarehouseSelection).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.HasOne(c => c.DefaultWarehouse)
.WithMany()
.HasForeignKey(c => c.DefaultWarehouseId)
.OnDelete(DeleteBehavior.SetNull);
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(c => c.RowVersion).IsRowVersion();
builder.HasOne(c => c.CashCustomer)
.WithMany()
.HasForeignKey(c => c.CashCustomerId)
.OnDelete(DeleteBehavior.SetNull);
builder.HasOne(c => c.UpdatedByUser)
.WithMany()
.HasForeignKey(c => c.UpdatedBy)
.OnDelete(DeleteBehavior.Restrict);
}
}
@@ -25,7 +25,6 @@ public sealed class SalesDayEndConfiguration : IEntityTypeConfiguration<SalesDay
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);
@@ -48,7 +48,6 @@ public sealed class SalesInvoiceConfiguration : IEntityTypeConfiguration<SalesIn
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);
@@ -113,6 +112,5 @@ public sealed class SalesInvoiceLineConfiguration : IEntityTypeConfiguration<Sal
.OnDelete(DeleteBehavior.Restrict);
builder.Property(x => x.PriceSource).HasMaxLength(50);
builder.Property(x => x.RowVersion).IsRowVersion();
}
}
@@ -42,7 +42,6 @@ public sealed class SalesSlipConfiguration : IEntityTypeConfiguration<SalesSlip>
builder.Property<decimal>(p).HasPrecision(18, 4);
builder.Property(x => x.CreatedAt).IsRequired();
builder.Property(x => x.RowVersion).IsRowVersion();
builder.HasIndex(x => x.Status);
builder.HasIndex(x => x.SlipDate);
@@ -95,6 +94,5 @@ public sealed class SalesSlipLineConfiguration : IEntityTypeConfiguration<SalesS
.OnDelete(DeleteBehavior.Restrict);
builder.Property(x => x.PriceSource).HasMaxLength(50);
builder.Property(x => x.RowVersion).IsRowVersion();
}
}
@@ -17,7 +17,6 @@ public sealed class StockAdjustmentConfiguration : IEntityTypeConfiguration<Stoc
builder.Property(a => a.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(a => a.CreatedAt).IsRequired();
builder.Property(a => a.GlJournalNo).HasMaxLength(30);
builder.Property(a => a.RowVersion).IsRowVersion();
builder.HasOne(a => a.Warehouse).WithMany().HasForeignKey(a => a.WarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(a => a.ReasonCode).WithMany().HasForeignKey(a => a.ReasonCodeId).OnDelete(DeleteBehavior.Restrict);
@@ -17,7 +17,6 @@ public sealed class StockCountConfiguration : IEntityTypeConfiguration<StockCoun
builder.Property(c => c.CountType).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(c => c.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(c => c.CreatedAt).IsRequired();
builder.Property(c => 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);
@@ -16,7 +16,6 @@ public sealed class StockTransferConfiguration : IEntityTypeConfiguration<StockT
builder.Property(t => t.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(t => t.CreatedAt).IsRequired();
builder.Property(t => t.RowVersion).IsRowVersion();
builder.HasOne(t => t.SrcWarehouse).WithMany().HasForeignKey(t => t.SrcWarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(t => t.DestWarehouse).WithMany().HasForeignKey(t => t.DestWarehouseId).OnDelete(DeleteBehavior.Restrict);
@@ -20,9 +20,6 @@ public sealed class SubCategoryConfiguration : IEntityTypeConfiguration<SubCateg
builder.Property(s => s.CreatedAt).IsRequired();
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(s => s.RowVersion).IsRowVersion();
builder.HasOne(s => s.Category)
.WithMany(c => c.SubCategories)
.HasForeignKey(s => s.CategoryId)
@@ -13,7 +13,6 @@ public sealed class SupportTicketConfiguration : IEntityTypeConfiguration<Suppor
builder.Property(t => t.Subject).IsRequired().HasMaxLength(200);
builder.Property(t => t.Status).IsRequired().HasConversion<string>().HasMaxLength(20);
builder.Property(t => t.RowVersion).IsRowVersion();
builder.HasIndex(t => t.CreatedBy);
builder.HasIndex(t => t.Status);
@@ -24,9 +24,6 @@ public sealed class TaxRateConfiguration : IEntityTypeConfiguration<TaxRate>
builder.Property(t => t.CreatedAt).IsRequired();
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(t => t.RowVersion).IsRowVersion();
builder.HasIndex(t => t.Status);
// Seeded so Item.TaxRateId has something real to point at immediately — codes
@@ -34,9 +31,9 @@ public sealed class TaxRateConfiguration : IEntityTypeConfiguration<TaxRate>
// afterward (percentages are typically jurisdiction-specific).
var seededAt = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
builder.HasData(
new TaxRate { TaxRateId = 1, Code = "STANDARD", Name = "Standard", Percentage = 15m, CreatedAt = seededAt },
new TaxRate { TaxRateId = 2, Code = "ZERO", Name = "Zero-Rated", Percentage = 0m, CreatedAt = seededAt },
new TaxRate { TaxRateId = 3, Code = "EXEMPT", Name = "Exempt", Percentage = 0m, CreatedAt = seededAt }
new TaxRate { TaxRateId = 1, Code = "STANDARD", Name = "Standard", Percentage = 15m, CreatedAt = seededAt, RowVersion = 1 },
new TaxRate { TaxRateId = 2, Code = "ZERO", Name = "Zero-Rated", Percentage = 0m, CreatedAt = seededAt, RowVersion = 1 },
new TaxRate { TaxRateId = 3, Code = "EXEMPT", Name = "Exempt", Percentage = 0m, CreatedAt = seededAt, RowVersion = 1 }
);
}
}
@@ -26,9 +26,6 @@ public sealed class VendorConfiguration : IEntityTypeConfiguration<Vendor>
builder.Property(v => v.CreatedAt).IsRequired();
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(v => v.RowVersion).IsRowVersion();
builder.HasIndex(v => v.Status);
builder.HasOne(v => v.Branch).WithMany()
@@ -0,0 +1,27 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
/// <summary>Custom warranty period master. No relationships — items store months, not a reference.</summary>
public sealed class WarrantyPeriodConfiguration : IEntityTypeConfiguration<WarrantyPeriod>
{
public void Configure(EntityTypeBuilder<WarrantyPeriod> builder)
{
builder.ToTable("warranty_periods", t =>
t.HasCheckConstraint("ck_warranty_periods_months", "\"Months\" BETWEEN 1 AND 600"));
builder.HasKey(p => p.WarrantyPeriodId);
builder.Property(p => p.Months).IsRequired();
builder.HasIndex(p => p.Months).IsUnique();
builder.Property(p => p.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.Property(p => p.CreatedAt).IsRequired();
builder.HasIndex(p => p.Status);
}
}
@@ -22,7 +22,6 @@ public sealed class WorkShiftConfiguration : IEntityTypeConfiguration<WorkShift>
.HasDefaultValue(EntityStatus.Active);
builder.Property(w => w.CreatedAt).IsRequired();
builder.Property(w => w.RowVersion).IsRowVersion();
builder.HasIndex(w => w.Status);
}
+81 -753
View File
@@ -1,15 +1,23 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Infra.Persistence;
/// <summary>
/// Idempotent startup seeding of configurable reference data (docs/10 §B.8.3).
/// Reason codes are seeded at runtime (not via <c>HasData</c>) so the identity
/// sequence advances normally and later admin <c>POST /reason-codes</c> calls
/// cannot collide with seeded ids.
/// Idempotent startup seeding of the reference data the application cannot run without
/// (docs/10 §B.8.3): the default branch, reason codes, item types, and the three singleton
/// config rows. Navigation items, sub-nav items, permissions, tax rates and the `system`
/// user come from <c>HasData</c> in Infra/Persistence/Configurations instead, so the
/// baseline migration emits them as InsertData.
///
/// Reason codes are seeded at runtime (not via <c>HasData</c>) so the identity sequence
/// advances normally and later admin <c>POST /reason-codes</c> calls cannot collide with
/// seeded ids.
///
/// Demo/sample data (warehouses MAIN/SHOP, UOMs, categories, demo items, fabricated stock
/// layers, sample invoices/slips, bundle sales) was removed with the MySQL port: a fresh
/// database is now production-shaped. The e2e suite creates whatever fixtures it needs.
/// </summary>
public static class DataSeeder
{
@@ -41,19 +49,57 @@ public static class DataSeeder
public static async Task SeedAsync(ErpDbContext db, CancellationToken ct = default)
{
// Must come first, and must save on its own: every branch-scoped entity has a
// non-nullable BranchId with a Restrict FK, so nothing branch-scoped can be written
// until this row has a real generated id.
await EnsureDefaultBranchAsync(db, ct);
var dirty = await SeedReasonCodesAsync(db, ct);
dirty |= await SeedItemTypesAsync(db, ct);
// dirty |= await SeedCompanyProfileAsync(db, ct);
dirty |= await SeedProductConfigAsync(db, ct);
dirty |= await SeedBranchConfigAsync(db, ct);
dirty |= await SeedSalesConfigAsync(db, ct);
dirty |= await SeedGrnConfigAsync(db, ct);
dirty |= await SeedNotificationConfigAsync(db, ct);
dirty |= await SeedSalesMastersAsync(db, ct);
dirty |= await SeedSalesStockAsync(db, ct);
dirty |= await SeedSalesAsync(db, ct);
if (dirty) await db.SaveChangesAsync(ct);
}
/// <summary>
/// Creates the deployment's default branch (<see cref="Branch.DefaultCode"/>) if absent.
/// </summary>
/// <remarks>
/// Before the MySQL port this row was inserted by raw SQL inside the BranchIsolationPhase1
/// migration, which the squash to a single baseline deleted — so on a genuinely fresh
/// database there would be no branch at all and the first branch-scoped insert would fail
/// on the FK. It is deliberately NOT <c>HasData</c>: branches are user-editable business
/// data, and an admin renaming this one through the UI would become permanent model drift
/// that EF tries to "correct" on the next migration.
///
/// <see cref="Services.BranchConfigService.ResolveDefaultBranchIdAsync"/> keeps its lazy
/// create as the runtime fallback, but seeding here means it never has to fire mid-request
/// (where it would do a nested SaveChanges inside a caller's UoW transaction).
/// </remarks>
private static async Task<int> EnsureDefaultBranchAsync(ErpDbContext db, CancellationToken ct)
{
var existingId = await db.Branches.AsNoTracking()
.Where(b => b.Code == Branch.DefaultCode)
.Select(b => b.BranchId)
.FirstOrDefaultAsync(ct);
if (existingId != 0) return existingId;
var branch = new Branch
{
Code = Branch.DefaultCode,
Name = Branch.DefaultName,
Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow
};
db.Branches.Add(branch);
await db.SaveChangesAsync(ct);
return branch.BranchId;
}
private static async Task<bool> SeedReasonCodesAsync(ErpDbContext db, CancellationToken ct)
{
var existing = await db.ReasonCodes
@@ -106,7 +152,10 @@ public static class DataSeeder
SellingPricesEnabled = true,
SerialNumbersEnabled = true,
BatchListsEnabled = true,
WarrantyPeriodsEnabled = true
WarrantyPeriodsEnabled = true,
CustomVariantsEnabled = false,
ItemImagesEnabled = false,
CustomWarrantyPeriodsEnabled = false
});
return true;
}
@@ -128,6 +177,28 @@ public static class DataSeeder
return true;
}
/// <summary>
/// Ensures the singleton sales-config row exists. Starts with cash customer sales off, so
/// every sale needs a real customer until an admin opts in from Settings → Configuration → Sales Configuration.
/// </summary>
private static async Task<bool> SeedSalesConfigAsync(ErpDbContext db, CancellationToken ct)
{
if (await db.SalesConfig.AnyAsync(c => c.ConfigId == SalesConfig.SingletonId, ct)) return false;
db.SalesConfig.Add(new SalesConfig { ConfigId = SalesConfig.SingletonId, AllowCashCustomer = false });
return true;
}
/// <summary>Seeds the GRN configuration singleton — warehouse selection on, no default warehouse,
/// so New GRN keeps its warehouse picker until an admin opts in from Settings → Configuration → GRN Configuration.</summary>
private static async Task<bool> SeedGrnConfigAsync(ErpDbContext db, CancellationToken ct)
{
if (await db.GrnConfig.AnyAsync(c => c.ConfigId == GrnConfig.SingletonId, ct)) return false;
db.GrnConfig.Add(new GrnConfig { ConfigId = GrnConfig.SingletonId });
return true;
}
private static async Task<bool> SeedNotificationConfigAsync(ErpDbContext db, CancellationToken ct)
{
if (await db.NotificationConfig.AnyAsync(c => c.NotificationConfigId == NotificationConfig.SingletonId, ct)) return false;
@@ -138,747 +209,4 @@ public static class DataSeeder
});
return true;
}
/// <summary>
/// Seeds a printable company profile with reasonable defaults for invoice headers.
/// These values are intentionally editable later through the API.
/// </summary>
//private static async Task<bool> SeedCompanyProfileAsync(ErpDbContext db, CancellationToken ct)
//{
// if (await db.CompanyProfiles.AnyAsync(c => c.CompanyProfileId == CompanyProfile.SingletonId, ct)) return false;
// db.CompanyProfiles.Add(new CompanyProfile
// {
// CompanyProfileId = CompanyProfile.SingletonId,
// LegalName = "ERP Core Trading (Pvt) Ltd",
// TradeName = "ERP Core Trading",
// TaxRegistrationNo = "TAX-DEFAULT-001",
// VatRegistrationNo = "VAT-DEFAULT-001",
// AddressLine1 = "1 Demo Street",
// City = "Colombo",
// Country = "Sri Lanka",
// Phone = "+94 11 000 0000",
// Email = "accounts@example.com",
// BankName = "Demo Bank",
// BankBranch = "Colombo Main",
// AccountName = "ERP Core Trading (Pvt) Ltd",
// AccountNumber = "000123456789",
// SwiftCode = "DEMO1234",
// FooterNote = "Thank you for your business."
// });
// return true;
//}
/// <summary>
/// Seeds the minimum catalog data required for the sales demo rows to exist.
/// These are safe additive rows and do not alter any existing data.
/// </summary>
private static async Task<bool> SeedSalesMastersAsync(ErpDbContext db, CancellationToken ct)
{
var dirty = false;
dirty |= await SeedWarehousesAsync(db, ct);
dirty |= await SeedUomsAsync(db, ct);
dirty |= await SeedCategoriesAsync(db, ct);
dirty |= await SeedItemsAsync(db, ct);
return dirty;
}
/// <summary>
/// Seeds a simple on-hand FIFO layer for the demo sales item so the sample
/// invoices can be posted without immediately failing stock validation.
/// This keeps the stock-check and posting flows testable on a fresh database.
/// </summary>
private static async Task<bool> SeedSalesStockAsync(ErpDbContext db, CancellationToken ct)
{
var warehouse = await db.Warehouses.AsNoTracking()
.OrderBy(w => w.WarehouseId)
.FirstOrDefaultAsync(ct);
var item = await db.Items.AsNoTracking()
.OrderBy(i => i.ItemId)
.FirstOrDefaultAsync(ct);
var secondItem = await db.Items.AsNoTracking()
.OrderByDescending(i => i.ItemId)
.FirstOrDefaultAsync(ct);
if (warehouse is null || item is null || secondItem is null)
return false;
var existing = await db.StockLayers.AnyAsync(
l => (l.ItemId == item.ItemId || l.ItemId == secondItem.ItemId) && l.WarehouseId == warehouse.WarehouseId && l.QtyRemaining > 0m,
ct);
if (existing) return false;
db.StockLayers.Add(new StockLayer
{
ItemId = item.ItemId,
WarehouseId = warehouse.WarehouseId,
QtyReceived = 100m,
QtyRemaining = 100m,
UnitCost = item.SellingFlatPrice.GetValueOrDefault() > 0m ? item.SellingFlatPrice.GetValueOrDefault() / 2m : 25m,
ReceiptDate = DateTime.UtcNow.AddDays(-7)
});
db.StockLayers.Add(new StockLayer
{
ItemId = secondItem.ItemId,
WarehouseId = warehouse.WarehouseId,
QtyReceived = 5m,
QtyRemaining = 5m,
UnitCost = secondItem.SellingFlatPrice.GetValueOrDefault() > 0m ? secondItem.SellingFlatPrice.GetValueOrDefault() / 2m : 15m,
ReceiptDate = DateTime.UtcNow.AddDays(-6)
});
return true;
}
private static async Task<bool> SeedWarehousesAsync(ErpDbContext db, CancellationToken ct)
{
var existingCodes = await db.Warehouses.Select(w => w.Code).ToListAsync(ct);
var have = existingCodes.ToHashSet(StringComparer.OrdinalIgnoreCase);
var seeds = new[]
{
new Warehouse { Code = "MAIN", Name = "Main Warehouse" },
new Warehouse { Code = "SHOP", Name = "Sales Counter" }
};
var toAdd = seeds.Where(w => !have.Contains(w.Code)).ToList();
if (toAdd.Count == 0) return false;
db.Warehouses.AddRange(toAdd);
return true;
}
private static async Task<bool> SeedUomsAsync(ErpDbContext db, CancellationToken ct)
{
var existingNames = await db.Uoms.Select(u => u.Name).ToListAsync(ct);
var have = existingNames.ToHashSet(StringComparer.OrdinalIgnoreCase);
var seeds = new[]
{
new Uom { Name = "PCS" },
new Uom { Name = "BOX" }
};
var toAdd = seeds.Where(u => !have.Contains(u.Name)).ToList();
if (toAdd.Count == 0) return false;
db.Uoms.AddRange(toAdd);
return true;
}
private static async Task<bool> SeedCategoriesAsync(ErpDbContext db, CancellationToken ct)
{
var existingNames = await db.Categories.Select(c => c.Name).ToListAsync(ct);
var have = existingNames.ToHashSet(StringComparer.OrdinalIgnoreCase);
var seeds = new[]
{
new Category { Name = "General Goods", Status = EntityStatus.Active, CreatedAt = DateTime.UtcNow },
new Category { Name = "Accessories", Status = EntityStatus.Active, CreatedAt = DateTime.UtcNow }
};
var toAdd = seeds.Where(c => !have.Contains(c.Name)).ToList();
if (toAdd.Count == 0) return false;
db.Categories.AddRange(toAdd);
return true;
}
private static async Task<bool> SeedItemsAsync(ErpDbContext db, CancellationToken ct)
{
var existingSkus = await db.Items.Select(i => i.Sku).ToListAsync(ct);
var have = existingSkus.ToHashSet(StringComparer.OrdinalIgnoreCase);
var category = await db.Categories.AsNoTracking()
.OrderBy(c => c.CategoryId)
.FirstOrDefaultAsync(ct);
var uom = await db.Uoms.AsNoTracking()
.OrderBy(u => u.UomId)
.FirstOrDefaultAsync(ct);
if (category is null || uom is null)
return false;
var now = DateTime.UtcNow;
var seeds = new[]
{
new Item
{
Sku = "SKU-DEMO-001",
Name = "Demo Item 1",
Description = "Seeded sample item for sales documents",
CategoryId = category.CategoryId,
BaseUomId = uom.UomId,
StockNature = StockNature.Stocked,
TrackingMode = TrackingMode.None,
SellingFlatPrice = 100m,
Status = EntityStatus.Active,
CreatedAt = now
},
new Item
{
Sku = "SKU-DEMO-002",
Name = "Demo Item 2 (500 ml)",
Description = "Secondary seeded sample item; carries a content size so the "
+ "production content-unit path has a fixture",
CategoryId = category.CategoryId,
BaseUomId = uom.UomId,
StockNature = StockNature.Stocked,
TrackingMode = TrackingMode.None,
SellingFlatPrice = 50m,
ContentQty = 500m,
ContentUnit = MeasureUnit.Ml,
ContentBaseQty = 500m,
ContentBaseUnit = MeasureUnit.Ml,
Status = EntityStatus.Active,
CreatedAt = now
}
};
var toAdd = seeds.Where(i => !have.Contains(i.Sku)).ToList();
if (toAdd.Count == 0) return false;
db.Items.AddRange(toAdd);
return true;
}
/// <summary>
/// Seeds the minimum sales bootstrap data needed for UI/backend development:
/// a couple of customer rows, current-year document counters, and a few draft
/// invoice/slip samples when the required master data already exists.
/// This intentionally never clears or rewrites any existing rows.
/// </summary>
private static async Task<bool> SeedSalesAsync(ErpDbContext db, CancellationToken ct)
{
var dirty = false;
dirty |= await SeedSalesCustomersAsync(db, ct);
dirty |= await SeedSalesSequencesAsync(db, ct);
dirty |= await SeedSampleSalesDocsAsync(db, ct);
try
{
dirty |= await SeedBundleSalesAsync(db, ct);
}
catch
{
// Bundle demo data is best-effort only; never block startup because of seed drift.
}
return dirty;
}
private static async Task<bool> SeedSalesCustomersAsync(ErpDbContext db, CancellationToken ct)
{
var existingCodes = await db.Customers.Select(c => c.CustomerCode).ToListAsync(ct);
var have = existingCodes.ToHashSet(StringComparer.OrdinalIgnoreCase);
var seeds = new[]
{
new Customer
{
CustomerCode = "CUST-WALKIN",
CustomerType = CustomerType.B2C,
Name = "Walk-in Customer",
DisplayName = "Walk-in Customer",
CreditLimit = 0m,
CreditDays = 0,
Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow
},
new Customer
{
CustomerCode = "CUST-DEMO",
CustomerType = CustomerType.B2B,
Name = "Demo Retail Ltd",
DisplayName = "Demo Retail Ltd",
Phone = "+94 11 000 0000",
Email = "sales@example.com",
AddressLine1 = "1 Demo Street",
City = "Colombo",
Country = "Sri Lanka",
TaxRegistrationNo = "VAT-DEMO-001",
CreditLimit = 250000m,
CreditDays = 30,
Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow
}
};
var toAdd = seeds.Where(c => !have.Contains(c.CustomerCode)).ToList();
if (toAdd.Count == 0) return false;
db.Customers.AddRange(toAdd);
return true;
}
private static async Task<bool> SeedSalesSequencesAsync(ErpDbContext db, CancellationToken ct)
{
var year = DateTime.UtcNow.Year;
var existing = await db.NumberSequences
.Where(s => s.Year == year && (s.DocType == DocumentTypes.SalesInvoice || s.DocType == DocumentTypes.SalesSlip || s.DocType == DocumentTypes.BundleSale))
.Select(s => s.DocType)
.ToListAsync(ct);
var have = existing.ToHashSet(StringComparer.OrdinalIgnoreCase);
var seeds = new[]
{
new NumberSequence { DocType = DocumentTypes.SalesInvoice, Year = year, LastNumber = 0 },
new NumberSequence { DocType = DocumentTypes.SalesSlip, Year = year, LastNumber = 0 },
new NumberSequence { DocType = DocumentTypes.BundleSale, Year = year, LastNumber = 0 }
};
var toAdd = seeds.Where(s => !have.Contains(s.DocType)).ToList();
if (toAdd.Count == 0) return false;
db.NumberSequences.AddRange(toAdd);
return true;
}
private static async Task<bool> SeedBundleSalesAsync(ErpDbContext db, CancellationToken ct)
{
if (await db.BundleSaleTemplates.AnyAsync(ct) || await db.BundleSales.AnyAsync(ct))
return false;
var customer = await db.Customers.AsNoTracking()
.OrderBy(c => c.CustomerId)
.FirstOrDefaultAsync(ct);
var warehouse = await db.Warehouses.AsNoTracking()
.OrderBy(w => w.WarehouseId)
.FirstOrDefaultAsync(ct);
var secondaryWarehouse = await db.Warehouses.AsNoTracking()
.OrderByDescending(w => w.WarehouseId)
.FirstOrDefaultAsync(ct);
var items = await db.Items.AsNoTracking()
.OrderBy(i => i.ItemId)
.Take(2)
.ToListAsync(ct);
var uom = await db.Uoms.AsNoTracking()
.OrderBy(u => u.UomId)
.FirstOrDefaultAsync(ct);
var user = await db.Users.AsNoTracking()
.OrderBy(u => u.UserId)
.FirstOrDefaultAsync(ct);
if (customer is null || warehouse is null || secondaryWarehouse is null || items.Count < 2 || uom is null || user is null)
return false;
var now = DateTime.UtcNow;
var template = new BundleSaleTemplate
{
TemplateCode = "BND-DEMO-001",
TemplateName = "Demo Bundle Pack",
Description = "Seeded fixed bundle template for integration testing",
Status = EntityStatus.Active,
CreatedAt = now,
Lines =
[
new BundleSaleTemplateLine
{
ItemId = items[0].ItemId,
WarehouseId = warehouse.WarehouseId,
Qty = 1m,
UnitPrice = items[0].SellingFlatPrice.GetValueOrDefault(),
IncludeInBundle = true,
SortOrder = 1
},
new BundleSaleTemplateLine
{
ItemId = items[1].ItemId,
WarehouseId = warehouse.WarehouseId,
Qty = 1m,
UnitPrice = items[1].SellingFlatPrice.GetValueOrDefault(),
IncludeInBundle = true,
SortOrder = 2
}
]
};
db.BundleSaleTemplates.Add(template);
await db.SaveChangesAsync(ct);
var bundleSales = new[]
{
new BundleSale
{
BundleNo = $"BND-{now:yyyy}-00001",
BundleDate = now.Date.AddDays(-2),
CustomerId = customer.CustomerId,
CustomerSnapshotName = customer.DisplayName ?? customer.Name,
WarehouseId = warehouse.WarehouseId,
CashierUserId = user.UserId,
BundleSaleTemplateId = template.BundleSaleTemplateId,
BundleName = "Demo Bundle Draft",
BundleCode = "BND-DEMO-001",
Status = BundleSaleStatus.Draft,
ComponentSubtotal = items[0].SellingFlatPrice.GetValueOrDefault() + items[1].SellingFlatPrice.GetValueOrDefault(),
BundlePrice = 0m,
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),
Lines =
[
new BundleSaleLine
{
ItemId = items[0].ItemId,
Description = items[0].Name,
Qty = 1m,
WarehouseId = warehouse.WarehouseId,
UnitPrice = items[0].SellingFlatPrice.GetValueOrDefault(),
LineTotal = items[0].SellingFlatPrice.GetValueOrDefault(),
IncludeInBundle = true,
IsComponent = true
},
new BundleSaleLine
{
ItemId = items[1].ItemId,
Description = items[1].Name,
Qty = 1m,
WarehouseId = warehouse.WarehouseId,
UnitPrice = items[1].SellingFlatPrice.GetValueOrDefault(),
LineTotal = items[1].SellingFlatPrice.GetValueOrDefault(),
IncludeInBundle = true,
IsComponent = true
}
]
},
new BundleSale
{
BundleNo = $"BND-{now:yyyy}-00002",
BundleDate = now.Date.AddDays(-1),
CustomerId = customer.CustomerId,
CustomerSnapshotName = customer.DisplayName ?? customer.Name,
WarehouseId = warehouse.WarehouseId,
CashierUserId = user.UserId,
BundleSaleTemplateId = template.BundleSaleTemplateId,
BundleName = "Demo Bundle Posted",
BundleCode = "BND-DEMO-001",
Status = BundleSaleStatus.Posted,
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].SellingFlatPrice.GetValueOrDefault() + items[1].SellingFlatPrice.GetValueOrDefault()) - 10m,
CreatedAt = now.AddDays(-1),
UpdatedAt = now.AddHours(-2),
Lines =
[
new BundleSaleLine
{
ItemId = items[0].ItemId,
Description = items[0].Name,
Qty = 1m,
WarehouseId = warehouse.WarehouseId,
UnitPrice = items[0].SellingFlatPrice.GetValueOrDefault(),
LineTotal = items[0].SellingFlatPrice.GetValueOrDefault(),
IncludeInBundle = true,
IsComponent = true
},
new BundleSaleLine
{
ItemId = items[1].ItemId,
Description = items[1].Name,
Qty = 1m,
WarehouseId = warehouse.WarehouseId,
UnitPrice = items[1].SellingFlatPrice.GetValueOrDefault(),
LineTotal = items[1].SellingFlatPrice.GetValueOrDefault(),
IncludeInBundle = true,
IsComponent = true
}
]
},
new BundleSale
{
BundleNo = $"BND-{now:yyyy}-00003",
BundleDate = now.Date,
CustomerId = customer.CustomerId,
CustomerSnapshotName = customer.DisplayName ?? customer.Name,
WarehouseId = secondaryWarehouse.WarehouseId,
CashierUserId = user.UserId,
BundleSaleTemplateId = template.BundleSaleTemplateId,
BundleName = "Demo Bundle Cancelled",
BundleCode = "BND-DEMO-001",
Status = BundleSaleStatus.Cancelled,
ComponentSubtotal = items[0].SellingFlatPrice.GetValueOrDefault(),
BundlePrice = items[0].SellingFlatPrice.GetValueOrDefault(),
MarginAmount = 0m,
DiscountTotal = 0m,
TaxTotal = 0m,
GrandTotal = items[0].SellingFlatPrice.GetValueOrDefault(),
CreatedAt = now,
UpdatedAt = now,
Lines =
[
new BundleSaleLine
{
ItemId = items[0].ItemId,
Description = items[0].Name,
Qty = 1m,
WarehouseId = secondaryWarehouse.WarehouseId,
UnitPrice = items[0].SellingFlatPrice.GetValueOrDefault(),
LineTotal = items[0].SellingFlatPrice.GetValueOrDefault(),
IncludeInBundle = true,
IsComponent = true
}
]
}
};
db.BundleSales.AddRange(bundleSales);
return true;
}
private static async Task<bool> SeedSampleSalesDocsAsync(ErpDbContext db, CancellationToken ct)
{
if (await db.SalesInvoices.AnyAsync(ct) || await db.SalesSlips.AnyAsync(ct))
return false;
var customer = await db.Customers.AsNoTracking()
.OrderBy(c => c.CustomerId)
.FirstOrDefaultAsync(ct);
var warehouse = await db.Warehouses.AsNoTracking()
.OrderBy(w => w.WarehouseId)
.FirstOrDefaultAsync(ct);
var secondaryWarehouse = await db.Warehouses.AsNoTracking()
.OrderByDescending(w => w.WarehouseId)
.FirstOrDefaultAsync(ct);
var items = await db.Items.AsNoTracking()
.OrderBy(i => i.ItemId)
.Take(2)
.ToListAsync(ct);
var uom = await db.Uoms.AsNoTracking()
.OrderBy(u => u.UomId)
.FirstOrDefaultAsync(ct);
var user = await db.Users.AsNoTracking()
.OrderBy(u => u.UserId)
.FirstOrDefaultAsync(ct);
if (customer is null || warehouse is null || secondaryWarehouse is null || items.Count < 2 || uom is null || user is null)
return false;
var postableItem = items[0];
var shortageItem = items[1];
var today = DateTime.UtcNow.Date;
var createdAt = DateTime.UtcNow.AddDays(-1);
db.SalesInvoices.AddRange(
new SalesInvoice
{
InvoiceNo = $"SI-{today:yyyy}-00001",
InvoiceDate = today.AddDays(-2),
CustomerId = customer.CustomerId,
CustomerSnapshotName = customer.Name,
CustomerSnapshotTaxNo = customer.TaxRegistrationNo,
WarehouseId = warehouse.WarehouseId,
InvoiceType = SalesInvoiceType.B2C,
Status = SalesInvoiceStatus.Draft,
Subtotal = 200m,
DiscountTotal = 0m,
TaxTotal = 0m,
GrandTotal = 200m,
RoundOff = 0m,
NetPayable = 200m,
PaidAmount = 0m,
BalanceAmount = 200m,
CreatedBy = user.UserId,
CreatedAt = createdAt,
Lines =
[
new SalesInvoiceLine
{
ItemId = postableItem.ItemId,
Description = postableItem.Name,
Qty = 2m,
FreeQty = 0m,
WarehouseId = warehouse.WarehouseId,
UnitPrice = 100m,
BaseCost = 0m,
PriceSource = "seed",
DiscountMode = SalesDiscountMode.Percentage,
DiscountPct = 0m,
DiscountAmount = 0m,
NetUnitPrice = 100m,
LineTotal = 200m,
TaxPct = 0m,
TaxAmount = 0m,
IsFreeIssue = false
}
]
},
new SalesInvoice
{
InvoiceNo = $"SI-{today:yyyy}-00002",
InvoiceDate = today.AddDays(-1),
CustomerId = customer.CustomerId,
CustomerSnapshotName = customer.Name,
CustomerSnapshotTaxNo = customer.TaxRegistrationNo,
WarehouseId = secondaryWarehouse.WarehouseId,
InvoiceType = SalesInvoiceType.B2B,
Status = SalesInvoiceStatus.Draft,
Subtotal = 300m,
DiscountTotal = 0m,
TaxTotal = 0m,
GrandTotal = 300m,
RoundOff = 0m,
NetPayable = 300m,
PaidAmount = 0m,
BalanceAmount = 300m,
CreatedBy = user.UserId,
CreatedAt = createdAt,
Lines =
[
new SalesInvoiceLine
{
ItemId = shortageItem.ItemId,
Description = shortageItem.Name,
Qty = 6m,
FreeQty = 0m,
WarehouseId = secondaryWarehouse.WarehouseId,
UnitPrice = 50m,
BaseCost = 0m,
PriceSource = "seed",
DiscountMode = SalesDiscountMode.Percentage,
DiscountPct = 0m,
DiscountAmount = 0m,
NetUnitPrice = 50m,
LineTotal = 300m,
TaxPct = 0m,
TaxAmount = 0m,
IsFreeIssue = false
}
]
},
new SalesInvoice
{
InvoiceNo = $"SI-{today:yyyy}-00003",
InvoiceDate = today,
CustomerId = customer.CustomerId,
CustomerSnapshotName = customer.Name,
CustomerSnapshotTaxNo = customer.TaxRegistrationNo,
WarehouseId = warehouse.WarehouseId,
InvoiceType = SalesInvoiceType.B2C,
Status = SalesInvoiceStatus.Posted,
Subtotal = 100m,
DiscountTotal = 0m,
TaxTotal = 0m,
GrandTotal = 100m,
RoundOff = 0m,
NetPayable = 100m,
PaidAmount = 100m,
BalanceAmount = 0m,
CreatedBy = user.UserId,
CreatedAt = createdAt,
UpdatedAt = DateTime.UtcNow,
Lines =
[
new SalesInvoiceLine
{
ItemId = postableItem.ItemId,
Description = postableItem.Name,
Qty = 1m,
FreeQty = 0m,
WarehouseId = warehouse.WarehouseId,
UnitPrice = 100m,
BaseCost = 0m,
PriceSource = "seed",
DiscountMode = SalesDiscountMode.Percentage,
DiscountPct = 0m,
DiscountAmount = 0m,
NetUnitPrice = 100m,
LineTotal = 100m,
TaxPct = 0m,
TaxAmount = 0m,
IsFreeIssue = false
}
]
}
);
db.SalesSlips.AddRange(
new SalesSlip
{
SlipNo = $"SSL-{today:yyyy}-00001",
SlipDate = today.AddDays(-2),
CustomerId = customer.CustomerId,
CustomerSnapshotName = customer.Name,
WarehouseId = warehouse.WarehouseId,
CashierUserId = user.UserId,
Status = SalesSlipStatus.Draft,
Subtotal = 50m,
DiscountTotal = 0m,
TaxTotal = 0m,
GrandTotal = 50m,
PaidAmount = 0m,
BalanceAmount = 50m,
CreatedAt = createdAt,
Lines =
[
new SalesSlipLine
{
ItemId = postableItem.ItemId,
Description = postableItem.Name,
Qty = 1m,
FreeQty = 0m,
WarehouseId = warehouse.WarehouseId,
UnitPrice = 50m,
BaseCost = 0m,
PriceSource = "seed",
DiscountMode = SalesDiscountMode.Percentage,
DiscountPct = 0m,
DiscountAmount = 0m,
NetUnitPrice = 50m,
LineTotal = 50m,
TaxPct = 0m,
TaxAmount = 0m,
IsFreeIssue = false
}
]
},
new SalesSlip
{
SlipNo = $"SSL-{today:yyyy}-00002",
SlipDate = today.AddDays(-1),
CustomerId = customer.CustomerId,
CustomerSnapshotName = customer.Name,
WarehouseId = secondaryWarehouse.WarehouseId,
CashierUserId = user.UserId,
Status = SalesSlipStatus.Draft,
Subtotal = 150m,
DiscountTotal = 15m,
TaxTotal = 0m,
GrandTotal = 135m,
PaidAmount = 0m,
BalanceAmount = 135m,
CreatedAt = createdAt,
Lines =
[
new SalesSlipLine
{
ItemId = shortageItem.ItemId,
Description = shortageItem.Name,
Qty = 3m,
FreeQty = 0m,
WarehouseId = secondaryWarehouse.WarehouseId,
UnitPrice = 50m,
BaseCost = 0m,
PriceSource = "seed",
DiscountMode = SalesDiscountMode.Percentage,
DiscountPct = 10m,
DiscountAmount = 15m,
NetUnitPrice = 45m,
LineTotal = 135m,
TaxPct = 0m,
TaxAmount = 0m,
IsFreeIssue = false
}
]
}
);
return true;
}
}
@@ -2,6 +2,7 @@ using ERPCore.Domain.Entities;
using ERPCore.Infra.Auth;
using ERPCore.Infra.Persistence.Auditing;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
namespace ERPCore.Infra.Persistence;
@@ -35,6 +36,8 @@ public class ErpDbContext : DbContext
public DbSet<TaxRate> TaxRates => Set<TaxRate>();
public DbSet<Item> Items => Set<Item>();
public DbSet<ItemReorder> ItemReorders => Set<ItemReorder>();
public DbSet<ItemImage> ItemImages => Set<ItemImage>();
public DbSet<WarrantyPeriod> WarrantyPeriods => Set<WarrantyPeriod>();
public DbSet<Vendor> Vendors => Set<Vendor>();
public DbSet<Warehouse> Warehouses => Set<Warehouse>();
public DbSet<Bin> Bins => Set<Bin>();
@@ -43,6 +46,8 @@ public class ErpDbContext : DbContext
public DbSet<ProductConfig> ProductConfig => Set<ProductConfig>();
/// <summary>Singleton row gating whether this deployment uses branches at all.</summary>
public DbSet<BranchConfig> BranchConfig => Set<BranchConfig>();
public DbSet<SalesConfig> SalesConfig => Set<SalesConfig>();
public DbSet<GrnConfig> GrnConfig => Set<GrnConfig>();
/// <summary>Singleton row for notification delivery preferences.</summary>
public DbSet<NotificationConfig> NotificationConfig => Set<NotificationConfig>();
public DbSet<NotificationConfigModule> NotificationConfigModules => Set<NotificationConfigModule>();
@@ -185,6 +190,15 @@ public class ErpDbContext : DbContext
// (Infra/Persistence/Configurations/*).
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ErpDbContext).Assembly);
// Pin the collation on the model. Pomelo emits `CharSet utf8mb4` per table/column but
// no collation, so without this every column silently takes the charset's server
// default — utf8mb4_general_ci on MariaDB — regardless of what the database is set to.
// utf8mb4_unicode_ci is case-insensitive (which is the decided semantics: it keeps the
// 48 EF.Functions.Like searches behaving like the old ILIKE, and makes the uniqueness
// guards consistent with their backing indexes) but uses proper UCA ordering rather
// than general_ci's simplified table. MariaDB has no utf8mb4_0900_ai_ci.
modelBuilder.UseCollation("utf8mb4_unicode_ci");
// Global read-side branch scoping (docs/BRANCH_ISOLATION_PLAN.md). Only for entities
// that carry an explicit BranchId directly — warehouse-inherited entities (Grn,
// StockLayer, StockTransfer, ...) are NOT covered here (would need an extra join in
@@ -198,10 +212,35 @@ public class ErpDbContext : DbContext
modelBuilder.Entity<PurchaseOrder>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
modelBuilder.Entity<PurchaseInvoice>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
// Npgsql requires DateTime values written to `timestamp with time zone` columns
// to have Kind=Utc; dates deserialized from a JSON request body (hire date,
// salary-structure effective date, etc.) come in as Kind=Unspecified and would
// otherwise throw at SaveChanges time. Force Utc kind globally for every
// Optimistic concurrency token. On PostgreSQL this was the virtual `xmin` system
// column via .IsRowVersion(), which the engine maintained for free. MariaDB has no
// equivalent: Pomelo maps .IsRowVersion() on a uint to a plain `int unsigned` column
// marked ValueGeneratedOnAddOrUpdate, so EF expects the *database* to bump it and the
// database never does — every row would sit at 0 and the concurrency check would
// silently always pass. So the column is real, generated by nothing, and incremented
// by BumpRowVersions() in the SaveChanges overrides below.
//
// Applied as a convention rather than 49 hand edits: a loop cannot miss an entity,
// and a missed one is invisible (a non-token column, no error, no protection).
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
var rowVersion = entityType.FindProperty(RowVersionProperty);
if (rowVersion is null || rowVersion.ClrType != typeof(uint)) continue;
rowVersion.IsConcurrencyToken = true;
// ValueGenerated.Never is load-bearing: anything else and EF refuses to write the
// value we compute. Deliberately no HasDefaultValue(0) either — a store default
// makes EF omit the column from INSERTs and treat it as store-generated.
rowVersion.ValueGenerated = ValueGenerated.Never;
rowVersion.IsNullable = false;
}
// MariaDB does not have `timestamp with time zone`; values are stored as datetime(6)
// in UTC. This converter is still load-bearing (do NOT delete it as a Postgres-only
// artifact): dates deserialized from a JSON request body (hire date, salary-structure
// effective date, etc.) arrive as Kind=Unspecified, and MySqlConnector hands values
// back as Kind=Unspecified too, so both directions need pinning. Force Utc kind
// globally for every
// DateTime/DateTime? property rather than remembering to convert at each HRM
// service call site (docs/12-BACKEND-HRM.md — new in Phase 2; Phase 1 never hit
// this because it only ever persisted server-generated DateTime.UtcNow values).
@@ -222,14 +261,60 @@ public class ErpDbContext : DbContext
}
}
/// <summary>
/// Name of the optimistic-concurrency token property. Must stay <c>"RowVersion"</c>:
/// the CLR type <c>uint</c> is what lets <see cref="Common.Http.ETag"/> and every
/// <c>If-Match</c> call site stay unchanged.
/// </summary>
internal const string RowVersionProperty = "RowVersion";
/// <summary>
/// Increments the concurrency token for every inserted or modified entity that carries
/// one. Replaces what PostgreSQL's <c>xmin</c> did automatically.
/// </summary>
private void BumpRowVersions()
{
foreach (var entry in ChangeTracker.Entries())
{
if (entry.State is not (EntityState.Added or EntityState.Modified)) continue;
var meta = entry.Metadata.FindProperty(RowVersionProperty);
if (meta is null || meta.ClrType != typeof(uint)) continue;
var p = entry.Property(RowVersionProperty);
// New rows start at 1, never 0: ETag.TryParse("AAAAAA==") succeeds and yields 0,
// so a client sending the all-zero token (exactly what a default-initialised uint
// produces) would otherwise pass the pre-check on every never-updated row.
// Starting at 1 keeps 0 permanently non-live.
//
// Updates increment OriginalValue, NOT CurrentValue. EF emits
// SET RowVersion = @new WHERE pk = @pk AND RowVersion = @orig
// taking WHERE from OriginalValue and SET from CurrentValue. Basing the new value
// on CurrentValue would break the moment anything else touches the property (a DTO
// mapper, a detached graph attach), producing a value unrelated to the row and an
// intermittent spurious 412 on the happy path.
p.CurrentValue = entry.State == EntityState.Added
? 1u
: (uint)(p.OriginalValue ?? 0u) + 1u;
}
}
// Audit trail (FR-X-02): capture mutations before save (accurate old→new), then
// write the log rows once inserts have their generated keys. A second base save
// persists the logs without re-auditing them.
//
// BumpRowVersions runs AFTER AuditScribe.Capture (which has already serialised each
// change set into an immutable string, so the token bump physically cannot leak into an
// audit payload) and BEFORE the first base save. It is called here rather than from a
// SaveChangesInterceptor precisely because base.Save… is invoked twice below — an
// interceptor would fire on both and double-bump.
public override async Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken ct = default)
{
IReadOnlyList<PendingAudit> pending = _writingAuditLogs
? Array.Empty<PendingAudit>()
: AuditScribe.Capture(ChangeTracker);
BumpRowVersions();
var result = await base.SaveChangesAsync(acceptAllChangesOnSuccess, ct);
if (pending.Count > 0)
{
@@ -252,6 +337,7 @@ public class ErpDbContext : DbContext
IReadOnlyList<PendingAudit> pending = _writingAuditLogs
? Array.Empty<PendingAudit>()
: AuditScribe.Capture(ChangeTracker);
BumpRowVersions();
var result = base.SaveChanges(acceptAllChangesOnSuccess);
if (pending.Count > 0)
{
+13 -3
View File
@@ -1,12 +1,22 @@
using System.Data;
using ERPCore.Infra.Persistence;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Infra.UoW;
/// <summary>
/// EF Core-backed unit of work. Uses the provider execution strategy so the
/// transactional path is safe under connection resiliency / retries.
/// EF Core-backed unit of work. Opens every transaction at READ COMMITTED to match the
/// semantics the code was written against (MariaDB defaults to REPEATABLE READ, where a
/// plain read inside a transaction returns a pinned snapshot while a locking read does not).
/// </summary>
/// <remarks>
/// Do NOT enable <c>EnableRetryOnFailure</c> to "harden" this. The execution strategy is
/// currently non-retrying, so the lambda runs exactly once. With retries on, EF re-runs the
/// lambda without resetting the ChangeTracker, and callers such as
/// <c>ProductionRunService.ApproveStageAsync</c> capture mutable state outside it — a retry
/// would re-insert already-Added entities and duplicate ledger/layer rows, which is precisely
/// the corruption the concurrency token exists to prevent.
/// </remarks>
public sealed class UnitOfWork : IUnitOfWork
{
private readonly ErpDbContext _db;
@@ -29,7 +39,7 @@ public sealed class UnitOfWork : IUnitOfWork
var strategy = _db.Database.CreateExecutionStrategy();
return await strategy.ExecuteAsync(async () =>
{
await using var tx = await _db.Database.BeginTransactionAsync(ct);
await using var tx = await _db.Database.BeginTransactionAsync(IsolationLevel.ReadCommitted, ct);
try
{
var result = await action(ct);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,29 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddItemTypeIsMeasurable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsMeasurable",
table: "item_types",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsMeasurable",
table: "item_types");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,113 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddGrnGlPostingAndPayments : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "BalanceAmount",
table: "grns",
type: "numeric(18,4)",
precision: 18,
scale: 4,
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<string>(
name: "GlJournalNo",
table: "grns",
type: "character varying(30)",
maxLength: 30,
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "GlPostedAt",
table: "grns",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "PaidAmount",
table: "grns",
type: "numeric(18,4)",
precision: 18,
scale: 4,
nullable: false,
defaultValue: 0m);
migrationBuilder.CreateTable(
name: "grn_payments",
columns: table => new
{
GrnPaymentId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
GrnId = table.Column<int>(type: "integer", nullable: false),
Amount = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
PaymentDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
GlBankAccountId = table.Column<long>(type: "bigint", nullable: false),
BankAccountName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Reference = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
GlJournalNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: true),
CreatedBy = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_grn_payments", x => x.GrnPaymentId);
table.ForeignKey(
name: "FK_grn_payments_grns_GrnId",
column: x => x.GrnId,
principalTable: "grns",
principalColumn: "GrnId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_grn_payments_users_CreatedBy",
column: x => x.CreatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_grn_payments_CreatedBy",
table: "grn_payments",
column: "CreatedBy");
migrationBuilder.CreateIndex(
name: "IX_grn_payments_GrnId",
table: "grn_payments",
column: "GrnId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "grn_payments");
migrationBuilder.DropColumn(
name: "BalanceAmount",
table: "grns");
migrationBuilder.DropColumn(
name: "GlJournalNo",
table: "grns");
migrationBuilder.DropColumn(
name: "GlPostedAt",
table: "grns");
migrationBuilder.DropColumn(
name: "PaidAmount",
table: "grns");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,196 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class warrenty : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Warranty",
table: "items",
type: "character varying(20)",
maxLength: 20,
nullable: false,
defaultValue: "NonWarranty");
migrationBuilder.AddColumn<int>(
name: "WarrantyPeriodMonths",
table: "items",
type: "integer",
nullable: true);
migrationBuilder.CreateTable(
name: "grn_line_warranty_numbers",
columns: table => new
{
GrnLineWarrantyNumberId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
GrnLineId = table.Column<int>(type: "integer", nullable: false),
WarrantyNo = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
WarrantyPeriodMonths = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_grn_line_warranty_numbers", x => x.GrnLineWarrantyNumberId);
table.ForeignKey(
name: "FK_grn_line_warranty_numbers_grn_lines_GrnLineId",
column: x => x.GrnLineId,
principalTable: "grn_lines",
principalColumn: "GrnLineId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "sales_returns",
columns: table => new
{
ReturnId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
DocNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
CustomerId = table.Column<int>(type: "integer", nullable: false),
WarehouseId = table.Column<int>(type: "integer", nullable: false),
ReasonCodeId = table.Column<int>(type: "integer", nullable: false),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
CreatedBy = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_sales_returns", x => x.ReturnId);
table.ForeignKey(
name: "FK_sales_returns_customers_CustomerId",
column: x => x.CustomerId,
principalTable: "customers",
principalColumn: "CustomerId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_sales_returns_reason_codes_ReasonCodeId",
column: x => x.ReasonCodeId,
principalTable: "reason_codes",
principalColumn: "ReasonCodeId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_sales_returns_users_CreatedBy",
column: x => x.CreatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_sales_returns_warehouses_WarehouseId",
column: x => x.WarehouseId,
principalTable: "warehouses",
principalColumn: "WarehouseId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "sales_return_lines",
columns: table => new
{
ReturnLineId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
ReturnId = table.Column<int>(type: "integer", nullable: false),
SalesInvoiceLineId = table.Column<int>(type: "integer", nullable: true),
ItemId = table.Column<int>(type: "integer", nullable: false),
Qty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_sales_return_lines", x => x.ReturnLineId);
table.ForeignKey(
name: "FK_sales_return_lines_items_ItemId",
column: x => x.ItemId,
principalTable: "items",
principalColumn: "ItemId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_sales_return_lines_sales_invoice_lines_SalesInvoiceLineId",
column: x => x.SalesInvoiceLineId,
principalTable: "sales_invoice_lines",
principalColumn: "SalesInvoiceLineId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_sales_return_lines_sales_returns_ReturnId",
column: x => x.ReturnId,
principalTable: "sales_returns",
principalColumn: "ReturnId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_grn_line_warranty_numbers_GrnLineId_WarrantyNo",
table: "grn_line_warranty_numbers",
columns: new[] { "GrnLineId", "WarrantyNo" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_sales_return_lines_ItemId",
table: "sales_return_lines",
column: "ItemId");
migrationBuilder.CreateIndex(
name: "IX_sales_return_lines_ReturnId",
table: "sales_return_lines",
column: "ReturnId");
migrationBuilder.CreateIndex(
name: "IX_sales_return_lines_SalesInvoiceLineId",
table: "sales_return_lines",
column: "SalesInvoiceLineId");
migrationBuilder.CreateIndex(
name: "IX_sales_returns_CreatedBy",
table: "sales_returns",
column: "CreatedBy");
migrationBuilder.CreateIndex(
name: "IX_sales_returns_CustomerId",
table: "sales_returns",
column: "CustomerId");
migrationBuilder.CreateIndex(
name: "IX_sales_returns_DocNo",
table: "sales_returns",
column: "DocNo",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_sales_returns_ReasonCodeId",
table: "sales_returns",
column: "ReasonCodeId");
migrationBuilder.CreateIndex(
name: "IX_sales_returns_WarehouseId",
table: "sales_returns",
column: "WarehouseId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "grn_line_warranty_numbers");
migrationBuilder.DropTable(
name: "sales_return_lines");
migrationBuilder.DropTable(
name: "sales_returns");
migrationBuilder.DropColumn(
name: "Warranty",
table: "items");
migrationBuilder.DropColumn(
name: "WarrantyPeriodMonths",
table: "items");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,109 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddSalesDayEnd : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "DayEndId",
table: "sales_slips",
type: "integer",
nullable: true);
migrationBuilder.CreateTable(
name: "sales_day_ends",
columns: table => new
{
SalesDayEndId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
DocNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
CashierUserId = table.Column<int>(type: "integer", nullable: false),
BusinessDate = table.Column<DateOnly>(type: "date", nullable: false),
SlipCount = table.Column<int>(type: "integer", nullable: false),
Subtotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
DiscountTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
TaxTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
GrandTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
CostOfGoodsSold = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
GlJournalNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: true),
GlPostedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
ClosedBy = table.Column<int>(type: "integer", nullable: false),
ClosedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_sales_day_ends", x => x.SalesDayEndId);
table.ForeignKey(
name: "FK_sales_day_ends_users_CashierUserId",
column: x => x.CashierUserId,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_sales_day_ends_users_ClosedBy",
column: x => x.ClosedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_sales_slips_DayEndId",
table: "sales_slips",
column: "DayEndId");
migrationBuilder.CreateIndex(
name: "IX_sales_day_ends_CashierUserId_BusinessDate",
table: "sales_day_ends",
columns: new[] { "CashierUserId", "BusinessDate" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_sales_day_ends_ClosedBy",
table: "sales_day_ends",
column: "ClosedBy");
migrationBuilder.CreateIndex(
name: "IX_sales_day_ends_DocNo",
table: "sales_day_ends",
column: "DocNo",
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_sales_slips_sales_day_ends_DayEndId",
table: "sales_slips",
column: "DayEndId",
principalTable: "sales_day_ends",
principalColumn: "SalesDayEndId",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_sales_slips_sales_day_ends_DayEndId",
table: "sales_slips");
migrationBuilder.DropTable(
name: "sales_day_ends");
migrationBuilder.DropIndex(
name: "IX_sales_slips_DayEndId",
table: "sales_slips");
migrationBuilder.DropColumn(
name: "DayEndId",
table: "sales_slips");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,61 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddReturnGlPosting : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "GlJournalNo",
table: "sales_returns",
type: "character varying(30)",
maxLength: 30,
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "GlPostedAt",
table: "sales_returns",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "GlJournalNo",
table: "purchase_returns",
type: "character varying(30)",
maxLength: 30,
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "GlPostedAt",
table: "purchase_returns",
type: "timestamp with time zone",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "GlJournalNo",
table: "sales_returns");
migrationBuilder.DropColumn(
name: "GlPostedAt",
table: "sales_returns");
migrationBuilder.DropColumn(
name: "GlJournalNo",
table: "purchase_returns");
migrationBuilder.DropColumn(
name: "GlPostedAt",
table: "purchase_returns");
}
}
}
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More