adding complete db migrations from posgres

This commit is contained in:
2026-10-05 15:57:55 +05:30
parent 9d710349c2
commit 65f58c4000
113 changed files with 24033 additions and 1007 deletions
@@ -106,7 +106,7 @@ public sealed class AdjustmentService : IAdjustmentService
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(a => EF.Functions.ILike(a.DocNo, $"%{term}%"));
q = q.Where(a => EF.Functions.Like(a.DocNo, $"%{term}%"));
}
if (warehouseId is not null) q = q.Where(a => a.WarehouseId == warehouseId);
// Warehouses are shared across branches — the adjustment carries its own owning BranchId.
@@ -18,8 +18,6 @@ namespace ERPCore.Services;
/// </summary>
public sealed class BranchConfigService : IBranchConfigService
{
private const string DefaultBranchCode = "BR-000";
private const string DefaultBranchName = "Main Branch";
private readonly IRepository<BranchConfig> _config;
private readonly IRepository<Branch> _branches;
@@ -116,10 +114,10 @@ public sealed class BranchConfigService : IBranchConfigService
public async Task<int> ResolveDefaultBranchIdAsync(CancellationToken ct = default)
{
var existingId = await _branches.Query().AsNoTracking()
.Where(b => b.Code == DefaultBranchCode).Select(b => b.BranchId).FirstOrDefaultAsync(ct);
.Where(b => b.Code == Branch.DefaultCode).Select(b => b.BranchId).FirstOrDefaultAsync(ct);
if (existingId != 0) return existingId;
var branch = new Branch { Code = DefaultBranchCode, Name = DefaultBranchName, Status = EntityStatus.Active, CreatedAt = DateTime.UtcNow };
var branch = new Branch { Code = Branch.DefaultCode, Name = Branch.DefaultName, Status = EntityStatus.Active, CreatedAt = DateTime.UtcNow };
await _branches.AddAsync(branch, ct);
await _uow.SaveChangesAsync(ct);
return branch.BranchId;
+1 -1
View File
@@ -32,7 +32,7 @@ public sealed class BrandService : IBrandService
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(b => EF.Functions.ILike(b.Name, $"%{term}%"));
q = q.Where(b => EF.Functions.Like(b.Name, $"%{term}%"));
}
if (status is not null) q = q.Where(b => b.Status == status);
@@ -62,7 +62,7 @@ public sealed class BundleSaleService : IBundleSaleService
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(x => EF.Functions.ILike(x.TemplateCode, $"%{term}%") || EF.Functions.ILike(x.TemplateName, $"%{term}%") || EF.Functions.ILike(x.Description ?? "", $"%{term}%"));
q = q.Where(x => EF.Functions.Like(x.TemplateCode, $"%{term}%") || EF.Functions.Like(x.TemplateName, $"%{term}%") || EF.Functions.Like(x.Description ?? "", $"%{term}%"));
}
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(x => x.BundleSaleTemplateId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
@@ -130,7 +130,7 @@ public sealed class BundleSaleService : IBundleSaleService
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(x => EF.Functions.ILike(x.BundleNo, $"%{term}%") || EF.Functions.ILike(x.BundleName, $"%{term}%") || EF.Functions.ILike(x.BundleCode, $"%{term}%"));
q = q.Where(x => EF.Functions.Like(x.BundleNo, $"%{term}%") || EF.Functions.Like(x.BundleName, $"%{term}%") || EF.Functions.Like(x.BundleCode, $"%{term}%"));
}
if (status is not null) q = q.Where(x => x.Status == status);
if (customerId is not null) q = q.Where(x => x.CustomerId == customerId);
@@ -181,7 +181,6 @@ public sealed class BundleSaleService : IBundleSaleService
Recalculate(bundle, request.BundlePrice);
bundle.BundleCode = $"{bundle.BundleNo}-B";
await _bundles.AddAsync(bundle, ct);
bundle.ConcurrencyStamp = 1;
await _uow.SaveChangesAsync(ct);
return Map(bundle);
}
@@ -206,7 +205,6 @@ public sealed class BundleSaleService : IBundleSaleService
foreach (var line in await BuildLinesAsync(template, request.WarehouseId, request.Lines, ct)) bundle.Lines.Add(line);
Recalculate(bundle, request.BundlePrice);
bundle.UpdatedAt = DateTime.UtcNow;
bundle.ConcurrencyStamp++;
await _uow.SaveChangesAsync(ct);
return Map(bundle);
}
@@ -227,7 +225,6 @@ public sealed class BundleSaleService : IBundleSaleService
throw new ConflictException($"Bundle sale {bundleSaleId} is {bundle.Status} and cannot be cancelled.");
bundle.Status = BundleSaleStatus.Cancelled;
bundle.UpdatedAt = DateTime.UtcNow;
bundle.ConcurrencyStamp++;
await _uow.SaveChangesAsync(ct);
return Map(bundle);
}
+2 -2
View File
@@ -41,7 +41,7 @@ public sealed class CategoryService : ICategoryService
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(c => EF.Functions.ILike(c.Name, $"%{term}%"));
q = q.Where(c => EF.Functions.Like(c.Name, $"%{term}%"));
}
if (status is not null) q = q.Where(c => c.Status == status);
@@ -123,7 +123,7 @@ public sealed class CategoryService : ICategoryService
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(s => EF.Functions.ILike(s.Name, $"%{term}%"));
q = q.Where(s => EF.Functions.Like(s.Name, $"%{term}%"));
}
if (status is not null) q = q.Where(s => s.Status == status);
+1 -1
View File
@@ -113,7 +113,7 @@ public sealed class CountService : ICountService
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(c => EF.Functions.ILike(c.DocNo, $"%{term}%"));
q = q.Where(c => EF.Functions.Like(c.DocNo, $"%{term}%"));
}
if (status is not null) q = q.Where(c => c.Status == status);
if (warehouseId is not null) q = q.Where(c => c.WarehouseId == warehouseId);
+12 -4
View File
@@ -54,9 +54,9 @@ public sealed class CustomerService : ICustomerService
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(c => EF.Functions.ILike(c.Name, $"%{term}%")
|| EF.Functions.ILike(c.CustomerCode, $"%{term}%")
|| (c.DisplayName != null && EF.Functions.ILike(c.DisplayName, $"%{term}%")));
q = q.Where(c => EF.Functions.Like(c.Name, $"%{term}%")
|| EF.Functions.Like(c.CustomerCode, $"%{term}%")
|| (c.DisplayName != null && EF.Functions.Like(c.DisplayName, $"%{term}%")));
}
if (status is not null) q = q.Where(c => c.Status == status);
if (customerType is not null) q = q.Where(c => c.CustomerType == customerType);
@@ -172,7 +172,15 @@ public sealed class CustomerService : ICustomerService
customer.BranchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
customer.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The customer was modified by another request.", 412);
}
return new ETagged<CustomerDto>(Map(customer), customer.RowVersion);
}
@@ -40,28 +40,28 @@ public sealed class GlobalSearchService : IGlobalSearchService
// Sequential, not parallel: these share one scoped DbContext, which EF Core
// does not allow concurrent operations against.
var items = await _items.Query().AsNoTracking()
.Where(i => EF.Functions.ILike(i.Sku, pattern) || EF.Functions.ILike(i.Name, pattern))
.Where(i => EF.Functions.Like(i.Sku, pattern) || EF.Functions.Like(i.Name, pattern))
.OrderBy(i => i.Name)
.Take(takePerType)
.Select(i => new GlobalSearchResultDto(GlobalSearchResultType.Item, i.ItemId, i.Sku, i.Name, i.Sku))
.ToListAsync(ct);
var pos = await _purchaseOrders.Query().AsNoTracking()
.Where(p => EF.Functions.ILike(p.DocNo, pattern))
.Where(p => EF.Functions.Like(p.DocNo, pattern))
.OrderByDescending(p => p.PoId)
.Take(takePerType)
.Select(p => new GlobalSearchResultDto(GlobalSearchResultType.PurchaseOrder, p.PoId, p.DocNo, p.DocNo, p.Status.ToString()))
.ToListAsync(ct);
var invoices = await _salesSlips.Query().AsNoTracking()
.Where(s => EF.Functions.ILike(s.SlipNo, pattern) || EF.Functions.ILike(s.CustomerSnapshotName, pattern))
.Where(s => EF.Functions.Like(s.SlipNo, pattern) || EF.Functions.Like(s.CustomerSnapshotName, pattern))
.OrderByDescending(s => s.SalesSlipId)
.Take(takePerType)
.Select(s => new GlobalSearchResultDto(GlobalSearchResultType.Invoice, s.SalesSlipId, s.SlipNo, s.SlipNo, s.CustomerSnapshotName))
.ToListAsync(ct);
var employees = await _employees.Query().AsNoTracking()
.Where(e => EF.Functions.ILike(e.FullName, pattern) || EF.Functions.ILike(e.EmployeeCode, pattern))
.Where(e => EF.Functions.Like(e.FullName, pattern) || EF.Functions.Like(e.EmployeeCode, pattern))
.OrderBy(e => e.FullName)
.Take(takePerType)
.Select(e => new GlobalSearchResultDto(GlobalSearchResultType.Employee, e.EmployeeId, e.EmployeeCode, e.FullName, e.EmployeeCode))
+1 -1
View File
@@ -88,7 +88,7 @@ public sealed class GrnService : IGrnService
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(g => EF.Functions.ILike(g.DocNo, $"%{term}%"));
q = q.Where(g => EF.Functions.Like(g.DocNo, $"%{term}%"));
}
if (status is not null) q = q.Where(g => g.Status == status);
if (poId is not null) q = q.Where(g => g.PoId == poId);
@@ -29,7 +29,7 @@ public sealed class BranchService : IBranchService
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(b => EF.Functions.ILike(b.Name, $"%{term}%") || EF.Functions.ILike(b.Code, $"%{term}%"));
q = q.Where(b => EF.Functions.Like(b.Name, $"%{term}%") || EF.Functions.Like(b.Code, $"%{term}%"));
}
if (status is not null) q = q.Where(b => b.Status == status);
@@ -34,7 +34,7 @@ public sealed class DepartmentService : IDepartmentService
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(d => EF.Functions.ILike(d.Name, $"%{term}%") || EF.Functions.ILike(d.Code, $"%{term}%"));
q = q.Where(d => EF.Functions.Like(d.Name, $"%{term}%") || EF.Functions.Like(d.Code, $"%{term}%"));
}
if (status is not null) q = q.Where(d => d.Status == status);
@@ -29,7 +29,7 @@ public sealed class DesignationService : IDesignationService
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(d => EF.Functions.ILike(d.Name, $"%{term}%") || EF.Functions.ILike(d.Code, $"%{term}%"));
q = q.Where(d => EF.Functions.Like(d.Name, $"%{term}%") || EF.Functions.Like(d.Code, $"%{term}%"));
}
if (status is not null) q = q.Where(d => d.Status == status);
@@ -70,7 +70,7 @@ public sealed class EmployeeService : IEmployeeService
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(e => EF.Functions.ILike(e.FullName, $"%{term}%") || EF.Functions.ILike(e.EmployeeCode, $"%{term}%"));
q = q.Where(e => EF.Functions.Like(e.FullName, $"%{term}%") || EF.Functions.Like(e.EmployeeCode, $"%{term}%"));
}
if (status is not null) q = q.Where(e => e.Status == status);
if (departmentId is not null) q = q.Where(e => e.DepartmentId == departmentId);
@@ -34,7 +34,7 @@ public sealed class EmployeeUserLinkService : IEmployeeUserLinkService
{
var term = email.Trim();
var employee = await _employees.Query().AsNoTracking()
.Where(e => e.UserId == null && e.Email != null && EF.Functions.ILike(e.Email, term))
.Where(e => e.UserId == null && e.Email != null && e.Email == term)
.Select(e => new EmployeeMatchDto(e.EmployeeId, e.EmployeeCode, e.FullName, e.Email!))
.FirstOrDefaultAsync(ct);
return employee;
@@ -46,7 +46,7 @@ public sealed class EmployeeUserLinkService : IEmployeeUserLinkService
var linkedUserIds = _employees.Query().Where(e => e.UserId != null).Select(e => e.UserId!.Value);
var user = await _users.Query().AsNoTracking()
.Where(u => u.Email != null && EF.Functions.ILike(u.Email, term) && !linkedUserIds.Contains(u.UserId))
.Where(u => u.Email != null && u.Email == term && !linkedUserIds.Contains(u.UserId))
.Select(u => new UserMatchDto(u.UserId, u.Username, u.DisplayName, u.Email!))
.FirstOrDefaultAsync(ct);
return user;
@@ -29,7 +29,7 @@ public sealed class EmploymentTypeService : IEmploymentTypeService
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(e => EF.Functions.ILike(e.Name, $"%{term}%") || EF.Functions.ILike(e.Code, $"%{term}%"));
q = q.Where(e => EF.Functions.Like(e.Name, $"%{term}%") || EF.Functions.Like(e.Code, $"%{term}%"));
}
if (status is not null) q = q.Where(e => e.Status == status);
@@ -29,7 +29,7 @@ public sealed class HrDocumentTypeService : IHrDocumentTypeService
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(t => EF.Functions.ILike(t.Name, $"%{term}%") || EF.Functions.ILike(t.Code, $"%{term}%"));
q = q.Where(t => EF.Functions.Like(t.Name, $"%{term}%") || EF.Functions.Like(t.Code, $"%{term}%"));
}
if (status is not null) q = q.Where(t => t.Status == status);
@@ -46,7 +46,7 @@ public sealed class LeaveRequestService : ILeaveRequestService
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(r => EF.Functions.ILike(r.DocNo, $"%{term}%") || EF.Functions.ILike(r.Employee!.FullName, $"%{term}%"));
q = q.Where(r => EF.Functions.Like(r.DocNo, $"%{term}%") || EF.Functions.Like(r.Employee!.FullName, $"%{term}%"));
}
var total = await q.CountAsync(ct);
@@ -29,7 +29,7 @@ public sealed class LeaveTypeService : ILeaveTypeService
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(t => EF.Functions.ILike(t.Name, $"%{term}%") || EF.Functions.ILike(t.Code, $"%{term}%"));
q = q.Where(t => EF.Functions.Like(t.Name, $"%{term}%") || EF.Functions.Like(t.Code, $"%{term}%"));
}
if (status is not null) q = q.Where(t => t.Status == status);
@@ -29,7 +29,7 @@ public sealed class SalaryComponentService : ISalaryComponentService
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(c => EF.Functions.ILike(c.Name, $"%{term}%") || EF.Functions.ILike(c.Code, $"%{term}%"));
q = q.Where(c => EF.Functions.Like(c.Name, $"%{term}%") || EF.Functions.Like(c.Code, $"%{term}%"));
}
if (status is not null) q = q.Where(c => c.Status == status);
@@ -32,7 +32,7 @@ public sealed class WorkShiftService : IWorkShiftService
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(w => EF.Functions.ILike(w.Name, $"%{term}%") || EF.Functions.ILike(w.Code, $"%{term}%"));
q = q.Where(w => EF.Functions.Like(w.Name, $"%{term}%") || EF.Functions.Like(w.Code, $"%{term}%"));
}
if (status is not null) q = q.Where(w => w.Status == status);
+2 -2
View File
@@ -88,7 +88,7 @@ public sealed class ItemService : IItemService
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(i => EF.Functions.ILike(i.Sku, $"%{term}%") || EF.Functions.ILike(i.Name, $"%{term}%"));
q = q.Where(i => EF.Functions.Like(i.Sku, $"%{term}%") || EF.Functions.Like(i.Name, $"%{term}%"));
}
if (status is not null) q = q.Where(i => i.Status == status);
if (categoryId is not null) q = q.Where(i => i.CategoryId == categoryId);
@@ -122,7 +122,7 @@ public sealed class ItemService : IItemService
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(i => EF.Functions.ILike(i.Sku, $"%{term}%") || EF.Functions.ILike(i.Name, $"%{term}%"));
q = q.Where(i => EF.Functions.Like(i.Sku, $"%{term}%") || EF.Functions.Like(i.Name, $"%{term}%"));
}
if (status is not null) q = q.Where(i => i.Status == status);
if (categoryId is not null) q = q.Where(i => i.CategoryId == categoryId);
+1 -1
View File
@@ -34,7 +34,7 @@ public sealed class ItemTypeService : IItemTypeService
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(t => EF.Functions.ILike(t.Name, $"%{term}%"));
q = q.Where(t => EF.Functions.Like(t.Name, $"%{term}%"));
}
if (status is not null) q = q.Where(t => t.Status == status);
@@ -9,16 +9,30 @@ using Microsoft.EntityFrameworkCore.Storage;
namespace ERPCore.Services;
/// <summary>
/// Atomic document-number issuer. Uses a single <c>INSERT … ON CONFLICT … DO UPDATE
/// … RETURNING</c> so concurrent issues for the same (docType, branchId, year) cannot get
/// the same number (the row is locked for the duration of the upsert). Runs as a direct
/// ADO.NET command enlisted in the DbContext's current transaction so it commits or
/// rolls back with the document (FR-X-03). EF's <c>SqlQuery</c> is avoided here
/// because it wraps the statement in a subquery, which PostgreSQL disallows for a
/// data-modifying statement. Numbering is per-branch (docs/BRANCH_ISOLATION_PLAN.md §5):
/// the sequence key is (doc_type, branch_id, year) and the format includes the branch code,
/// e.g. <c>PO-BR-001-2026-00042</c>.
/// Atomic document-number issuer. Uses a single <c>INSERT … ON DUPLICATE KEY UPDATE …
/// RETURNING</c> so concurrent issues for the same (docType, branchId, year) cannot get
/// the same number (the upsert holds an exclusive lock on the row until commit). Runs as a
/// direct ADO.NET command enlisted in the DbContext's current transaction so it commits or
/// rolls back with the document (FR-X-03). EF's <c>SqlQuery</c> is avoided here because it
/// wraps the statement in a subquery, which is not valid for a data-modifying statement.
/// Numbering is per-branch (docs/BRANCH_ISOLATION_PLAN.md §5): the sequence key is
/// (doc_type, branch_id, year) and the format includes the branch code, e.g.
/// <c>PO-BR-001-2026-00042</c>.
/// </summary>
/// <remarks>
/// MariaDB 10.5+ supports <c>RETURNING</c> on an upsert, so this stays a single atomic
/// statement exactly as the PostgreSQL version was — which is what keeps the callers that
/// run outside a UoW transaction safe. Do NOT "optimise" this into the one-statement
/// <c>LAST_INSERT_ID()</c> trick: this table has an AUTO_INCREMENT surrogate key, so on the
/// insert branch the engine's own auto-increment value clobbers the explicit one and the
/// first document of a (docType, branch, year) would be numbered with the sequence table's
/// PK. Splitting it into INSERT-then-SELECT is also wrong here, because the non-transacted
/// callers would then race. Side note: the surrogate AUTO_INCREMENT burns a value on the
/// duplicate path, so <c>sequence_id</c> has gaps — harmless, do not "fix" it.
/// <c>ON DUPLICATE KEY UPDATE</c> fires on any unique-key collision; today the only unique
/// keys are the never-supplied PK and IX(doc_type, branch_id, year), so it is unambiguous.
/// Adding another unique index to this table would silently change which row gets updated.
/// </remarks>
public sealed class NumberSequenceService : INumberSequenceService
{
private readonly ErpDbContext _db;
@@ -53,11 +67,10 @@ public sealed class NumberSequenceService : INumberSequenceService
await using var cmd = conn.CreateCommand();
cmd.Transaction = _db.Database.CurrentTransaction?.GetDbTransaction();
cmd.CommandText = """
INSERT INTO number_sequences (doc_type, branch_id, year, last_number)
INSERT INTO `number_sequences` (`doc_type`, `branch_id`, `year`, `last_number`)
VALUES (@docType, @branchId, @year, 1)
ON CONFLICT (doc_type, branch_id, year)
DO UPDATE SET last_number = number_sequences.last_number + 1
RETURNING last_number;
ON DUPLICATE KEY UPDATE `last_number` = `last_number` + 1
RETURNING `last_number`;
""";
AddParam(cmd, "docType", docType);
AddParam(cmd, "branchId", branchId);
@@ -73,7 +73,7 @@ public sealed class ProductionRunService : IProductionRunService
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(r => EF.Functions.ILike(r.DocNo, $"%{term}%"));
q = q.Where(r => EF.Functions.Like(r.DocNo, $"%{term}%"));
}
if (status is not null) q = q.Where(r => r.Status == status);
@@ -342,7 +342,7 @@ public sealed class ProductionRunService : IProductionRunService
});
}
await _uow.ExecuteInTransactionAsync(async token =>
await SaveGuardingConcurrencyAsync(async token =>
{
// Must be inside the transaction — NumberSequenceService enlists in it.
run.DocNo = await _numbers.NextAsync(DocumentTypes.Production, token);
@@ -363,7 +363,7 @@ public sealed class ProductionRunService : IProductionRunService
{
RunStage? stage = null;
await _uow.ExecuteInTransactionAsync(async token =>
await SaveGuardingConcurrencyAsync(async token =>
{
var run = await LoadGraphQuery().FirstOrDefaultAsync(r => r.RunId == runId, token)
?? throw new NotFoundException($"Production run {runId} was not found.");
@@ -546,7 +546,7 @@ public sealed class ProductionRunService : IProductionRunService
var consumed = new List<ConsumedInputDto>();
var ledgerRefs = new List<StockLedger>();
await _uow.ExecuteInTransactionAsync(async token =>
await SaveGuardingConcurrencyAsync(async token =>
{
var (run, stage) = await LoadForActionAsync(runId, runStageId, token);
@@ -607,7 +607,7 @@ public sealed class ProductionRunService : IProductionRunService
public async Task<RunStageDto> CompleteStageAsync(
int runId, int runStageId, CompleteStageRequest request, CancellationToken ct = default)
{
await _uow.ExecuteInTransactionAsync(async token =>
await SaveGuardingConcurrencyAsync(async token =>
{
var (run, stage) = await LoadForActionAsync(runId, runStageId, token);
@@ -677,7 +677,7 @@ public sealed class ProductionRunService : IProductionRunService
CostPoolDto? costPool = null;
var runStatus = ProductionRunStatus.InProgress;
await _uow.ExecuteInTransactionAsync(async token =>
await SaveGuardingConcurrencyAsync(async token =>
{
var (run, stage) = await LoadForActionAsync(runId, runStageId, token);
@@ -756,7 +756,7 @@ public sealed class ProductionRunService : IProductionRunService
{
var transfers = new List<TransferDto>();
await _uow.ExecuteInTransactionAsync(async token =>
await SaveGuardingConcurrencyAsync(async token =>
{
var (run, stage) = await LoadForActionAsync(runId, runStageId, token);
@@ -785,7 +785,7 @@ public sealed class ProductionRunService : IProductionRunService
decimal unitCost = 0, returnValue = 0, returnedQty = 0;
CostPoolDto pool = new(0, 0, 0);
await _uow.ExecuteInTransactionAsync(async token =>
await SaveGuardingConcurrencyAsync(async token =>
{
var run = await LoadGraphQuery().FirstOrDefaultAsync(r => r.RunId == runId, token)
?? throw new NotFoundException($"Production run {runId} was not found.");
@@ -856,7 +856,7 @@ public sealed class ProductionRunService : IProductionRunService
{
var pulled = new List<PulledBackDto>();
await _uow.ExecuteInTransactionAsync(async token =>
await SaveGuardingConcurrencyAsync(async token =>
{
var (run, stage) = await LoadForActionAsync(runId, runStageId, token);
@@ -930,7 +930,7 @@ public sealed class ProductionRunService : IProductionRunService
{
var reworkNumber = 0;
await _uow.ExecuteInTransactionAsync(async token =>
await SaveGuardingConcurrencyAsync(async token =>
{
var (run, stage) = await LoadForActionAsync(runId, runStageId, token);
@@ -1026,7 +1026,7 @@ public sealed class ProductionRunService : IProductionRunService
var writeOffs = new List<ScrapWriteOffDto>();
var ledgerRefs = new List<StockLedger>();
await _uow.ExecuteInTransactionAsync(async token =>
await SaveGuardingConcurrencyAsync(async token =>
{
var run = await LoadGraphQuery().FirstOrDefaultAsync(r => r.RunId == runId, token)
?? throw new NotFoundException($"Production run {runId} was not found.");
@@ -1297,6 +1297,26 @@ public sealed class ProductionRunService : IProductionRunService
$"Required field(s) not provided for stage '{stage.Name}': {string.Join(", ", missing)}.", 400);
}
/// <summary>
/// Runs <paramref name="work"/> in a transaction, translating a lost optimistic-concurrency
/// race (two users acting on the same run or stage) into a 412 rather than a bare 500.
/// The catch has to sit outside the lambda: <see cref="IUnitOfWork.ExecuteInTransactionAsync{T}"/>
/// issues its own <c>SaveChangesAsync</c> after the lambda returns, so a try inside it would
/// miss the save that actually writes the <c>RowVersion</c> check.
/// </summary>
private async Task SaveGuardingConcurrencyAsync(Func<CancellationToken, Task> work, CancellationToken ct)
{
try
{
await _uow.ExecuteInTransactionAsync(work, ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ConcurrencyConflict,
"The production run or the stock it uses was modified by another request.", 412);
}
}
private async Task EnsureProductionReasonAsync(int reasonCodeId, CancellationToken ct)
{
var reason = await _reasonCodes.Query().AsNoTracking()
@@ -56,7 +56,7 @@ public sealed class ProductionTemplateService : IProductionTemplateService
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(t => EF.Functions.ILike(t.Code, $"%{term}%") || EF.Functions.ILike(t.Name, $"%{term}%"));
q = q.Where(t => EF.Functions.Like(t.Code, $"%{term}%") || EF.Functions.Like(t.Name, $"%{term}%"));
}
if (status is not null) q = q.Where(t => t.Status == status);
@@ -192,7 +192,7 @@ public sealed class ProductionTemplateService : IProductionTemplateService
if (await _templates.Query().AnyAsync(t => t.Code == code && t.TemplateId != templateId, ct))
throw new ConflictException($"A production template with code '{code}' already exists.");
await _uow.ExecuteInTransactionAsync(async token =>
await SaveGuardingConcurrencyAsync(async token =>
{
var template = await LoadGraphQuery().FirstOrDefaultAsync(t => t.TemplateId == templateId, token)
?? throw new NotFoundException($"Production template {templateId} was not found.");
@@ -478,6 +478,26 @@ public sealed class ProductionTemplateService : IProductionTemplateService
}
}
/// <summary>
/// Runs <paramref name="work"/> in a transaction, translating a lost optimistic-concurrency
/// race into the same 412 the <c>RowVersion</c> pre-check returns. The catch has to sit
/// outside the lambda: <see cref="IUnitOfWork.ExecuteInTransactionAsync{T}"/> issues its own
/// <c>SaveChangesAsync</c> after the lambda returns, so a try inside it would miss the save
/// that actually writes the token check.
/// </summary>
private async Task SaveGuardingConcurrencyAsync(Func<CancellationToken, Task> work, CancellationToken ct)
{
try
{
await _uow.ExecuteInTransactionAsync(work, ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ConcurrencyConflict,
"The template was modified by another request.", 412);
}
}
// --- mapping -------------------------------------------------------------
private async Task<ETagged<TemplateGraphDto>> RequireGraphAsync(int templateId, CancellationToken ct)
@@ -59,9 +59,9 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(x => EF.Functions.ILike(x.DocNo, $"%{term}%")
|| EF.Functions.ILike(x.VendorInvoiceNo, $"%{term}%")
|| EF.Functions.ILike(x.VendorSnapshotName, $"%{term}%"));
q = q.Where(x => EF.Functions.Like(x.DocNo, $"%{term}%")
|| EF.Functions.Like(x.VendorInvoiceNo, $"%{term}%")
|| EF.Functions.Like(x.VendorSnapshotName, $"%{term}%"));
}
if (status is not null) q = q.Where(x => x.Status == status);
if (vendorId is not null) q = q.Where(x => x.VendorId == vendorId);
@@ -156,7 +156,15 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService
Recalculate(invoice);
invoice.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The purchase invoice was modified by another request.", 412);
}
return new ETagged<PurchaseInvoiceDto>(MapInvoice(invoice), invoice.RowVersion);
}
@@ -61,7 +61,7 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(p => EF.Functions.ILike(p.DocNo, $"%{term}%"));
q = q.Where(p => EF.Functions.Like(p.DocNo, $"%{term}%"));
}
if (status is not null) q = q.Where(p => p.Status == status);
if (vendorId is not null) q = q.Where(p => p.VendorId == vendorId);
@@ -79,7 +79,7 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(r => EF.Functions.ILike(r.DocNo, $"%{term}%"));
q = q.Where(r => EF.Functions.Like(r.DocNo, $"%{term}%"));
}
if (vendorId is not null) q = q.Where(r => r.VendorId == vendorId);
if (warehouseId is not null) q = q.Where(r => r.WarehouseId == warehouseId);
@@ -40,7 +40,7 @@ public sealed class RequisitionService : IRequisitionService
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(r => EF.Functions.ILike(r.DocNo, $"%{term}%"));
q = q.Where(r => EF.Functions.Like(r.DocNo, $"%{term}%"));
}
if (status is not null) q = q.Where(r => r.Status == status);
+1 -1
View File
@@ -43,7 +43,7 @@ public sealed class RfqService : IRfqService
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(r => EF.Functions.ILike(r.DocNo, $"%{term}%"));
q = q.Where(r => EF.Functions.Like(r.DocNo, $"%{term}%"));
}
if (status is not null) q = q.Where(r => r.Status == status);
+1 -1
View File
@@ -47,7 +47,7 @@ public sealed class RoleService : IRoleService
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(r => EF.Functions.ILike(r.Code, $"%{term}%") || EF.Functions.ILike(r.Name, $"%{term}%"));
q = q.Where(r => EF.Functions.Like(r.Code, $"%{term}%") || EF.Functions.Like(r.Name, $"%{term}%"));
}
if (status is not null) q = q.Where(r => r.Status == status);
@@ -57,7 +57,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(x => EF.Functions.ILike(x.InvoiceNo, $"%{term}%") || EF.Functions.ILike(x.CustomerSnapshotName, $"%{term}%"));
q = q.Where(x => EF.Functions.Like(x.InvoiceNo, $"%{term}%") || EF.Functions.Like(x.CustomerSnapshotName, $"%{term}%"));
}
if (status is not null) q = q.Where(x => x.Status == status);
if (customerId is not null) q = q.Where(x => x.CustomerId == customerId);
@@ -140,7 +140,15 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
foreach (var line in await BuildLinesAsync(request.WarehouseId, request.Lines, ct)) invoice.Lines.Add(line);
Recalculate(invoice);
invoice.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The sales invoice was modified by another request.", 412);
}
return new ETagged<SalesInvoiceDto>(_mapping.MapInvoice(invoice), invoice.RowVersion);
}
@@ -73,7 +73,7 @@ public sealed class SalesReturnService : ISalesReturnService
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(r => EF.Functions.ILike(r.DocNo, $"%{term}%"));
q = q.Where(r => EF.Functions.Like(r.DocNo, $"%{term}%"));
}
if (customerId is not null) q = q.Where(r => r.CustomerId == customerId);
if (warehouseId is not null) q = q.Where(r => r.WarehouseId == warehouseId);
+11 -3
View File
@@ -66,7 +66,7 @@ public sealed class SalesSlipService : ISalesSlipService
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(x => EF.Functions.ILike(x.SlipNo, $"%{term}%") || EF.Functions.ILike(x.CustomerSnapshotName, $"%{term}%"));
q = q.Where(x => EF.Functions.Like(x.SlipNo, $"%{term}%") || EF.Functions.Like(x.CustomerSnapshotName, $"%{term}%"));
}
if (status is not null) q = q.Where(x => x.Status == status);
if (customerId is not null) q = q.Where(x => x.CustomerId == customerId);
@@ -91,7 +91,7 @@ public sealed class SalesSlipService : ISalesSlipService
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(x => EF.Functions.ILike(x.SlipNo, $"%{term}%") || EF.Functions.ILike(x.CustomerSnapshotName, $"%{term}%"));
q = q.Where(x => EF.Functions.Like(x.SlipNo, $"%{term}%") || EF.Functions.Like(x.CustomerSnapshotName, $"%{term}%"));
}
if (status is not null) q = q.Where(x => x.Status == status);
if (customerId is not null) q = q.Where(x => x.CustomerId == customerId);
@@ -165,7 +165,15 @@ public sealed class SalesSlipService : ISalesSlipService
foreach (var line in await BuildLinesAsync(request.WarehouseId, request.Lines, ct)) slip.Lines.Add(line);
Recalculate(slip, request.PaidAmount);
slip.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The sales slip was modified by another request.", 412);
}
return new ETagged<SalesSlipDto>(_mapping.MapSlip(slip), slip.RowVersion);
}
@@ -78,41 +78,79 @@ public sealed class FifoCostingService : IFifoCostingService
$"Batch {batchId} expired on {expiry:yyyy-MM-dd} and cannot be issued.", 409);
}
// Row-lock the issuable open layers oldest-first (SELECT … FOR UPDATE, NFR-02), plus any
// shortfall (negative) layers left by an over-stock sale. Excludes on-hold and rejected
// (grn line) and expired-batch stock — rejected stock sits quarantined until a Purchase
// Return consumes it directly (see PurchaseReturnService), never through this
// general-issue path. No LINQ is composed on top of the raw SQL so the FOR UPDATE
// reaches the database intact.
var locked = await _db.StockLayers
.FromSqlInterpolated($"""
SELECT * FROM stock_layers sl
WHERE sl."ItemId" = {itemId} AND sl."WarehouseId" = {warehouseId}
AND (sl."QtyRemaining" < 0 OR (sl."QtyRemaining" > 0
AND NOT EXISTS (SELECT 1 FROM grn_lines gl WHERE gl."GrnLineId" = sl."GrnLineId" AND gl."HoldStatus" IN ('OnHold', 'Rejected'))
AND NOT EXISTS (SELECT 1 FROM batches b WHERE b."BatchId" = sl."BatchId" AND b."ExpiryDate" < {today})))
ORDER BY sl."ReceiptDate", sl."LayerId"
FOR UPDATE
// Row-lock the issuable open layers oldest-first (NFR-02), in two steps.
//
// Why two steps: InnoDB's locking clause applies to EVERY table a statement reads,
// including rows pulled in by the NOT EXISTS subqueries. A single-statement
// `... NOT EXISTS (grn_lines) ... FOR UPDATE` would therefore also X-lock grn_lines
// and batches rows, which creates a lock cycle against GrnService (it mutates
// HoldStatus). PostgreSQL locked only the outer FROM list, and MySQL 8.0.1+ can scope
// a lock with `FOR UPDATE OF sl` — but MariaDB has no `OF` clause at all (verified:
// syntax error). So step 1 evaluates the exclusions WITHOUT a locking clause, and
// step 2 locks exactly those ids against stock_layers alone.
//
// Excludes on-hold and rejected (grn line) and expired-batch stock — rejected stock
// sits quarantined until a Purchase Return consumes it directly (see
// PurchaseReturnService), never through this general-issue path.
var candidateIds = await _db.Database
.SqlQuery<int>($"""
SELECT sl.`LayerId` AS `Value` FROM `stock_layers` sl
WHERE sl.`ItemId` = {itemId} AND sl.`WarehouseId` = {warehouseId} AND sl.`QtyRemaining` > 0
AND ({batchId} IS NULL OR sl.`BatchId` = {batchId})
AND NOT EXISTS (SELECT 1 FROM `grn_lines` gl WHERE gl.`GrnLineId` = sl.`GrnLineId` AND gl.`HoldStatus` IN ('OnHold', 'Rejected'))
AND NOT EXISTS (SELECT 1 FROM `batches` b WHERE b.`BatchId` = sl.`BatchId` AND b.`ExpiryDate` < {today})
ORDER BY sl.`ReceiptDate`, sl.`LayerId`
""")
.ToListAsync(ct);
// Stock sold before it arrived is owed first: net shortfall layers off against issuable
// stock (oldest-first) before anything new is issued, so it can't be sold twice. The
// sale already posted its ledger Out, so settling writes no ledger entry of its own.
var owed = locked.Where(l => l.QtyRemaining < 0).ToList();
var open = locked.Where(l => l.QtyRemaining > 0).ToList();
foreach (var shortfall in owed)
// `IN ()` is a syntax error, so short-circuit to the normal insufficient-stock path.
List<StockLayer> layers;
if (candidateIds.Count == 0)
{
foreach (var layer in open)
layers = new List<StockLayer>();
}
else
{
// SELECT sl.* (not *) and no LINQ composed on top: composition would wrap this in a
// derived table and the locking clause would be lost. Safe because StockLayer has no
// RowVersion and no shadow properties — note that if one is ever added, `*` WOULD
// return it here (a real column), unlike PostgreSQL's system-column xmin.
// Injection-safe by type, not by escaping: candidateIds is List<int> read straight
// back from the database, so it can only ever render as digits and commas.
var idList = string.Join(",", candidateIds);
// EF1002 is suppressed deliberately, not ignored: a parameterised IN list is not
// an option here because the parameter count varies per call, and FromSql would
// not let the `FOR UPDATE` survive. idList is built from List<int>, so it cannot
// contain anything but digits and commas.
#pragma warning disable EF1002 // Possible SQL injection vulnerability — see above.
layers = await _db.StockLayers
.FromSqlRaw($"""
SELECT sl.* FROM `stock_layers` sl
WHERE sl.`LayerId` IN ({idList}) AND sl.`QtyRemaining` > 0
ORDER BY sl.`ReceiptDate`, sl.`LayerId`
FOR UPDATE
""")
.ToListAsync(ct);
#pragma warning restore EF1002
// Re-validate under the locks. Between step 1 and step 2 a layer could have gone
// on-hold or had its batch expire; now that the rows are locked nothing can change
// under us, so this restores the single-statement guarantee.
if (layers.Count > 0)
{
if (shortfall.QtyRemaining >= 0) break;
var settle = Math.Min(-shortfall.QtyRemaining, layer.QtyRemaining);
layer.QtyRemaining -= settle;
shortfall.QtyRemaining += settle;
var stillIssuable = await _db.Database
.SqlQuery<int>($"""
SELECT sl.`LayerId` AS `Value` FROM `stock_layers` sl
WHERE sl.`LayerId` IN ({idList})
AND NOT EXISTS (SELECT 1 FROM `grn_lines` gl WHERE gl.`GrnLineId` = sl.`GrnLineId` AND gl.`HoldStatus` IN ('OnHold', 'Rejected'))
AND NOT EXISTS (SELECT 1 FROM `batches` b WHERE b.`BatchId` = sl.`BatchId` AND b.`ExpiryDate` < {today})
""")
.ToListAsync(ct);
var issuable = stillIssuable.ToHashSet();
layers = layers.Where(l => issuable.Contains(l.LayerId)).ToList();
}
}
var layers = open.Where(l => l.QtyRemaining > 0 && (batchId == null || l.BatchId == batchId)).ToList();
var available = layers.Sum(l => l.QtyRemaining);
if (available < qtyBase && !allowShortfall)
{
+1 -1
View File
@@ -29,7 +29,7 @@ public sealed class TaxRateService : ITaxRateService
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(t => EF.Functions.ILike(t.Code, $"%{term}%") || EF.Functions.ILike(t.Name, $"%{term}%"));
q = q.Where(t => EF.Functions.Like(t.Code, $"%{term}%") || EF.Functions.Like(t.Name, $"%{term}%"));
}
if (status is not null) q = q.Where(t => t.Status == status);
+1 -1
View File
@@ -62,7 +62,7 @@ public sealed class TransferService : ITransferService
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(t => EF.Functions.ILike(t.DocNo, $"%{term}%"));
q = q.Where(t => EF.Functions.Like(t.DocNo, $"%{term}%"));
}
if (status is not null) q = q.Where(t => t.Status == status);
if (srcWarehouseId is not null) q = q.Where(t => t.SrcWarehouseId == srcWarehouseId);
+1 -1
View File
@@ -27,7 +27,7 @@ public sealed class UomService : IUomService
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(u => EF.Functions.ILike(u.Name, $"%{term}%"));
q = q.Where(u => EF.Functions.Like(u.Name, $"%{term}%"));
}
if (status is not null) q = q.Where(u => u.Status == status);
@@ -43,10 +43,10 @@ public sealed class UserManagementService : IUserManagementService
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(u => EF.Functions.ILike(u.Username, $"%{term}%") || EF.Functions.ILike(u.DisplayName, $"%{term}%"));
q = q.Where(u => EF.Functions.Like(u.Username, $"%{term}%") || EF.Functions.Like(u.DisplayName, $"%{term}%"));
}
if (status is not null) q = q.Where(u => u.Status == status);
if (!string.IsNullOrWhiteSpace(roleCode)) q = q.Where(u => u.Role != null && EF.Functions.ILike(u.Role.Code, roleCode));
if (!string.IsNullOrWhiteSpace(roleCode)) q = q.Where(u => u.Role != null && u.Role.Code == roleCode);
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(u => u.Username)
+1 -1
View File
@@ -30,7 +30,7 @@ public sealed class VendorService : IVendorService
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(v => EF.Functions.ILike(v.Code, $"%{term}%") || EF.Functions.ILike(v.Name, $"%{term}%"));
q = q.Where(v => EF.Functions.Like(v.Code, $"%{term}%") || EF.Functions.Like(v.Name, $"%{term}%"));
}
if (status is not null) q = q.Where(v => v.Status == status);
+1 -1
View File
@@ -34,7 +34,7 @@ public sealed class WarehouseService : IWarehouseService
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(w => EF.Functions.ILike(w.Code, $"%{term}%") || EF.Functions.ILike(w.Name, $"%{term}%"));
q = q.Where(w => EF.Functions.Like(w.Code, $"%{term}%") || EF.Functions.Like(w.Name, $"%{term}%"));
}
if (branchId is not null) q = q.Where(w => w.BranchId == branchId);