Compare commits

...

13 Commits

Author SHA1 Message Date
ImanThiyanga 4efd5bda75 Merge branch 'Dev' into feat/mysql-migration 2026-10-05 12:10:58 +00:00
ImanThiyanga dbf9e841f8 update on production db 2026-10-05 17:40:04 +05:30
ImanThiyanga c1405da8d2 Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 11m11s
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 14m58s
2026-10-05 11:47:00 +00:00
ImanThiyanga cc83a3e59f Merge pull request 'Feat/mysql migration' (#99) from feat/mysql-migration into Dev
Reviewed-on: #99
2026-10-05 11:46:12 +00:00
ImanThiyanga 8706c2557b initiating db migration 2026-10-05 17:15:08 +05:30
ImanThiyanga 9503ea5fe8 fix after migrations 2026-10-05 17:14:42 +05:30
ImanThiyanga 65f58c4000 adding complete db migrations from posgres 2026-10-05 16:11:35 +05:30
ImanThiyanga 9d710349c2 Refactor code structure for improved readability and maintainability; removed redundant code blocks and optimized function calls. 2026-10-05 16:03:31 +05:30
kalana_Thilakarathna 535616c48f Merge pull request 'Dev' (#98) from Dev into production
Reviewed-on: #98
2026-10-05 10:07:55 +00:00
kalana_Thilakarathna c6c9c6a9dd Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 13m53s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 15m25s
2026-10-05 10:07:50 +00:00
kalana_Thilakarathna dedb42365e Merge pull request 'Day end sales fixers' (#97) from day-end-sales-fixers into Dev
Reviewed-on: #97
2026-10-05 10:07:34 +00:00
Sasanka f4ec3b4c65 fixers of day end sales and sales 2026-10-05 13:11:01 +05:30
ashan_rusiru 6c707d88b1 Merge pull request 'sales & grn fixers' (#94) from sales-&-GRN-fixers into production
Reviewed-on: #94
2026-10-04 11:42:29 +00:00
223 changed files with 8431 additions and 383429 deletions
+15 -1
View File
@@ -45,10 +45,24 @@ jobs:
find /home/erp/core/publish -type f -exec chmod 644 {} +
chmod +x /home/erp/core/publish/ERPCore
- name: Restart service
# ConnectionStrings__DefaultConnection is supplied by the systemd unit on the host
# (Environment= or EnvironmentFile=), deliberately not committed: appsettings.json ships
# the key empty and appsettings.Production.json carries no ConnectionStrings section at
# all. If the unit does not set it, startup throws before serving and the Verify step
# below fails the job.
run: systemctl restart erpcore.service
- name: Verify
run: |
systemctl is-active erpcore.service && echo "✅ Service running" || echo "❌ Service failed"
# `is-active ... || echo` always exited 0, so a crashed service still reported success.
# Wait a few seconds first: a startup failure (bad or missing connection string) happens
# during MigrateAsync, after systemctl restart has already returned.
sleep 5
if ! systemctl is-active --quiet erpcore.service; then
echo "❌ erpcore.service is not running:"
journalctl -u erpcore.service -n 50 --no-pager
exit 1
fi
echo "✅ Service running"
build-and-deploy-frontend:
name: Build & Deploy Frontend
+1 -1
View File
@@ -8,7 +8,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.20" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
@@ -30,7 +30,7 @@ public class NotificationConfigServiceTests
var result = await service.GetAsync();
Assert.Equal(7u, result.RowVersion);
Assert.Equal(1u, result.RowVersion);
Assert.Contains(result.Value.Modules, m => m.ModuleKey == "sale.create" && m.EmailEnabled && !m.SmsEnabled && m.InAppEnabled);
Assert.Contains(result.Value.Modules, m => m.ModuleKey == "grn.confirmed" && !m.EmailEnabled && !m.SmsEnabled && !m.InAppEnabled);
}
@@ -55,9 +55,9 @@ public class NotificationConfigServiceTests
}).ToList()
};
var updated = await service.UpdateAsync(request, 7);
var updated = await service.UpdateAsync(request, 1);
Assert.Equal(7u, updated.RowVersion);
Assert.Equal(2u, updated.RowVersion);
Assert.Contains(updated.Value.Modules, m => m.ModuleKey == catalog.GetAll()[0].Key && m.EmailEnabled && !m.SmsEnabled && !m.InAppEnabled);
Assert.Contains(updated.Value.Modules, m => m.ModuleKey == catalog.GetAll()[1].Key && !m.EmailEnabled && m.SmsEnabled && m.InAppEnabled);
@@ -133,7 +133,7 @@ public class NotificationConfigServiceTests
}).ToList()
};
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(request, 7));
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(request, 1));
Assert.Equal(ErrorCodes.ConcurrencyConflict, ex.Code);
Assert.Equal(412, ex.StatusCode);
}
@@ -0,0 +1,124 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Production;
using ERPCore.Infra.Auth;
using ERPCore.Infra.Persistence;
using ERPCore.Repositories;
using ERPCore.Services.Production;
using ERPCore.System.Errors;
using ERPCore.Tests.TestSupport;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
namespace ERPCore.Tests;
/// <summary>
/// Covers the 412 translation on the two production services, which save through
/// <c>ExecuteInTransactionAsync</c> rather than a bare <c>SaveChangesAsync</c> and so guard
/// concurrency differently from the inline pattern the other services use.
/// </summary>
public class ProductionConcurrencyTests
{
private static readonly InMemoryDatabaseRoot Root = new();
[Fact]
public async Task TemplateUpdateAsync_MapsConcurrencyFailuresToDomainException()
{
await using var db = CreateContext();
// Validation runs before the transaction opens, so the referenced item has to exist.
db.Items.Add(new Item
{
ItemId = 1,
Sku = "FG-1",
Name = "Finished good",
CategoryId = 1,
BaseUomId = 1,
Status = EntityStatus.Active
});
await db.SaveChangesAsync();
var service = CreateTemplateService(db);
var ex = await Assert.ThrowsAsync<DomainException>(
() => service.UpdateAsync(1, SingleStageTemplate(), expectedRowVersion: 1));
Assert.Equal(ErrorCodes.ConcurrencyConflict, ex.Code);
Assert.Equal(412, ex.StatusCode);
}
[Fact]
public async Task RunUpdateStageQuantitiesAsync_MapsConcurrencyFailuresToDomainException()
{
await using var db = CreateContext();
var service = CreateRunService(db);
var ex = await Assert.ThrowsAsync<DomainException>(
() => service.UpdateStageQuantitiesAsync(1, 1, new UpdateStageQuantitiesRequest()));
Assert.Equal(ErrorCodes.ConcurrencyConflict, ex.Code);
Assert.Equal(412, ex.StatusCode);
}
/// <summary>
/// The smallest graph the validator accepts: one stage (both entry and terminal) with a
/// stock input and a single item-bearing output, and no edges.
/// </summary>
private static SaveTemplateRequest SingleStageTemplate() => new()
{
Code = "TPL-1",
Name = "Template 1",
Stages =
[
new SaveStageRequest
{
Key = "tmp-1",
Name = "Mix",
Inputs =
[
new SaveInputRequest
{
Source = StageInputSource.Stock,
ItemId = 1,
QtyUnit = StageQtyUnit.Pack,
QtyPerBatch = 1m
}
],
Outputs =
[
new SaveOutputRequest { Key = "tmp-o1", Name = "Output", ItemId = 1, QtyPerBatch = 1m }
]
}
]
};
private static ErpDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ErpDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString("N"), Root)
.Options;
// The real CurrentBranchContext over an unauthenticated user yields IsActive=false,
// which leaves the branch query filters off: what a unit test wants.
var currentUser = new TestCurrentUser();
return new ErpDbContext(options, currentUser, new CurrentBranchContext(currentUser));
}
private static ProductionTemplateService CreateTemplateService(ErpDbContext db)
=> new(
new Repository<ProductionTemplate>(db), new Repository<TemplateStage>(db),
new Repository<StageInput>(db), new Repository<StageOutput>(db),
new Repository<StageEdge>(db), new Repository<Item>(db),
new Repository<Uom>(db), new Repository<ProductionRun>(db),
new ThrowingTransactionUnitOfWork(), new TestCurrentUser());
// The costing, measurement and numbering collaborators are only ever touched inside the
// transaction lambda, which ThrowingTransactionUnitOfWork never invokes.
private static ProductionRunService CreateRunService(ErpDbContext db)
=> new(
new Repository<ProductionRun>(db), new Repository<ProductionTemplate>(db),
new Repository<Warehouse>(db), new Repository<Bin>(db),
new Repository<Item>(db), new Repository<ReasonCode>(db),
fifo: null!, measure: null!, numbers: null!,
new ThrowingTransactionUnitOfWork(), new TestCurrentUser());
}
@@ -11,6 +11,12 @@ internal sealed class TestCurrentUser : ICurrentUser
public string UserId => "system";
public int AuditUserId => User.SystemUserId;
public bool IsAuthenticated => false;
// Unauthenticated, so no role claim and no branch scoping: the three members the
// AuthHex integration added to ICurrentUser.
public string? RoleCode => null;
public bool IsAdmin => false;
public int? BranchId => null;
}
internal sealed class ThrowingUnitOfWork : ERPCore.Infra.UoW.IUnitOfWork
@@ -25,6 +31,25 @@ internal sealed class ThrowingUnitOfWork : ERPCore.Infra.UoW.IUnitOfWork
=> throw new NotSupportedException();
}
/// <summary>
/// Fails the transactional path the way a lost optimistic-concurrency race does, so the
/// services that save through <c>ExecuteInTransactionAsync</c> can be covered too. The
/// lambda is never invoked, which is the point: the real <c>UnitOfWork</c> issues its own
/// <c>SaveChangesAsync</c> after the lambda returns, so the catch under test has to sit
/// outside it.
/// </summary>
internal sealed class ThrowingTransactionUnitOfWork : ERPCore.Infra.UoW.IUnitOfWork
{
public Task<int> SaveChangesAsync(CancellationToken ct = default)
=> throw new DbUpdateConcurrencyException("Test concurrency conflict.");
public Task ExecuteInTransactionAsync(Func<CancellationToken, Task> action, CancellationToken ct = default)
=> throw new DbUpdateConcurrencyException("Test concurrency conflict.");
public Task<T> ExecuteInTransactionAsync<T>(Func<CancellationToken, Task<T>> action, CancellationToken ct = default)
=> throw new DbUpdateConcurrencyException("Test concurrency conflict.");
}
internal static class NotificationConfigTestSupport
{
private static readonly InMemoryDatabaseRoot Root = new();
@@ -35,7 +60,10 @@ internal static class NotificationConfigTestSupport
.UseInMemoryDatabase(name, Root)
.Options;
return new ErpDbContext(options, new TestCurrentUser());
// The real CurrentBranchContext over an unauthenticated user yields IsActive=false,
// which leaves the branch query filters off: what a unit test wants.
var currentUser = new TestCurrentUser();
return new ErpDbContext(options, currentUser, new CurrentBranchContext(currentUser));
}
public static async Task SeedAsync(ErpDbContext db, IEnumerable<NotificationConfigModule>? modules = null)
@@ -52,7 +80,8 @@ internal static class NotificationConfigTestSupport
NotificationConfigId = NotificationConfig.SingletonId,
UpdatedBy = User.SystemUserId,
UpdatedAt = DateTime.UtcNow,
RowVersion = 7,
// No RowVersion here on purpose: ErpDbContext.BumpRowVersions stamps every
// inserted row with 1, so any seeded value is overwritten on save.
};
if (modules is not null)
@@ -24,8 +24,9 @@ public sealed class CustomersController : ApiControllerBase
[FromQuery] EntityStatus? status,
[FromQuery] CustomerType? customerType,
CancellationToken ct,
[FromQuery] bool includeCashCustomer = false)
=> Ok(await _customers.ListAsync(query, status, customerType, includeCashCustomer, ct));
[FromQuery] bool includeCashCustomer = false,
[FromQuery] int? branchId = null)
=> Ok(await _customers.ListAsync(query, status, customerType, includeCashCustomer, branchId, ct));
[HttpGet("{customerId:int}")]
[ProducesResponseType(typeof(CustomerDto), StatusCodes.Status200OK)]
@@ -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,
@@ -90,8 +90,10 @@ public sealed class SalesInvoicesController : ApiControllerBase
[FromQuery] int? branchId,
[FromQuery] DateOnly? from,
[FromQuery] DateOnly? to,
[FromQuery] int? createdBy,
[FromQuery] bool? paid,
CancellationToken ct)
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, from, to, ct));
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, from, to, createdBy, paid, ct));
[HttpGet("{salesInvoiceId:int}")]
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
+12
View File
@@ -9,6 +9,18 @@ namespace ERPCore.Domain.Entities;
/// </summary>
public class Branch
{
/// <summary>
/// Code of the deployment's default branch. Every branch-scoped entity has a
/// non-nullable <c>BranchId</c> with a Restrict FK, so this row must exist before any
/// of them can be written — see <c>DataSeeder.EnsureDefaultBranchAsync</c>, which
/// creates it at startup, and <c>BranchConfigService.ResolveDefaultBranchIdAsync</c>,
/// which is the lazy runtime fallback.
/// </summary>
public const string DefaultCode = "BR-000";
/// <summary>Display name paired with <see cref="DefaultCode"/>.</summary>
public const string DefaultName = "Main Branch";
public int BranchId { get; set; }
/// <summary>Server-generated on create ("BR-001", "BR-002", ...) — never accepted from the client.</summary>
@@ -27,7 +27,7 @@ public class BundleSale
public decimal GrandTotal { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public int ConcurrencyStamp { get; set; }
public uint RowVersion { get; set; }
/// <summary>Set once this bundle's business date is closed by <see cref="SalesDayEnd"/> — a
/// closed bundle sale is frozen (no further edits). Same role as <see cref="SalesSlip.DayEndId"/>;
@@ -11,7 +11,7 @@ public class BundleSaleTemplate
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public int ConcurrencyStamp { get; set; }
public uint RowVersion { get; set; }
public ICollection<BundleSaleTemplateLine> Lines { get; set; } = new List<BundleSaleTemplateLine>();
}
@@ -23,7 +23,7 @@ public sealed record SalesInvoiceDto(
public sealed record SalesInvoiceSummaryDto(
int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId,
string CustomerSnapshotName, int WarehouseId, int BranchId, SalesInvoiceType InvoiceType,
SalesInvoiceStatus Status, SalesInvoiceTotalsDto Totals, DateTime CreatedAt, string? GlJournalNo);
SalesInvoiceStatus Status, SalesInvoiceTotalsDto Totals, DateTime CreatedAt, string? GlJournalNo, int CreatedBy);
public sealed record SalesInvoicePaymentDto(
int SalesInvoicePaymentId, int SalesInvoiceId, decimal Amount, DateTime PaymentDate,
+6 -6
View File
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
@@ -10,18 +10,18 @@
<ItemGroup>
<PackageReference Include="ClosedXML" Version="0.105.0" />
<PackageReference Include="CsvHelper" Version="33.1.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.20" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.20">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9">
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.20">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.20" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
@@ -78,7 +78,11 @@ public static class AuditScribe
foreach (var p in entry.Properties)
{
if (p.Metadata.IsPrimaryKey()) continue;
if (p.Metadata.Name == nameof(Item.RowVersion)) continue;
// Semantic rather than name-matched: covers every concurrency token automatically
// and survives a rename. (This used to be `Name == nameof(Item.RowVersion)`, which
// silently missed BundleSale/BundleSaleTemplate's differently-named token and so
// leaked it into every bundle-sale change set.)
if (p.Metadata.IsConcurrencyToken) continue;
switch (action)
{
@@ -22,8 +22,6 @@ public sealed class AttendanceRecordConfiguration : IEntityTypeConfiguration<Att
builder.HasOne(r => r.WorkShift).WithMany()
.HasForeignKey(r => r.WorkShiftId).OnDelete(DeleteBehavior.Restrict);
builder.Property(r => r.RowVersion).IsRowVersion();
builder.HasIndex(r => new { r.EmployeeId, r.AttendanceDate });
builder.HasIndex(r => r.AttendanceUploadBatchId);
}
@@ -19,7 +19,6 @@ public sealed class AttendanceUploadBatchConfiguration : IEntityTypeConfiguratio
builder.Property(b => b.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(b => b.UploadedAt).IsRequired();
builder.Property(b => b.RowVersion).IsRowVersion();
builder.HasIndex(b => b.Status);
builder.HasIndex(b => new { b.PeriodStart, b.PeriodEnd });
@@ -13,7 +13,7 @@ public sealed class AuditLogConfiguration : IEntityTypeConfiguration<AuditLog>
builder.Property(a => a.EntityType).IsRequired().HasMaxLength(80);
builder.Property(a => a.Action).HasConversion<string>().HasMaxLength(10).IsRequired();
builder.Property(a => a.ChangeSet).IsRequired().HasColumnType("jsonb");
builder.Property(a => a.ChangeSet).IsRequired().HasColumnType("longtext");
builder.Property(a => a.CreatedAt).IsRequired();
builder.HasOne<User>().WithMany().HasForeignKey(a => a.UserId).OnDelete(DeleteBehavior.Restrict);
@@ -13,7 +13,7 @@ public sealed class BranchConfigConfiguration : IEntityTypeConfiguration<BranchC
public void Configure(EntityTypeBuilder<BranchConfig> builder)
{
builder.ToTable("branch_config", t =>
t.HasCheckConstraint("ck_branch_config_singleton", $"\"ConfigId\" = {BranchConfig.SingletonId}"));
t.HasCheckConstraint("ck_branch_config_singleton", $"`ConfigId` = {BranchConfig.SingletonId}"));
builder.HasKey(c => c.ConfigId);
@@ -22,9 +22,6 @@ public sealed class BranchConfigConfiguration : IEntityTypeConfiguration<BranchC
builder.Property(c => c.BranchingEnabled).IsRequired().HasDefaultValue(true);
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(c => c.RowVersion).IsRowVersion();
builder.HasOne(c => c.UpdatedByUser)
.WithMany()
.HasForeignKey(c => c.UpdatedBy)
@@ -23,7 +23,6 @@ public sealed class BranchConfiguration : IEntityTypeConfiguration<Branch>
.HasDefaultValue(EntityStatus.Active);
builder.Property(b => b.CreatedAt).IsRequired();
builder.Property(b => b.RowVersion).IsRowVersion();
builder.HasIndex(b => b.Status);
}
@@ -21,9 +21,6 @@ public sealed class BrandConfiguration : IEntityTypeConfiguration<Brand>
builder.Property(b => b.CreatedAt).IsRequired();
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(b => b.RowVersion).IsRowVersion();
builder.HasIndex(b => b.Status);
}
}
@@ -27,11 +27,6 @@ public sealed class BundleSaleConfiguration : IEntityTypeConfiguration<BundleSal
builder.Property(x => x.TaxTotal).HasPrecision(18, 4);
builder.Property(x => x.GrandTotal).HasPrecision(18, 4);
builder.Property(x => x.CreatedAt).IsRequired();
builder.Property(x => x.ConcurrencyStamp)
.IsRequired()
.HasColumnType("integer")
.HasDefaultValue(0)
.IsConcurrencyToken();
builder.HasOne(x => x.Customer).WithMany().HasForeignKey(x => x.CustomerId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Warehouse).WithMany().HasForeignKey(x => x.WarehouseId).OnDelete(DeleteBehavior.Restrict);
@@ -20,11 +20,6 @@ public sealed class BundleSaleTemplateConfiguration : IEntityTypeConfiguration<B
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.Property(x => x.CreatedAt).IsRequired();
builder.Property(x => x.ConcurrencyStamp)
.IsRequired()
.HasColumnType("integer")
.HasDefaultValue(0)
.IsConcurrencyToken();
builder.HasMany(x => x.Lines)
.WithOne(x => x.BundleSaleTemplate)
@@ -21,9 +21,6 @@ public sealed class CategoryConfiguration : IEntityTypeConfiguration<Category>
builder.Property(c => c.CreatedAt).IsRequired();
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(c => c.RowVersion).IsRowVersion();
builder.HasIndex(c => c.Status);
}
}
@@ -42,7 +42,6 @@ public sealed class CustomerConfiguration : IEntityTypeConfiguration<Customer>
.HasDefaultValue(EntityStatus.Active);
builder.Property(c => c.CreatedAt).IsRequired();
builder.Property(c => c.RowVersion).IsRowVersion();
builder.HasIndex(c => c.Status);
builder.HasIndex(c => c.CustomerType);
@@ -32,7 +32,6 @@ public sealed class DepartmentConfiguration : IEntityTypeConfiguration<Departmen
.HasForeignKey(d => d.BranchId).OnDelete(DeleteBehavior.Restrict);
builder.Property(d => d.CreatedAt).IsRequired();
builder.Property(d => d.RowVersion).IsRowVersion();
builder.HasIndex(d => d.Status);
builder.HasIndex(d => d.ParentDepartmentId);
@@ -21,7 +21,6 @@ public sealed class DesignationConfiguration : IEntityTypeConfiguration<Designat
.HasDefaultValue(EntityStatus.Active);
builder.Property(d => d.CreatedAt).IsRequired();
builder.Property(d => d.RowVersion).IsRowVersion();
builder.HasIndex(d => d.Status);
}
@@ -26,7 +26,6 @@ public sealed class EmployeeBankDetailConfiguration : IEntityTypeConfiguration<E
.HasForeignKey(b => b.EmployeeId).OnDelete(DeleteBehavior.Cascade);
builder.Property(b => b.CreatedAt).IsRequired();
builder.Property(b => b.RowVersion).IsRowVersion();
builder.HasIndex(b => b.EmployeeId);
}
@@ -63,7 +63,6 @@ public sealed class EmployeeConfiguration : IEntityTypeConfiguration<Employee>
.HasDefaultValue(EmployeeStatus.Active);
builder.Property(e => e.CreatedAt).IsRequired();
builder.Property(e => e.RowVersion).IsRowVersion();
builder.HasIndex(e => e.Status);
builder.HasIndex(e => e.DepartmentId);
@@ -28,7 +28,6 @@ public sealed class EmployeeDocumentConfiguration : IEntityTypeConfiguration<Emp
.HasForeignKey(d => d.HrDocumentTypeId).OnDelete(DeleteBehavior.Restrict);
builder.Property(d => d.UploadedAt).IsRequired();
builder.Property(d => d.RowVersion).IsRowVersion();
builder.HasIndex(d => d.EmployeeId);
builder.HasIndex(d => d.ExpiryDate);
@@ -28,7 +28,6 @@ public sealed class EmployeeLoanConfiguration : IEntityTypeConfiguration<Employe
.HasForeignKey(i => i.EmployeeLoanId).OnDelete(DeleteBehavior.Cascade);
builder.Property(l => l.CreatedAt).IsRequired();
builder.Property(l => l.RowVersion).IsRowVersion();
builder.HasIndex(l => l.EmployeeId);
builder.HasIndex(l => l.Status);
@@ -49,8 +48,6 @@ public sealed class LoanInstallmentConfiguration : IEntityTypeConfiguration<Loan
builder.HasOne(i => i.PayrollRun).WithMany()
.HasForeignKey(i => i.PayrollRunId).OnDelete(DeleteBehavior.Restrict);
builder.Property(i => i.RowVersion).IsRowVersion();
builder.HasIndex(i => new { i.DueYear, i.DueMonth });
}
}
@@ -22,7 +22,6 @@ public sealed class EmployeeSalaryStructureConfiguration : IEntityTypeConfigurat
.HasForeignKey(l => l.EmployeeSalaryStructureId).OnDelete(DeleteBehavior.Cascade);
builder.Property(s => s.CreatedAt).IsRequired();
builder.Property(s => s.RowVersion).IsRowVersion();
builder.HasIndex(s => new { s.EmployeeId, s.EffectiveTo });
}
@@ -21,7 +21,6 @@ public sealed class EmploymentTypeConfiguration : IEntityTypeConfiguration<Emplo
.HasDefaultValue(EntityStatus.Active);
builder.Property(e => e.CreatedAt).IsRequired();
builder.Property(e => e.RowVersion).IsRowVersion();
builder.HasIndex(e => e.Status);
}
@@ -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);
@@ -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);
@@ -32,6 +32,7 @@ public sealed class ProductConfigConfiguration : IEntityTypeConfiguration<Produc
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);
}
+53 -752
View File
@@ -1,15 +1,23 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Infra.Persistence;
/// <summary>
/// Idempotent startup seeding of configurable reference data (docs/10 §B.8.3).
/// Reason codes are seeded at runtime (not via <c>HasData</c>) so the identity
/// sequence advances normally and later admin <c>POST /reason-codes</c> calls
/// cannot collide with seeded ids.
/// Idempotent startup seeding of the reference data the application cannot run without
/// (docs/10 §B.8.3): the default branch, reason codes, item types, and the three singleton
/// config rows. Navigation items, sub-nav items, permissions, tax rates and the `system`
/// user come from <c>HasData</c> in Infra/Persistence/Configurations instead, so the
/// baseline migration emits them as InsertData.
///
/// Reason codes are seeded at runtime (not via <c>HasData</c>) so the identity sequence
/// advances normally and later admin <c>POST /reason-codes</c> calls cannot collide with
/// seeded ids.
///
/// Demo/sample data (warehouses MAIN/SHOP, UOMs, categories, demo items, fabricated stock
/// layers, sample invoices/slips, bundle sales) was removed with the MySQL port: a fresh
/// database is now production-shaped. The e2e suite creates whatever fixtures it needs.
/// </summary>
public static class DataSeeder
{
@@ -41,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)
{
+13 -3
View File
@@ -1,12 +1,22 @@
using System.Data;
using ERPCore.Infra.Persistence;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Infra.UoW;
/// <summary>
/// EF Core-backed unit of work. Uses the provider execution strategy so the
/// transactional path is safe under connection resiliency / retries.
/// EF Core-backed unit of work. Opens every transaction at READ COMMITTED to match the
/// semantics the code was written against (MariaDB defaults to REPEATABLE READ, where a
/// plain read inside a transaction returns a pinned snapshot while a locking read does not).
/// </summary>
/// <remarks>
/// Do NOT enable <c>EnableRetryOnFailure</c> to "harden" this. The execution strategy is
/// currently non-retrying, so the lambda runs exactly once. With retries on, EF re-runs the
/// lambda without resetting the ChangeTracker, and callers such as
/// <c>ProductionRunService.ApproveStageAsync</c> capture mutable state outside it — a retry
/// would re-insert already-Added entities and duplicate ledger/layer rows, which is precisely
/// the corruption the concurrency token exists to prevent.
/// </remarks>
public sealed class UnitOfWork : IUnitOfWork
{
private readonly ErpDbContext _db;
@@ -29,7 +39,7 @@ public sealed class UnitOfWork : IUnitOfWork
var strategy = _db.Database.CreateExecutionStrategy();
return await strategy.ExecuteAsync(async () =>
{
await using var tx = await _db.Database.BeginTransactionAsync(ct);
await using var tx = await _db.Database.BeginTransactionAsync(IsolationLevel.ReadCommitted, ct);
try
{
var result = await action(ct);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,29 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddItemTypeIsMeasurable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsMeasurable",
table: "item_types",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsMeasurable",
table: "item_types");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,113 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddGrnGlPostingAndPayments : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "BalanceAmount",
table: "grns",
type: "numeric(18,4)",
precision: 18,
scale: 4,
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<string>(
name: "GlJournalNo",
table: "grns",
type: "character varying(30)",
maxLength: 30,
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "GlPostedAt",
table: "grns",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "PaidAmount",
table: "grns",
type: "numeric(18,4)",
precision: 18,
scale: 4,
nullable: false,
defaultValue: 0m);
migrationBuilder.CreateTable(
name: "grn_payments",
columns: table => new
{
GrnPaymentId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
GrnId = table.Column<int>(type: "integer", nullable: false),
Amount = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
PaymentDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
GlBankAccountId = table.Column<long>(type: "bigint", nullable: false),
BankAccountName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Reference = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
GlJournalNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: true),
CreatedBy = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_grn_payments", x => x.GrnPaymentId);
table.ForeignKey(
name: "FK_grn_payments_grns_GrnId",
column: x => x.GrnId,
principalTable: "grns",
principalColumn: "GrnId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_grn_payments_users_CreatedBy",
column: x => x.CreatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_grn_payments_CreatedBy",
table: "grn_payments",
column: "CreatedBy");
migrationBuilder.CreateIndex(
name: "IX_grn_payments_GrnId",
table: "grn_payments",
column: "GrnId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "grn_payments");
migrationBuilder.DropColumn(
name: "BalanceAmount",
table: "grns");
migrationBuilder.DropColumn(
name: "GlJournalNo",
table: "grns");
migrationBuilder.DropColumn(
name: "GlPostedAt",
table: "grns");
migrationBuilder.DropColumn(
name: "PaidAmount",
table: "grns");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,196 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class warrenty : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Warranty",
table: "items",
type: "character varying(20)",
maxLength: 20,
nullable: false,
defaultValue: "NonWarranty");
migrationBuilder.AddColumn<int>(
name: "WarrantyPeriodMonths",
table: "items",
type: "integer",
nullable: true);
migrationBuilder.CreateTable(
name: "grn_line_warranty_numbers",
columns: table => new
{
GrnLineWarrantyNumberId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
GrnLineId = table.Column<int>(type: "integer", nullable: false),
WarrantyNo = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
WarrantyPeriodMonths = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_grn_line_warranty_numbers", x => x.GrnLineWarrantyNumberId);
table.ForeignKey(
name: "FK_grn_line_warranty_numbers_grn_lines_GrnLineId",
column: x => x.GrnLineId,
principalTable: "grn_lines",
principalColumn: "GrnLineId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "sales_returns",
columns: table => new
{
ReturnId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
DocNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
CustomerId = table.Column<int>(type: "integer", nullable: false),
WarehouseId = table.Column<int>(type: "integer", nullable: false),
ReasonCodeId = table.Column<int>(type: "integer", nullable: false),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
CreatedBy = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_sales_returns", x => x.ReturnId);
table.ForeignKey(
name: "FK_sales_returns_customers_CustomerId",
column: x => x.CustomerId,
principalTable: "customers",
principalColumn: "CustomerId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_sales_returns_reason_codes_ReasonCodeId",
column: x => x.ReasonCodeId,
principalTable: "reason_codes",
principalColumn: "ReasonCodeId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_sales_returns_users_CreatedBy",
column: x => x.CreatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_sales_returns_warehouses_WarehouseId",
column: x => x.WarehouseId,
principalTable: "warehouses",
principalColumn: "WarehouseId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "sales_return_lines",
columns: table => new
{
ReturnLineId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
ReturnId = table.Column<int>(type: "integer", nullable: false),
SalesInvoiceLineId = table.Column<int>(type: "integer", nullable: true),
ItemId = table.Column<int>(type: "integer", nullable: false),
Qty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_sales_return_lines", x => x.ReturnLineId);
table.ForeignKey(
name: "FK_sales_return_lines_items_ItemId",
column: x => x.ItemId,
principalTable: "items",
principalColumn: "ItemId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_sales_return_lines_sales_invoice_lines_SalesInvoiceLineId",
column: x => x.SalesInvoiceLineId,
principalTable: "sales_invoice_lines",
principalColumn: "SalesInvoiceLineId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_sales_return_lines_sales_returns_ReturnId",
column: x => x.ReturnId,
principalTable: "sales_returns",
principalColumn: "ReturnId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_grn_line_warranty_numbers_GrnLineId_WarrantyNo",
table: "grn_line_warranty_numbers",
columns: new[] { "GrnLineId", "WarrantyNo" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_sales_return_lines_ItemId",
table: "sales_return_lines",
column: "ItemId");
migrationBuilder.CreateIndex(
name: "IX_sales_return_lines_ReturnId",
table: "sales_return_lines",
column: "ReturnId");
migrationBuilder.CreateIndex(
name: "IX_sales_return_lines_SalesInvoiceLineId",
table: "sales_return_lines",
column: "SalesInvoiceLineId");
migrationBuilder.CreateIndex(
name: "IX_sales_returns_CreatedBy",
table: "sales_returns",
column: "CreatedBy");
migrationBuilder.CreateIndex(
name: "IX_sales_returns_CustomerId",
table: "sales_returns",
column: "CustomerId");
migrationBuilder.CreateIndex(
name: "IX_sales_returns_DocNo",
table: "sales_returns",
column: "DocNo",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_sales_returns_ReasonCodeId",
table: "sales_returns",
column: "ReasonCodeId");
migrationBuilder.CreateIndex(
name: "IX_sales_returns_WarehouseId",
table: "sales_returns",
column: "WarehouseId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "grn_line_warranty_numbers");
migrationBuilder.DropTable(
name: "sales_return_lines");
migrationBuilder.DropTable(
name: "sales_returns");
migrationBuilder.DropColumn(
name: "Warranty",
table: "items");
migrationBuilder.DropColumn(
name: "WarrantyPeriodMonths",
table: "items");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,109 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddSalesDayEnd : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "DayEndId",
table: "sales_slips",
type: "integer",
nullable: true);
migrationBuilder.CreateTable(
name: "sales_day_ends",
columns: table => new
{
SalesDayEndId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
DocNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
CashierUserId = table.Column<int>(type: "integer", nullable: false),
BusinessDate = table.Column<DateOnly>(type: "date", nullable: false),
SlipCount = table.Column<int>(type: "integer", nullable: false),
Subtotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
DiscountTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
TaxTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
GrandTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
CostOfGoodsSold = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
GlJournalNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: true),
GlPostedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
ClosedBy = table.Column<int>(type: "integer", nullable: false),
ClosedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_sales_day_ends", x => x.SalesDayEndId);
table.ForeignKey(
name: "FK_sales_day_ends_users_CashierUserId",
column: x => x.CashierUserId,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_sales_day_ends_users_ClosedBy",
column: x => x.ClosedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_sales_slips_DayEndId",
table: "sales_slips",
column: "DayEndId");
migrationBuilder.CreateIndex(
name: "IX_sales_day_ends_CashierUserId_BusinessDate",
table: "sales_day_ends",
columns: new[] { "CashierUserId", "BusinessDate" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_sales_day_ends_ClosedBy",
table: "sales_day_ends",
column: "ClosedBy");
migrationBuilder.CreateIndex(
name: "IX_sales_day_ends_DocNo",
table: "sales_day_ends",
column: "DocNo",
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_sales_slips_sales_day_ends_DayEndId",
table: "sales_slips",
column: "DayEndId",
principalTable: "sales_day_ends",
principalColumn: "SalesDayEndId",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_sales_slips_sales_day_ends_DayEndId",
table: "sales_slips");
migrationBuilder.DropTable(
name: "sales_day_ends");
migrationBuilder.DropIndex(
name: "IX_sales_slips_DayEndId",
table: "sales_slips");
migrationBuilder.DropColumn(
name: "DayEndId",
table: "sales_slips");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,61 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddReturnGlPosting : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "GlJournalNo",
table: "sales_returns",
type: "character varying(30)",
maxLength: 30,
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "GlPostedAt",
table: "sales_returns",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "GlJournalNo",
table: "purchase_returns",
type: "character varying(30)",
maxLength: 30,
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "GlPostedAt",
table: "purchase_returns",
type: "timestamp with time zone",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "GlJournalNo",
table: "sales_returns");
migrationBuilder.DropColumn(
name: "GlPostedAt",
table: "sales_returns");
migrationBuilder.DropColumn(
name: "GlJournalNo",
table: "purchase_returns");
migrationBuilder.DropColumn(
name: "GlPostedAt",
table: "purchase_returns");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,40 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddAdjustmentGlPosting : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "GlJournalNo",
table: "stock_adjustments",
type: "character varying(30)",
maxLength: 30,
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "GlPostedAt",
table: "stock_adjustments",
type: "timestamp with time zone",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "GlJournalNo",
table: "stock_adjustments");
migrationBuilder.DropColumn(
name: "GlPostedAt",
table: "stock_adjustments");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,60 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddBundleSaleToDayEnd : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "BundleCount",
table: "sales_day_ends",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "DayEndId",
table: "bundle_sales",
type: "integer",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_bundle_sales_DayEndId",
table: "bundle_sales",
column: "DayEndId");
migrationBuilder.AddForeignKey(
name: "FK_bundle_sales_sales_day_ends_DayEndId",
table: "bundle_sales",
column: "DayEndId",
principalTable: "sales_day_ends",
principalColumn: "SalesDayEndId",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_bundle_sales_sales_day_ends_DayEndId",
table: "bundle_sales");
migrationBuilder.DropIndex(
name: "IX_bundle_sales_DayEndId",
table: "bundle_sales");
migrationBuilder.DropColumn(
name: "BundleCount",
table: "sales_day_ends");
migrationBuilder.DropColumn(
name: "DayEndId",
table: "bundle_sales");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,87 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddSalesInvoicePayments : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "GlJournalNo",
table: "sales_invoices",
type: "character varying(30)",
maxLength: 30,
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "GlPostedAt",
table: "sales_invoices",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.CreateTable(
name: "sales_invoice_payments",
columns: table => new
{
SalesInvoicePaymentId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
SalesInvoiceId = table.Column<int>(type: "integer", nullable: false),
Amount = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
PaymentDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
GlBankAccountId = table.Column<long>(type: "bigint", nullable: false),
BankAccountName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Reference = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
GlJournalNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: true),
CreatedBy = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_sales_invoice_payments", x => x.SalesInvoicePaymentId);
table.ForeignKey(
name: "FK_sales_invoice_payments_sales_invoices_SalesInvoiceId",
column: x => x.SalesInvoiceId,
principalTable: "sales_invoices",
principalColumn: "SalesInvoiceId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_sales_invoice_payments_users_CreatedBy",
column: x => x.CreatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_sales_invoice_payments_CreatedBy",
table: "sales_invoice_payments",
column: "CreatedBy");
migrationBuilder.CreateIndex(
name: "IX_sales_invoice_payments_SalesInvoiceId",
table: "sales_invoice_payments",
column: "SalesInvoiceId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "sales_invoice_payments");
migrationBuilder.DropColumn(
name: "GlJournalNo",
table: "sales_invoices");
migrationBuilder.DropColumn(
name: "GlPostedAt",
table: "sales_invoices");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,90 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddProductionNavPermissions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
table: "nav_items",
columns: new[] { "NavItemId", "Code", "Href", "Icon", "Label", "SortOrder" },
values: new object[] { 14, "production", "/dashboard/production", null, "Production", 14 });
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[] { 35, "NAV:production", 14, null });
migrationBuilder.InsertData(
table: "sub_nav_items",
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
values: new object[,]
{
{ 24, "production.templates", "/dashboard/production/templates", null, "Templates", 14, 1 },
{ 25, "production.runs", "/dashboard/production/runs", null, "Runs", 14, 2 },
{ 26, "production.produced", "/dashboard/production/produced", null, "Produced", 14, 3 }
});
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[,]
{
{ 36, "NAV:production.templates", null, 24 },
{ 37, "NAV:production.runs", null, 25 },
{ 38, "NAV:production.produced", null, 26 }
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 35);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 36);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 37);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 38);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 24);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 25);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 26);
migrationBuilder.DeleteData(
table: "nav_items",
keyColumn: "NavItemId",
keyValue: 14);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,52 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class itemModel : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "SalePrice",
table: "items",
newName: "SellingMinPrice");
migrationBuilder.AddColumn<decimal>(
name: "SellingFlatPrice",
table: "items",
type: "numeric(18,4)",
precision: 18,
scale: 4,
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "SellingMaxPrice",
table: "items",
type: "numeric(18,4)",
precision: 18,
scale: 4,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "SellingFlatPrice",
table: "items");
migrationBuilder.DropColumn(
name: "SellingMaxPrice",
table: "items");
migrationBuilder.RenameColumn(
name: "SellingMinPrice",
table: "items",
newName: "SalePrice");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,103 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddNotificationConfigModulePreferences : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "notification_config",
columns: table => new
{
NotificationConfigId = table.Column<int>(type: "integer", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
UpdatedBy = table.Column<int>(type: "integer", nullable: true),
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_notification_config", x => x.NotificationConfigId);
table.CheckConstraint("ck_notification_config_singleton", "\"NotificationConfigId\" = 1");
table.ForeignKey(
name: "FK_notification_config_users_UpdatedBy",
column: x => x.UpdatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "notification_config_modules",
columns: table => new
{
NotificationConfigModuleId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
NotificationConfigId = table.Column<int>(type: "integer", nullable: false),
ModuleKey = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
EmailEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
SmsEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
InAppEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
},
constraints: table =>
{
table.PrimaryKey("PK_notification_config_modules", x => x.NotificationConfigModuleId);
table.ForeignKey(
name: "FK_notification_config_modules_notification_config_Notificatio~",
column: x => x.NotificationConfigId,
principalTable: "notification_config",
principalColumn: "NotificationConfigId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.Sql("""
INSERT INTO sub_nav_items ("SubNavItemId", "NavItemId", "Code", "Href", "Icon", "Label", "SortOrder")
VALUES (24, 9, 'settings.notification-config', '/dashboard/settings/notification-config', NULL, 'Notification Config', 3)
ON CONFLICT ("SubNavItemId") DO UPDATE
SET "NavItemId" = EXCLUDED."NavItemId",
"Code" = EXCLUDED."Code",
"Href" = EXCLUDED."Href",
"Icon" = EXCLUDED."Icon",
"Label" = EXCLUDED."Label",
"SortOrder" = EXCLUDED."SortOrder";
""");
migrationBuilder.CreateIndex(
name: "IX_notification_config_UpdatedBy",
table: "notification_config",
column: "UpdatedBy");
migrationBuilder.CreateIndex(
name: "IX_notification_config_modules_ModuleKey",
table: "notification_config_modules",
column: "ModuleKey",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_notification_config_modules_NotificationConfigId",
table: "notification_config_modules",
column: "NotificationConfigId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "notification_config_modules");
migrationBuilder.DropTable(
name: "notification_config");
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 24);
}
}
}
@@ -1,49 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
public partial class UpdateItemSellingPrices : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "SalePrice",
table: "items",
newName: "SellingFlatPrice");
migrationBuilder.AddColumn<decimal>(
name: "SellingMinPrice",
table: "items",
type: "numeric(18,4)",
precision: 18,
scale: 4,
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "SellingMaxPrice",
table: "items",
type: "numeric(18,4)",
precision: 18,
scale: 4,
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "SellingMinPrice",
table: "items");
migrationBuilder.DropColumn(
name: "SellingMaxPrice",
table: "items");
migrationBuilder.RenameColumn(
name: "SellingFlatPrice",
table: "items",
newName: "SalePrice");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,62 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddProductConfigFeatureFlags : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "BatchListsEnabled",
table: "product_config",
type: "boolean",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<bool>(
name: "SellingPricesEnabled",
table: "product_config",
type: "boolean",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<bool>(
name: "SerialNumbersEnabled",
table: "product_config",
type: "boolean",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<bool>(
name: "WarrantyPeriodsEnabled",
table: "product_config",
type: "boolean",
nullable: false,
defaultValue: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "BatchListsEnabled",
table: "product_config");
migrationBuilder.DropColumn(
name: "SellingPricesEnabled",
table: "product_config");
migrationBuilder.DropColumn(
name: "SerialNumbersEnabled",
table: "product_config");
migrationBuilder.DropColumn(
name: "WarrantyPeriodsEnabled",
table: "product_config");
}
}
}
@@ -1,24 +0,0 @@
// <auto-generated />
using ERPCore.Infra.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
[DbContext(typeof(ErpDbContext))]
[Migration("20260825000000_FixProductionTemplatesSubNavItemIdConflict")]
partial class FixProductionTemplatesSubNavItemIdConflict
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
// This migration only repairs seed row data via raw SQL — no entity/column shape
// changes — so it does not redeclare the full model here. The single source of
// truth for scaffolding future migrations is ErpDbContextModelSnapshot.cs, which
// already reflects this fix (production.templates seeded at SubNavItemId 27).
}
}
}
@@ -1,46 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <summary>
/// Repairs a collision baked into migration history: 20260818090139_AddProductionNavPermissions
/// inserted "production.templates" as sub_nav_items row 24, then
/// 20260820100345_AddNotificationConfigModulePreferences reused the same literal id 24 for
/// "settings.notification-config" via a raw-SQL <c>INSERT ... ON CONFLICT ("SubNavItemId") DO
/// UPDATE</c>, silently overwriting the production.templates row instead of inserting a new
/// one. Row 24 now correctly holds notification-config; this migration re-creates the lost
/// production.templates row under a fresh id (27, matching the corrected seed data in
/// SubNavItemConfiguration/PermissionConfiguration) and repoints the NAV:production.templates
/// permission (id 36) at it.
/// </summary>
public partial class FixProductionTemplatesSubNavItemIdConflict : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
INSERT INTO sub_nav_items ("SubNavItemId", "NavItemId", "Code", "Href", "Icon", "Label", "SortOrder")
VALUES (27, 14, 'production.templates', '/dashboard/production/templates', NULL, 'Templates', 1)
ON CONFLICT ("SubNavItemId") DO NOTHING;
""");
migrationBuilder.Sql("""
UPDATE permissions SET "SubNavItemId" = 27 WHERE "PermissionId" = 36;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
UPDATE permissions SET "SubNavItemId" = 24 WHERE "PermissionId" = 36;
""");
migrationBuilder.Sql("""
DELETE FROM sub_nav_items WHERE "SubNavItemId" = 27;
""");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,303 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddPurchaseInvoices : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "grn_payments");
migrationBuilder.DropColumn(
name: "BalanceAmount",
table: "grns");
migrationBuilder.DropColumn(
name: "PaidAmount",
table: "grns");
migrationBuilder.AddColumn<int>(
name: "PurchaseInvoiceId",
table: "grns",
type: "integer",
nullable: true);
migrationBuilder.CreateTable(
name: "purchase_invoices",
columns: table => new
{
PurchaseInvoiceId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
DocNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
VendorInvoiceNo = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
VendorInvoiceDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
VendorId = table.Column<int>(type: "integer", nullable: false),
VendorSnapshotName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
VendorSnapshotTaxNo = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Draft"),
Subtotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
VatTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
GrandTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
PaidAmount = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
BalanceAmount = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
GlJournalNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: true),
GlPostedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
CreatedBy = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_purchase_invoices", x => x.PurchaseInvoiceId);
table.ForeignKey(
name: "FK_purchase_invoices_users_CreatedBy",
column: x => x.CreatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_purchase_invoices_vendors_VendorId",
column: x => x.VendorId,
principalTable: "vendors",
principalColumn: "VendorId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "purchase_invoice_lines",
columns: table => new
{
PurchaseInvoiceLineId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
PurchaseInvoiceId = table.Column<int>(type: "integer", nullable: false),
GrnId = table.Column<int>(type: "integer", nullable: false),
Subtotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
VatTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
LineTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_purchase_invoice_lines", x => x.PurchaseInvoiceLineId);
table.ForeignKey(
name: "FK_purchase_invoice_lines_grns_GrnId",
column: x => x.GrnId,
principalTable: "grns",
principalColumn: "GrnId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_purchase_invoice_lines_purchase_invoices_PurchaseInvoiceId",
column: x => x.PurchaseInvoiceId,
principalTable: "purchase_invoices",
principalColumn: "PurchaseInvoiceId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "purchase_invoice_payments",
columns: table => new
{
PurchaseInvoicePaymentId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
PurchaseInvoiceId = table.Column<int>(type: "integer", nullable: false),
Amount = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
PaymentDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
GlBankAccountId = table.Column<long>(type: "bigint", nullable: false),
BankAccountName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Reference = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
GlJournalNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: true),
CreatedBy = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_purchase_invoice_payments", x => x.PurchaseInvoicePaymentId);
table.ForeignKey(
name: "FK_purchase_invoice_payments_purchase_invoices_PurchaseInvoice~",
column: x => x.PurchaseInvoiceId,
principalTable: "purchase_invoices",
principalColumn: "PurchaseInvoiceId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_purchase_invoice_payments_users_CreatedBy",
column: x => x.CreatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.InsertData(
table: "sub_nav_items",
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
values: new object[] { 28, "procurement.purchase-invoices", "/dashboard/procurement/purchase-invoices", null, "Purchase Invoices", 4, 5 });
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[] { 39, "NAV:procurement.purchase-invoices", null, 28 });
migrationBuilder.CreateIndex(
name: "IX_grns_PurchaseInvoiceId",
table: "grns",
column: "PurchaseInvoiceId");
migrationBuilder.CreateIndex(
name: "IX_purchase_invoice_lines_GrnId",
table: "purchase_invoice_lines",
column: "GrnId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_purchase_invoice_lines_PurchaseInvoiceId",
table: "purchase_invoice_lines",
column: "PurchaseInvoiceId");
migrationBuilder.CreateIndex(
name: "IX_purchase_invoice_payments_CreatedBy",
table: "purchase_invoice_payments",
column: "CreatedBy");
migrationBuilder.CreateIndex(
name: "IX_purchase_invoice_payments_PurchaseInvoiceId",
table: "purchase_invoice_payments",
column: "PurchaseInvoiceId");
migrationBuilder.CreateIndex(
name: "IX_purchase_invoices_CreatedBy",
table: "purchase_invoices",
column: "CreatedBy");
migrationBuilder.CreateIndex(
name: "IX_purchase_invoices_DocNo",
table: "purchase_invoices",
column: "DocNo",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_purchase_invoices_Status",
table: "purchase_invoices",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_purchase_invoices_VendorId",
table: "purchase_invoices",
column: "VendorId");
migrationBuilder.CreateIndex(
name: "IX_purchase_invoices_VendorId_VendorInvoiceNo",
table: "purchase_invoices",
columns: new[] { "VendorId", "VendorInvoiceNo" });
migrationBuilder.AddForeignKey(
name: "FK_grns_purchase_invoices_PurchaseInvoiceId",
table: "grns",
column: "PurchaseInvoiceId",
principalTable: "purchase_invoices",
principalColumn: "PurchaseInvoiceId",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_grns_purchase_invoices_PurchaseInvoiceId",
table: "grns");
migrationBuilder.DropTable(
name: "purchase_invoice_lines");
migrationBuilder.DropTable(
name: "purchase_invoice_payments");
migrationBuilder.DropTable(
name: "purchase_invoices");
migrationBuilder.DropIndex(
name: "IX_grns_PurchaseInvoiceId",
table: "grns");
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 39);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 28);
migrationBuilder.DropColumn(
name: "PurchaseInvoiceId",
table: "grns");
migrationBuilder.AddColumn<decimal>(
name: "BalanceAmount",
table: "grns",
type: "numeric(18,4)",
precision: 18,
scale: 4,
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "PaidAmount",
table: "grns",
type: "numeric(18,4)",
precision: 18,
scale: 4,
nullable: false,
defaultValue: 0m);
migrationBuilder.CreateTable(
name: "grn_payments",
columns: table => new
{
GrnPaymentId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
CreatedBy = table.Column<int>(type: "integer", nullable: false),
GrnId = table.Column<int>(type: "integer", nullable: false),
Amount = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
BankAccountName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
GlBankAccountId = table.Column<long>(type: "bigint", nullable: false),
GlJournalNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: true),
PaymentDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
Reference = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_grn_payments", x => x.GrnPaymentId);
table.ForeignKey(
name: "FK_grn_payments_grns_GrnId",
column: x => x.GrnId,
principalTable: "grns",
principalColumn: "GrnId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_grn_payments_users_CreatedBy",
column: x => x.CreatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_grn_payments_CreatedBy",
table: "grn_payments",
column: "CreatedBy");
migrationBuilder.CreateIndex(
name: "IX_grn_payments_GrnId",
table: "grn_payments",
column: "GrnId");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,29 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddBranchPhoneNumberAndCodeAutogen : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "PhoneNumber",
table: "hr_branches",
type: "character varying(30)",
maxLength: 30,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PhoneNumber",
table: "hr_branches");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,38 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddSettingsBranchesNavItem : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
table: "sub_nav_items",
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
values: new object[] { 29, "settings.branches", "/dashboard/settings/branches", null, "Branches", 9, 0 });
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[] { 40, "NAV:settings.branches", null, 29 });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 40);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 29);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,50 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class MakeEmployeeBranchRequired : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Branch becomes a required field on Employee going forward. Any pre-existing
// employee left with no branch is backfilled to a "Main Branch" placeholder
// (created only if it doesn't already exist) so the NOT NULL below never fails
// on live data.
migrationBuilder.Sql(@"
INSERT INTO hr_branches (""Code"", ""Name"", ""Status"", ""CreatedAt"")
SELECT 'BR-000', 'Main Branch', 'Active', now()
WHERE NOT EXISTS (SELECT 1 FROM hr_branches WHERE ""Code"" = 'BR-000')
AND EXISTS (SELECT 1 FROM hr_employees WHERE ""BranchId"" IS NULL);
UPDATE hr_employees
SET ""BranchId"" = (SELECT ""BranchId"" FROM hr_branches WHERE ""Code"" = 'BR-000')
WHERE ""BranchId"" IS NULL;
");
migrationBuilder.AlterColumn<int>(
name: "BranchId",
table: "hr_employees",
type: "integer",
nullable: false,
oldClrType: typeof(int),
oldType: "integer",
oldNullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
name: "BranchId",
table: "hr_employees",
type: "integer",
nullable: true,
oldClrType: typeof(int),
oldType: "integer");
}
}
}
File diff suppressed because it is too large Load Diff

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