adding complete db migrations from posgres
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user