Feat/mysql migration #99
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
-2
@@ -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,9 +29,7 @@ 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);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,21 +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
|
||||
@@ -165,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;
|
||||
|
||||
@@ -189,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
|
||||
@@ -202,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).
|
||||
@@ -226,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)
|
||||
{
|
||||
@@ -256,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)
|
||||
{
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -17,9 +17,7 @@ using ERPCore.System.Errors;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.OpenApi;
|
||||
using Npgsql;
|
||||
using Serilog;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
@@ -33,11 +31,17 @@ builder.Host.UseSerilog((ctx, cfg) => cfg
|
||||
builder.Services.AddControllers()
|
||||
.AddJsonOptions(o => o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
|
||||
|
||||
// EF Core + PostgreSQL
|
||||
// EF Core + MariaDB (Pomelo). The server version is pinned explicitly rather than using
|
||||
// ServerVersion.AutoDetect: AutoDetect opens a connection, which would make design-time
|
||||
// commands (`dotnet ef migrations add`) and startup depend on the database being reachable.
|
||||
// MariaDbServerVersion — not MySqlServerVersion — because the target is MariaDB 10.11, and
|
||||
// Pomelo changes its SQL translation based on which it is given.
|
||||
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
|
||||
?? throw new InvalidOperationException("ConnectionStrings:DefaultConnection is not configured.");
|
||||
|
||||
builder.Services.AddDbContext<ErpDbContext>(o =>
|
||||
{
|
||||
o.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"));
|
||||
o.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning));
|
||||
o.UseMySql(connectionString, new MariaDbServerVersion(new Version(10, 11, 15)));
|
||||
});
|
||||
|
||||
// ProblemDetails (RFC 7807) + domain-exception mapping
|
||||
|
||||
@@ -14,6 +14,5 @@ public interface IRepository<T> where T : class
|
||||
IQueryable<T> Query();
|
||||
|
||||
Task AddAsync(T entity, CancellationToken ct = default);
|
||||
void Update(T entity);
|
||||
void Remove(T entity);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,5 @@ public class Repository<T> : IRepository<T> where T : class
|
||||
public virtual async Task AddAsync(T entity, CancellationToken ct = default)
|
||||
=> await Set.AddAsync(entity, ct);
|
||||
|
||||
public virtual void Update(T entity) => Set.Update(entity);
|
||||
|
||||
public virtual void Remove(T entity) => Set.Remove(entity);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user