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