Compare commits

..

60 Commits

Author SHA1 Message Date
ImanThiyanga 8d5a05a419 Add smoke tests for UOM conversions and enhance frontend UOM management
- Implemented smoke tests for UOM directionality, ensuring conversions are one-directional and correctly validated.
- Added tests for receiving and selling items in different UOMs, verifying correct quantity handling and error responses.
- Created a UOM conversions panel in the frontend to allow users to manage UOM conversions for items.
- Introduced hooks for allowed UOMs to optimize fetching and caching of UOM data for document line forms.
- Developed utility functions for consistent UOM formatting and conversion handling across the application.
2026-08-10 15:12:51 +05:30
ImanThiyanga d37824cecc fronted fix 2026-08-07 16:40:31 +05:30
ImanThiyanga 271c940640 created new hosted db update 2026-08-07 14:54:35 +05:30
ImanThiyanga f2825900aa Merge pull request 'Fix bundle sale service interface signature' (#36) from test/sales into Dev
Reviewed-on: #36
2026-08-07 06:18:32 +00:00
ImanThiyanga 6520930aeb migrations removed 2026-08-07 11:42:07 +05:30
DeepnaPooja a8c6b4cb5e Fix bundle sale service interface signature 2026-08-07 11:41:52 +05:30
ImanThiyanga 8e9974b735 Merge pull request 'fix: update toast messages and improve UI elements in purchase orders and stock pages' (#34) from ui-fixers-7/8 into Dev
Reviewed-on: #34
2026-08-07 05:17:04 +00:00
Sasanka c4e016c460 fix: update toast messages and improve UI elements in purchase orders and stock pages 2026-08-07 10:46:23 +05:30
ImanThiyanga 7e8418685c Merge pull request 'fix sales issues' (#33) from fix--Sales_issues into Dev
Reviewed-on: #33
2026-08-07 05:13:02 +00:00
DeepnaPooja cbc72ef830 fix sales issues 2026-08-05 17:03:49 +05:30
ImanThiyanga 4324ba1a96 Merge branch 'Dev' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core into Dev 2026-08-05 16:16:50 +05:30
ImanThiyanga 1af16d3dec fix: update DefaultConnection string for development environment 2026-08-05 16:16:15 +05:30
ImanThiyanga f140959b43 Merge pull request 'feat(e2e): add Playwright end-to-end tests for authentication, GRN, production, stock transfers, and adjustments' (#32) from test/rebase into Dev
Reviewed-on: #32
2026-08-05 08:04:42 +00:00
ImanThiyanga c31e23c2b9 feat(e2e): add Playwright end-to-end tests for authentication, GRN, production, stock transfers, and adjustments
- Introduced Playwright configuration for e2e testing.
- Implemented authentication tests to validate login functionality.
- Created tests for GRN (Goods Receipt Note) to ensure proper stock handling.
- Developed production run tests to verify lifecycle and stock posting.
- Added stock transfer tests to check movement between warehouses.
- Implemented stock adjustment tests for positive and negative adjustments.
- Established API seeder for test data setup and verification.
- Enhanced utility functions for UI interactions and response handling.
2026-08-05 13:26:41 +05:30
ImanThiyanga 8e24ed6375 Merge branch 'production' into Dev 2026-08-05 07:22:25 +00:00
ImanThiyanga 9f22026784 Merge pull request 'Fix/general ledger v01' (#30) from fix/general-ledger-v01 into Dev
Reviewed-on: #30
2026-08-05 07:22:12 +00:00
ImanThiyanga ef105302bd Merge pull request 'fix: map GL response integers to string enums for Cheque Management fields' (#29) from fix/general-ledger-v01 into production
Reviewed-on: #29
2026-08-05 07:20:28 +00:00
ImanThiyanga d7ee83828c Merge branch 'production' into fix/general-ledger-v01 2026-08-05 07:20:23 +00:00
HarithaRandunu eb7b2691df fix: map GL response integers to string enums for Cheque Management fields 2026-08-05 12:45:34 +05:30
ImanThiyanga 1a0fb4603e Merge pull request 'Dev' (#28) from Dev into production
Reviewed-on: #28
2026-08-05 06:59:22 +00:00
ImanThiyanga 5fc5ef59ac Merge pull request 'feat: enhance purchase orders management with delete and approve actions' (#27) from fixers-frontend-8/5 into Dev
Reviewed-on: #27
2026-08-05 06:58:46 +00:00
Sasanka d1fe164ea2 fix: resolve import conflict in purchase order detail page 2026-08-05 12:21:01 +05:30
Sasanka 02f47bd485 Merge branch 'fixers-frontend-8/5' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core into fixers-frontend-8/5 2026-08-05 12:15:28 +05:30
Sasanka 45554ceb9a feat: enhance purchase orders management with delete and approve actions
- Added delete and approve functionality for purchase orders in the procurement dashboard.
- Integrated toast notifications for user feedback on actions.
- Updated the purchase orders table to include action buttons for viewing, editing, approving, and deleting orders.

refactor: improve stage editor panel UI and functionality

- Refactored StageEditorPanel to use a dialog for better user experience.
- Enhanced input handling and added custom fields management.
- Improved layout and organization of inputs and outputs sections.

fix: streamline template builder page layout

- Adjusted layout of the template builder page for better responsiveness and usability.
- Ensured the StageEditorPanel is displayed correctly when a node is selected.

feat: implement vendor code generation logic

- Created a new utility function to generate unique vendor codes based on the vendor name.
- Updated the vendor creation form to auto-generate vendor codes and display them to the user.
- Removed manual vendor code input requirement, improving user experience and reducing errors.
2026-08-05 12:14:08 +05:30
Sasanka 5d0ea3f035 feat: enhance purchase orders management with delete and approve actions
- Added delete and approve functionality for purchase orders in the procurement dashboard.
- Integrated toast notifications for user feedback on actions.
- Updated the purchase orders table to include action buttons for viewing, editing, approving, and deleting orders.

refactor: improve stage editor panel UI and functionality

- Refactored StageEditorPanel to use a dialog for better user experience.
- Enhanced input handling and added custom fields management.
- Improved layout and organization of inputs and outputs sections.

fix: streamline template builder page layout

- Adjusted layout of the template builder page for better responsiveness and usability.
- Ensured the StageEditorPanel is displayed correctly when a node is selected.

feat: implement vendor code generation logic

- Created a new utility function to generate unique vendor codes based on the vendor name.
- Updated the vendor creation form to auto-generate vendor codes and display them to the user.
- Removed manual vendor code input requirement, improving user experience and reducing errors.
2026-08-05 10:56:05 +05:30
DeepnaPooja 26cf2a146a Merge pull request 'centralize sales flow' (#26) from fix-sales-module into Dev
Reviewed-on: #26
2026-08-04 11:58:10 +00:00
DeepnaPooja 0750773f94 centralize sales flow 2026-08-04 17:19:30 +05:30
ImanThiyanga 38c7545413 removing migrations 2026-08-04 16:14:12 +05:30
DeepnaPooja 0d60aeef64 Merge pull request 'Enhance/sales' (#25) from enhance/sales into Dev
Reviewed-on: #25
2026-08-04 06:06:31 +00:00
DeepnaPooja c6bc8065a2 DB issue fix 2026-08-04 11:27:39 +05:30
DeepnaPooja 4f56d481a2 Add bundle sales front end integration 2026-08-04 11:18:32 +05:30
DeepnaPooja d79371697e Add bundle sales backend implementation 2026-08-04 11:17:21 +05:30
ImanThiyanga f7a65b5f7e fixed dbContext 2026-08-03 12:18:44 +05:30
ashan_rusiru 6b216195c6 Merge pull request 'Feat/sales management' (#24) from feat/sales-management into Dev
Reviewed-on: #24
2026-08-02 04:56:51 +00:00
DeepnaPooja 266a2a2c14 implement fe with backend 2026-08-02 01:25:17 +05:30
DeepnaPooja 3c5b476635 sales ,invoice ,slip and free issues BE fixes 2026-08-02 01:12:09 +05:30
DeepnaPooja 59af50bf11 Add sales slip document 2026-08-02 01:07:01 +05:30
DeepnaPooja 0b9d64f911 Add sales invoice document 2026-08-02 01:05:09 +05:30
DeepnaPooja 6258ebd8de implemnet customer master used for both B2B and B2C. 2026-08-02 00:59:16 +05:30
ImanThiyanga 37c8da2ced removed all migration updated 2026-08-01 08:45:10 +05:30
ImanThiyanga b7f9f599eb Merge pull request 'Feat/sales management' (#23) from feat/sales-management into Dev
Reviewed-on: #23
2026-08-01 01:47:27 +00:00
ImanThiyanga f43a8a8486 Merge pull request 'Feat/general ledger service' (#22) from feat/general-ledger-service into Dev
Reviewed-on: #22
2026-08-01 01:46:45 +00:00
HarithaRandunu a271d1832e fix: resolve repo-wide bug with missing HRM and Manufacturing tables in EF model
- Identified and fixed a critical issue where 36 tables existed in the EF model but were absent in the actual database.
- Root cause traced to a `.gitignore` rule that prevented migration files from being tracked, leading to discrepancies between the model snapshot and the database.
- Removed problematic entities from the snapshot, generated a migration to add the missing tables, and confirmed successful application.
- Reverted the `.gitignore` rule to ensure future migrations are tracked properly.
- No changes to existing tables, ensuring no collateral schema drift occurred.
2026-07-31 18:57:32 +05:30
ImanThiyanga 6ebdbb655a changes for db 2026-07-31 18:11:40 +05:30
HarithaRandunu 22657f0910 feat: Implement new General Ledger frontend section with comprehensive report screens and cash/bank account management
- Added a new Ledgers sidebar section for statutory-format financial reports and cash/bank-account management.
- Introduced dedicated GL client for API interactions, handling response envelopes and error management.
- Developed report screens for Trial Balance, Balance Sheet, General Ledger, Profit & Loss, Cash Flow, Budget vs Actual, and a new Tax Report.
- Implemented CSV download functionality alongside existing PDF downloads for all report screens.
- Separated Cash and Bank accounts into distinct tables/endpoints, with updated create forms and unified list view.
- Created a new Accounts section for Cheque Management, moving Cash/Bank Accounts from the Ledgers section.
- Updated RBAC navigation to include new permissions and sub-navigation items for the added features.
- Ensured compliance with GL's updated API contract, including renaming fields and adjusting response shapes.
- Addressed various bugs and presentation issues, enhancing user experience across the new module.
2026-07-31 18:02:03 +05:30
DeepnaPooja 74d3e684d2 Add sales report functions 2026-07-31 17:59:06 +05:30
DeepnaPooja 2dab7051b3 Add discoont rule 2026-07-31 17:59:05 +05:30
DeepnaPooja 4f722432cd add shared pricing resolution with fixed yem price,GRN weighted and FIFO valuation cost fallback 2026-07-31 17:59:05 +05:30
DeepnaPooja 1f9e12e84b Add sales slip document 2026-07-31 17:59:05 +05:30
DeepnaPooja 8b8e79e0fe Add sales invoice document 2026-07-31 17:59:05 +05:30
DeepnaPooja ffbd47f6f9 implemnet customer master used for both B2B and B2C. 2026-07-31 17:58:14 +05:30
Kalana B. Thilakarathna a414dfc4ea graphify update 2026-07-31 12:01:42 +05:30
Kalana B. Thilakarathna 3cccaf4c63 graphify added 2026-07-31 11:53:20 +05:30
ImanThiyanga 76484c7268 Merge pull request 'Refactor: Remove unused imports and clean up code across multiple pages' (#21) from fix/frontend-changers into Dev
Reviewed-on: #21
2026-07-31 05:42:56 +00:00
Sasanka 96f81cb03a Refactor: Remove unused imports and clean up code across multiple pages
- Removed unused Link imports and associated code for navigation in various pages.
- Simplified TableHeader components by removing unnecessary class names.
- Updated alert dialog styles for better consistency with the design system.
- Cleaned up imports in several components to streamline the codebase.
2026-07-31 11:04:32 +05:30
ImanThiyanga b7bd8dca5c Merge pull request 'Dev' (#20) from Dev into production
Reviewed-on: #20
2026-07-31 05:24:39 +00:00
ImanThiyanga cb3f1e5cde Merge branch 'production' into Dev 2026-07-31 05:24:32 +00:00
ImanThiyanga c3fef88bcf Merge pull request 'feat: add production templates API and documentation for manufacturing phase 2' (#19) from feat/production-integration into Dev
Reviewed-on: #19
2026-07-31 04:54:00 +00:00
ImanThiyanga 7d6e597389 feat: add production templates API and documentation for manufacturing phase 2
- Implemented CRUD operations for production templates, including listing, retrieving, creating, updating, and deactivating templates.
- Introduced a new API contract for production runs, detailing the lifecycle from creation to completion, including handling of stock inputs and outputs.
- Documented the architecture, requirements, entity model, and API contract for the manufacturing phase 2, ensuring clarity on the production process and its integration with existing systems.
2026-07-31 10:22:40 +05:30
ImanThiyanga 8484601494 Merge pull request 'Dev' (#13) from Dev into production
Reviewed-on: #13
2026-07-22 06:12:34 +00:00
293 changed files with 31355 additions and 19315 deletions
+9 -5
View File
@@ -30,9 +30,13 @@ yarn-error.log*
Thumbs.db
.idea/
# ── Playwright E2E (Testing/e2e) ──────────────────────────────────────
Testing/e2e/playwright-report/
Testing/e2e/test-results/
Testing/e2e/.auth/
Testing/e2e/blob-report/
# ── Migrations ─────────────────────────────────────────────────────────
# New EF Core migrations are not committed. Note the 4 migrations already in
# Backend/ERPCore/Infra/Persistence/Migrations/ stay tracked — .gitignore does
# not apply to tracked files — so edits to those still get committed as normal.
# Untracking them too takes `git rm --cached`.
**/Migrations/
# Each dev keeps EF Core migrations local; DB schema changes are announced
# to the team instead of committed, so migration files aren't shared here.
Migrations/
@@ -0,0 +1,80 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/bundle-sales")]
public sealed class BundleSalesController : ApiControllerBase
{
private readonly IBundleSaleService _bundles;
public BundleSalesController(IBundleSaleService bundles) => _bundles = bundles;
[HttpGet("templates")]
[ProducesResponseType(typeof(PagedResponse<BundleSaleTemplateSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<BundleSaleTemplateSummaryDto>>> ListTemplates(
[FromQuery] PageQuery query,
CancellationToken ct)
=> Ok(await _bundles.ListTemplatesAsync(query, ct));
[HttpGet("templates/{bundleSaleTemplateId:int}")]
[ProducesResponseType(typeof(BundleSaleTemplateDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<BundleSaleTemplateDto>> GetTemplate(int bundleSaleTemplateId, CancellationToken ct)
{
var result = await _bundles.GetTemplateAsync(bundleSaleTemplateId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<BundleSaleSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<BundleSaleSummaryDto>>> List(
[FromQuery] PageQuery query,
[FromQuery] BundleSaleStatus? status,
[FromQuery] int? customerId,
[FromQuery] int? warehouseId,
CancellationToken ct)
=> Ok(await _bundles.ListAsync(query, status, customerId, warehouseId, ct));
[HttpGet("{bundleSaleId:int}")]
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<BundleSaleDto>> GetById(int bundleSaleId, CancellationToken ct)
{
var result = await _bundles.GetAsync(bundleSaleId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("{bundleSaleId:int}/posting-check")]
[ProducesResponseType(typeof(BundleSalePostingCheckDto), StatusCodes.Status200OK)]
public async Task<ActionResult<BundleSalePostingCheckDto>> PostingCheck(int bundleSaleId, CancellationToken ct)
=> Ok(await _bundles.CheckPostingAsync(bundleSaleId, ct));
[HttpPost]
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status201Created)]
public async Task<ActionResult<BundleSaleDto>> Create([FromBody] CreateBundleSaleRequest request, CancellationToken ct)
{
var result = await _bundles.CreateAsync(request, ct);
return Created($"/api/v1/bundle-sales/{result.BundleSaleId}", result);
}
[HttpPut("{bundleSaleId:int}")]
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
public async Task<ActionResult<BundleSaleDto>> Update(int bundleSaleId, [FromBody] UpdateBundleSaleRequest request, CancellationToken ct)
{
return Ok(await _bundles.UpdateAsync(bundleSaleId, request, ct));
}
[HttpPost("{bundleSaleId:int}/post")]
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
public async Task<ActionResult<BundleSaleDto>> Post(int bundleSaleId, CancellationToken ct)
=> Ok(await _bundles.PostAsync(bundleSaleId, ct));
[HttpPost("{bundleSaleId:int}/cancel")]
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
public async Task<ActionResult<BundleSaleDto>> Cancel(int bundleSaleId, CancellationToken ct)
=> Ok(await _bundles.CancelAsync(bundleSaleId, ct));
}
@@ -0,0 +1,69 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Customers;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Customer master endpoints for Phase 1 sales.</summary>
[Route("api/v1/customers")]
public sealed class CustomersController : ApiControllerBase
{
private readonly ICustomerService _customers;
public CustomersController(ICustomerService customers) => _customers = customers;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<CustomerDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<CustomerDto>>> List(
[FromQuery] PageQuery query,
[FromQuery] EntityStatus? status,
[FromQuery] CustomerType? customerType,
CancellationToken ct)
=> Ok(await _customers.ListAsync(query, status, customerType, ct));
[HttpGet("{customerId:int}")]
[ProducesResponseType(typeof(CustomerDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CustomerDto>> GetById(int customerId, CancellationToken ct)
{
var result = await _customers.GetAsync(customerId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(CustomerDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<CustomerDto>> Create([FromBody] CreateCustomerRequest request, CancellationToken ct)
{
var result = await _customers.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/customers/{result.Value.CustomerId}", result.Value);
}
[HttpPut("{customerId:int}")]
[ProducesResponseType(typeof(CustomerDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<CustomerDto>> Update(int customerId, [FromBody] UpdateCustomerRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _customers.UpdateAsync(customerId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{customerId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int customerId, [FromBody] UpdateCustomerStatusRequest request, CancellationToken ct)
{
await _customers.SetStatusAsync(customerId, request.Status, ct);
return NoContent();
}
}
@@ -0,0 +1,78 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Thin API alias for free-issue handling. Free issues are modeled as sales slips
/// with line-level <c>IsFreeIssue</c>/<c>FreeQty</c> flags, so this controller reuses
/// the existing sales-slip CRUD surface under a more business-friendly route.
/// </summary>
[Route("api/v1/free-issues")]
public sealed class FreeIssuesController : ApiControllerBase
{
private readonly ISalesSlipService _slips;
public FreeIssuesController(ISalesSlipService slips) => _slips = slips;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<FreeIssueSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<FreeIssueSummaryDto>>> List(
[FromQuery] PageQuery query,
[FromQuery] SalesSlipStatus? status,
[FromQuery] int? customerId,
[FromQuery] int? warehouseId,
CancellationToken ct)
=> Ok(await _slips.ListFreeIssuesAsync(query, status, customerId, warehouseId, ct));
[HttpGet("{freeIssueId:int}")]
[ProducesResponseType(typeof(FreeIssueDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<FreeIssueDto>> GetById(int freeIssueId, CancellationToken ct)
{
var result = await _slips.GetFreeIssueAsync(freeIssueId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpGet("{freeIssueId:int}/posting-check")]
[ProducesResponseType(typeof(SalesSlipPostingCheckDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesSlipPostingCheckDto>> PostingCheck(int freeIssueId, CancellationToken ct)
=> Ok(await _slips.CheckPostingAsync(freeIssueId, ct));
[HttpPost]
[ProducesResponseType(typeof(FreeIssueDto), StatusCodes.Status201Created)]
public async Task<ActionResult<FreeIssueDto>> Create([FromBody] CreateSalesSlipRequest request, CancellationToken ct)
{
var result = await _slips.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/free-issues/{result.Value.SalesSlipId}", result.Value);
}
[HttpPut("{freeIssueId:int}")]
[ProducesResponseType(typeof(FreeIssueDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<FreeIssueDto>> Update(int freeIssueId, [FromBody] UpdateSalesSlipRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _slips.UpdateAsync(freeIssueId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost("{freeIssueId:int}/post")]
[ProducesResponseType(typeof(FreeIssueDto), StatusCodes.Status200OK)]
public async Task<ActionResult<FreeIssueDto>> Post(int freeIssueId, CancellationToken ct)
=> Ok(await _slips.PostAsync(freeIssueId, ct));
[HttpPost("{freeIssueId:int}/cancel")]
[ProducesResponseType(typeof(FreeIssueDto), StatusCodes.Status200OK)]
public async Task<ActionResult<FreeIssueDto>> Cancel(int freeIssueId, CancellationToken ct)
=> Ok(await _slips.CancelAsync(freeIssueId, ct));
}
@@ -0,0 +1,43 @@
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Generic reverse proxy into the external General Ledger service — forwards every
/// method/path/query/body under this prefix verbatim via
/// <see cref="IGeneralLedgerService"/> and returns GL's response (status, content-type,
/// body) unchanged. No endpoint-specific shape lives here; see
/// docs/12-GENERAL-LEDGER-INTEGRATION.md for the full GL contract and what this proxy
/// does and doesn't do. Gated by the same ERP door policy as every other v1 endpoint
/// (<see cref="ApiControllerBase"/>) — the shared GL API key is attached server-side
/// only and is never exposed to the frontend.
/// </summary>
[Route("api/v1/gl")]
public sealed class GeneralLedgerController : ApiControllerBase
{
private readonly IGeneralLedgerService _gl;
public GeneralLedgerController(IGeneralLedgerService gl) => _gl = gl;
[HttpGet("{**path}")]
public Task<IActionResult> Get(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Get, path, ct);
[HttpPost("{**path}")]
public Task<IActionResult> Post(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Post, path, ct);
[HttpPut("{**path}")]
public Task<IActionResult> Put(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Put, path, ct);
private async Task<IActionResult> ForwardAsync(HttpMethod method, string path, CancellationToken ct)
{
var body = method == HttpMethod.Get ? null : Request.Body;
var result = await _gl.ForwardAsync(method, path, Request.QueryString.Value, Request.ContentType, body, ct);
return new ContentResult
{
StatusCode = result.StatusCode,
Content = result.Body,
ContentType = result.ContentType ?? "application/json"
};
}
}
+18 -1
View File
@@ -1,6 +1,7 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Items;
using ERPCore.Dtos.Uoms;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -11,8 +12,13 @@ namespace ERPCore.Controllers;
public sealed class ItemsController : ApiControllerBase
{
private readonly IItemService _items;
private readonly IUomConverter _uomConverter;
public ItemsController(IItemService items) => _items = items;
public ItemsController(IItemService items, IUomConverter uomConverter)
{
_items = items;
_uomConverter = uomConverter;
}
/// <summary>List items with optional filters and paging.</summary>
[HttpGet]
@@ -88,4 +94,15 @@ public sealed class ItemsController : ApiControllerBase
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ItemUomConversionsDto>> UpdateUomConversions(int itemId, [FromBody] UpdateUomConversionsRequest request, CancellationToken ct)
=> Ok(await _items.UpdateUomConversionsAsync(itemId, request, ct));
/// <summary>
/// The UOMs this item may be transacted in — its base UOM plus every UOM it has a
/// conversion from, each with the factor to base. Document line forms use this to offer
/// only units that will survive posting, instead of the whole global UOM list (FR-MD-02/03).
/// </summary>
[HttpGet("{itemId:int}/uoms")]
[ProducesResponseType(typeof(IReadOnlyList<AllowedUomDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<IReadOnlyList<AllowedUomDto>>> GetAllowedUoms(int itemId, CancellationToken ct)
=> Ok(await _uomConverter.GetAllowedUomsAsync(itemId, ct));
}
@@ -0,0 +1,157 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Production;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Production run endpoints (docs/30-BACKEND-PHASE2.md §D.2D.3).</summary>
[Route("api/v1/production-runs")]
public sealed class ProductionRunsController : ApiControllerBase
{
private readonly IProductionRunService _runs;
public ProductionRunsController(IProductionRunService runs) => _runs = runs;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<RunSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<RunSummaryDto>>> List(
[FromQuery] PageQuery query,
[FromQuery] ProductionRunStatus? status,
[FromQuery] int? templateId,
[FromQuery] int? warehouseId,
CancellationToken ct)
=> Ok(await _runs.ListAsync(query, status, templateId, warehouseId, ct));
[HttpGet("{runId:int}")]
[ProducesResponseType(typeof(RunGraphDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<RunGraphDto>> GetById(int runId, CancellationToken ct)
{
var result = await _runs.GetAsync(runId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(RunGraphDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<RunGraphDto>> Create([FromBody] CreateRunRequest request, CancellationToken ct)
{
var result = await _runs.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/production-runs/{result.Value.RunId}", result.Value);
}
[HttpPut("{runId:int}/stages/{runStageId:int}/quantities")]
[ProducesResponseType(typeof(RunStageDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<RunStageDto>> UpdateQuantities(
int runId, int runStageId, [FromBody] UpdateStageQuantitiesRequest request, CancellationToken ct)
=> Ok(await _runs.UpdateStageQuantitiesAsync(runId, runStageId, request, ct));
// --- stage actions (docs/30 §D.3) ---------------------------------------
//
// Idempotency-Key is accepted on every action to match the Phase-1 contract (docs/11
// §1.6) but, as in GrnService.ConfirmAsync, it is not stored. Replay safety comes from
// the status guards instead: a double-fire finds the stage already moved on and gets a
// 409, which docs/21 §6 tells the client to treat as a silent refetch. Recorded as a
// deviation from §D.3's "idempotency-key honored".
[HttpPost("{runId:int}/stages/{runStageId:int}/start")]
[ProducesResponseType(typeof(StartStageResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<StartStageResultDto>> Start(
int runId, int runStageId,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
CancellationToken ct)
=> Ok(await _runs.StartStageAsync(runId, runStageId, ct));
[HttpPost("{runId:int}/stages/{runStageId:int}/complete")]
[ProducesResponseType(typeof(RunStageDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<RunStageDto>> Complete(
int runId, int runStageId, [FromBody] CompleteStageRequest request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
CancellationToken ct)
=> Ok(await _runs.CompleteStageAsync(runId, runStageId, request, ct));
[HttpPost("{runId:int}/stages/{runStageId:int}/approve")]
[ProducesResponseType(typeof(ApproveStageResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<ApproveStageResultDto>> Approve(
int runId, int runStageId, [FromBody] ApproveStageRequest? request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
CancellationToken ct)
=> Ok(await _runs.ApproveStageAsync(runId, runStageId, request ?? new ApproveStageRequest(), ct));
[HttpPost("{runId:int}/stages/{runStageId:int}/transfer")]
[ProducesResponseType(typeof(TransferResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<TransferResultDto>> Transfer(
int runId, int runStageId, [FromBody] TransferRemainderRequest request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
CancellationToken ct)
=> Ok(await _runs.TransferAsync(runId, runStageId, request, ct));
[HttpPost("{runId:int}/inputs/{runInputId:int}/return-leftover")]
[ProducesResponseType(typeof(ReturnLeftoverResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<ReturnLeftoverResultDto>> ReturnLeftover(
int runId, int runInputId, [FromBody] ReturnLeftoverRequest request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
CancellationToken ct)
=> Ok(await _runs.ReturnLeftoverAsync(runId, runInputId, request, ct));
[HttpPost("{runId:int}/stages/{runStageId:int}/reject-intake")]
[ProducesResponseType(typeof(RejectIntakeResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<RejectIntakeResultDto>> RejectIntake(
int runId, int runStageId, [FromBody] RejectRequest? request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
CancellationToken ct)
=> Ok(await _runs.RejectIntakeAsync(runId, runStageId, request ?? new RejectRequest(), ct));
/// <summary>Terminal reject — resets the whole run for a rework pass (FR-MFG-16).</summary>
[HttpPost("{runId:int}/stages/{runStageId:int}/reject")]
[ProducesResponseType(typeof(TerminalRejectResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<TerminalRejectResultDto>> Reject(
int runId, int runStageId, [FromBody] RejectRequest? request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
CancellationToken ct)
=> Ok(await _runs.RejectTerminalAsync(runId, runStageId, request ?? new RejectRequest(), ct));
[HttpPost("{runId:int}/cancel")]
[ProducesResponseType(typeof(CancelRunResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<CancelRunResultDto>> Cancel(
int runId, [FromBody] CancelRunRequest request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
CancellationToken ct)
=> Ok(await _runs.CancelAsync(runId, request, ct));
}
@@ -0,0 +1,73 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Production;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Production template endpoints (docs/30-BACKEND-PHASE2.md §D.1).</summary>
[Route("api/v1/production-templates")]
public sealed class ProductionTemplatesController : ApiControllerBase
{
private readonly IProductionTemplateService _templates;
public ProductionTemplatesController(IProductionTemplateService templates) => _templates = templates;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<TemplateSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<TemplateSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _templates.ListAsync(query, status, ct));
[HttpGet("{templateId:int}")]
[ProducesResponseType(typeof(TemplateGraphDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<TemplateGraphDto>> GetById(int templateId, CancellationToken ct)
{
var result = await _templates.GetAsync(templateId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(TemplateGraphDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<TemplateGraphDto>> Create(
[FromBody] SaveTemplateRequest request, CancellationToken ct)
{
var result = await _templates.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/production-templates/{result.Value.TemplateId}", result.Value);
}
[HttpPut("{templateId:int}")]
[ProducesResponseType(typeof(TemplateGraphDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<TemplateGraphDto>> Update(
int templateId, [FromBody] SaveTemplateRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _templates.UpdateAsync(templateId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{templateId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(
int templateId, [FromBody] UpdateTemplateStatusRequest request, CancellationToken ct)
{
await _templates.SetStatusAsync(templateId, request.Status, ct);
return NoContent();
}
}
@@ -0,0 +1,73 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/sales-invoices")]
public sealed class SalesInvoicesController : ApiControllerBase
{
private readonly ISalesInvoiceService _invoices;
public SalesInvoicesController(ISalesInvoiceService invoices) => _invoices = invoices;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<SalesInvoiceSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<SalesInvoiceSummaryDto>>> List(
[FromQuery] PageQuery query,
[FromQuery] SalesInvoiceStatus? status,
[FromQuery] int? customerId,
[FromQuery] int? warehouseId,
CancellationToken ct)
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, ct));
[HttpGet("{salesInvoiceId:int}")]
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesInvoiceDto>> GetById(int salesInvoiceId, CancellationToken ct)
{
var result = await _invoices.GetAsync(salesInvoiceId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpGet("{salesInvoiceId:int}/posting-check")]
[ProducesResponseType(typeof(SalesInvoicePostingCheckDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesInvoicePostingCheckDto>> PostingCheck(int salesInvoiceId, CancellationToken ct)
=> Ok(await _invoices.CheckPostingAsync(salesInvoiceId, ct));
[HttpPost]
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status201Created)]
public async Task<ActionResult<SalesInvoiceDto>> Create([FromBody] CreateSalesInvoiceRequest request, CancellationToken ct)
{
var result = await _invoices.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/sales-invoices/{result.Value.SalesInvoiceId}", result.Value);
}
[HttpPut("{salesInvoiceId:int}")]
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<SalesInvoiceDto>> Update(int salesInvoiceId, [FromBody] UpdateSalesInvoiceRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _invoices.UpdateAsync(salesInvoiceId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost("{salesInvoiceId:int}/post")]
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
public async Task<ActionResult<SalesInvoiceDto>> Post(int salesInvoiceId, CancellationToken ct)
=> Ok(await _invoices.PostAsync(salesInvoiceId, ct));
[HttpPost("{salesInvoiceId:int}/cancel")]
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
public async Task<ActionResult<SalesInvoiceDto>> Cancel(int salesInvoiceId, CancellationToken ct)
=> Ok(await _invoices.CancelAsync(salesInvoiceId, ct));
}
@@ -0,0 +1,35 @@
using ERPCore.Dtos.Sales;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/reports/sales")]
public sealed class SalesReportsController : ApiControllerBase
{
private readonly ISalesReportService _reports;
public SalesReportsController(ISalesReportService reports) => _reports = reports;
[HttpGet]
[ProducesResponseType(typeof(IReadOnlyList<SalesReportDefinitionDto>), StatusCodes.Status200OK)]
public ActionResult<IReadOnlyList<SalesReportDefinitionDto>> ListReports()
=> Ok(_reports.ListReports());
[HttpGet("{reportId}")]
[ProducesResponseType(typeof(SalesReportDefinitionDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<SalesReportDefinitionDto> GetReport(string reportId)
{
var report = _reports.GetReport(reportId);
return report is null ? NotFound() : Ok(report);
}
[HttpPost("query")]
[ProducesResponseType(typeof(SalesReportQueryResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<SalesReportQueryResponse>> Query([FromBody] SalesReportQueryRequest request, CancellationToken ct)
{
var rows = await _reports.QueryAsync(request.ReportType, request.From, request.To, request.ItemId, request.CustomerId, request.WarehouseId, ct);
return Ok(new SalesReportQueryResponse(request.ReportType, request.From, request.To, rows));
}
}
@@ -0,0 +1,22 @@
using ERPCore.Dtos.Sales;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/sales-slips/{salesSlipId:int}/free-issue-suggestions")]
public sealed class SalesSlipPromotionsController : ApiControllerBase
{
private readonly ISalesPromotionSuggestionService _suggestions;
public SalesSlipPromotionsController(ISalesPromotionSuggestionService suggestions) => _suggestions = suggestions;
[HttpGet]
[ProducesResponseType(typeof(SalesFreeIssueSuggestionDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesFreeIssueSuggestionDto>> Get(int salesSlipId, CancellationToken ct)
{
var result = await _suggestions.GetFreeIssueSuggestionsAsync(salesSlipId, ct);
return result is null ? NotFound() : Ok(result);
}
}
@@ -0,0 +1,73 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/sales-slips")]
public sealed class SalesSlipsController : ApiControllerBase
{
private readonly ISalesSlipService _slips;
public SalesSlipsController(ISalesSlipService slips) => _slips = slips;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<SalesSlipSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<SalesSlipSummaryDto>>> List(
[FromQuery] PageQuery query,
[FromQuery] SalesSlipStatus? status,
[FromQuery] int? customerId,
[FromQuery] int? warehouseId,
CancellationToken ct)
=> Ok(await _slips.ListAsync(query, status, customerId, warehouseId, ct));
[HttpGet("{salesSlipId:int}")]
[ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesSlipDto>> GetById(int salesSlipId, CancellationToken ct)
{
var result = await _slips.GetAsync(salesSlipId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpGet("{salesSlipId:int}/posting-check")]
[ProducesResponseType(typeof(SalesSlipPostingCheckDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesSlipPostingCheckDto>> PostingCheck(int salesSlipId, CancellationToken ct)
=> Ok(await _slips.CheckPostingAsync(salesSlipId, ct));
[HttpPost]
[ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status201Created)]
public async Task<ActionResult<SalesSlipDto>> Create([FromBody] CreateSalesSlipRequest request, CancellationToken ct)
{
var result = await _slips.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/sales-slips/{result.Value.SalesSlipId}", result.Value);
}
[HttpPut("{salesSlipId:int}")]
[ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<SalesSlipDto>> Update(int salesSlipId, [FromBody] UpdateSalesSlipRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _slips.UpdateAsync(salesSlipId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost("{salesSlipId:int}/post")]
[ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status200OK)]
public async Task<ActionResult<SalesSlipDto>> Post(int salesSlipId, CancellationToken ct)
=> Ok(await _slips.PostAsync(salesSlipId, ct));
[HttpPost("{salesSlipId:int}/cancel")]
[ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status200OK)]
public async Task<ActionResult<SalesSlipDto>> Cancel(int salesSlipId, CancellationToken ct)
=> Ok(await _slips.CancelAsync(salesSlipId, ct));
}
+6
View File
@@ -14,4 +14,10 @@ public static class DocumentTypes
public const string Adjustment = "ADJ";
public const string Count = "CNT";
public const string PurchaseReturn = "PRET";
/// <summary>Production run (docs/30 FR-MFG-08) — <c>PRD-2026-00001</c>.</summary>
public const string Production = "PRD";
public const string SalesInvoice = "SI";
public const string SalesSlip = "SSL";
public const string BundleSale = "BND";
}
@@ -0,0 +1,33 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
public class BundleSale
{
public int BundleSaleId { get; set; }
public string BundleNo { get; set; } = string.Empty;
public DateTime BundleDate { get; set; }
public int CustomerId { get; set; }
public Customer? Customer { get; set; }
public string CustomerSnapshotName { get; set; } = string.Empty;
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public int CashierUserId { get; set; }
public User? CashierUser { get; set; }
public int BundleSaleTemplateId { get; set; }
public BundleSaleTemplate? BundleSaleTemplate { get; set; }
public string BundleName { get; set; } = string.Empty;
public string BundleCode { get; set; } = string.Empty;
public BundleSaleStatus Status { get; set; } = BundleSaleStatus.Draft;
public decimal ComponentSubtotal { get; set; }
public decimal BundlePrice { get; set; }
public decimal MarginAmount { get; set; }
public decimal DiscountTotal { get; set; }
public decimal TaxTotal { get; set; }
public decimal GrandTotal { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public int ConcurrencyStamp { get; set; }
public ICollection<BundleSaleLine> Lines { get; set; } = new List<BundleSaleLine>();
}
@@ -0,0 +1,35 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
public class BundleSaleLine
{
public int BundleSaleLineId { get; set; }
public int BundleSaleId { get; set; }
public BundleSale? BundleSale { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public string Description { get; set; } = string.Empty;
/// <summary>Component quantity, in <see cref="UomId"/> — what the user entered.</summary>
public decimal Qty { get; set; }
public int UomId { get; set; }
public Uom? Uom { get; set; }
/// <summary>
/// <see cref="Qty"/> restated in the item's base UOM, resolved once at save. Posting
/// consumes this. <see cref="Qty"/> and <see cref="UnitPrice"/> are always a matching
/// pair in <see cref="UomId"/>, so <see cref="LineTotal"/> stays value-correct.
/// </summary>
public decimal QtyBase { get; set; }
/// <summary>The factor used to derive <see cref="QtyBase"/>; 1 when the line is in base UOM.</summary>
public decimal ConversionFactor { get; set; } = 1m;
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public decimal UnitPrice { get; set; }
public decimal LineTotal { get; set; }
public bool IncludeInBundle { get; set; } = true;
public bool IsComponent { get; set; } = true;
public int? ParentLineId { get; set; }
}
@@ -0,0 +1,17 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
public class BundleSaleTemplate
{
public int BundleSaleTemplateId { get; set; }
public string TemplateCode { get; set; } = string.Empty;
public string TemplateName { get; set; } = string.Empty;
public string? Description { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public int ConcurrencyStamp { get; set; }
public ICollection<BundleSaleTemplateLine> Lines { get; set; } = new List<BundleSaleTemplateLine>();
}
@@ -0,0 +1,20 @@
namespace ERPCore.Domain.Entities;
public class BundleSaleTemplateLine
{
public int BundleSaleTemplateLineId { get; set; }
public int BundleSaleTemplateId { get; set; }
public BundleSaleTemplate? BundleSaleTemplate { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public int UomId { get; set; }
public Uom? Uom { get; set; }
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public decimal Qty { get; set; }
public decimal UnitPrice { get; set; }
public bool IncludeInBundle { get; set; } = true;
public int SortOrder { get; set; }
}
@@ -0,0 +1,38 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Customer master for both B2B and B2C sales.
/// Phase 1 keeps this lean: identity, contact, tax, credit, and default warehouse.
/// </summary>
public class Customer
{
public int CustomerId { get; set; }
public string CustomerCode { get; set; } = string.Empty;
public CustomerType CustomerType { get; set; } = CustomerType.B2C;
public string Name { get; set; } = string.Empty;
public string? DisplayName { get; set; }
public string? Phone { get; set; }
public string? Email { get; set; }
public string? AddressLine1 { get; set; }
public string? AddressLine2 { get; set; }
public string? City { get; set; }
public string? Country { get; set; }
public string? TaxRegistrationNo { get; set; }
public decimal CreditLimit { get; set; }
public int CreditDays { get; set; }
public int? DefaultWarehouseId { get; set; }
public Warehouse? DefaultWarehouse { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
public uint RowVersion { get; set; }
}
@@ -34,8 +34,20 @@ public class GrnLine
public int? BatchId { get; set; }
public Batch? Batch { get; set; }
/// <summary>Quantity received, in <see cref="UomId"/> — what the user entered.</summary>
public decimal Qty { get; set; }
/// <summary>
/// <see cref="Qty"/> restated in the item's base UOM, resolved once at line creation.
/// Confirm reads this snapshot rather than re-converting, so a conversion factor edited
/// between create and confirm cannot change what a saved GRN posts, and a later reversal
/// reproduces the original layer exactly.
/// </summary>
public decimal QtyBase { get; set; }
/// <summary>The factor used to derive <see cref="QtyBase"/>; 1 when the line is in base UOM.</summary>
public decimal ConversionFactor { get; set; } = 1m;
/// <summary>Gross unit cost received at (entered, or PO price when omitted).</summary>
public decimal UnitCost { get; set; }
+24
View File
@@ -21,8 +21,32 @@ public class PoLine
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
/// <summary>Quantity ordered, in <see cref="UomId"/>.</summary>
public decimal Qty { get; set; }
public decimal UnitPrice { get; set; }//
public decimal Tax { get; set; }
/// <summary>
/// <see cref="Qty"/> restated in the item's base UOM, resolved once at line creation.
/// This — not <see cref="Qty"/> — is what open-quantity and close checks compare against,
/// because a GRN may legitimately receive against this line in a different UOM.
/// </summary>
public decimal QtyBase { get; set; }
/// <summary>The factor used to derive <see cref="QtyBase"/>; 1 when the line is in base UOM.</summary>
public decimal ConversionFactor { get; set; } = 1m;
/// <summary>
/// Accrues as GRNs confirm (FR-PROC-07), in <see cref="UomId"/>. <b>Denormalized for
/// display only</b> — it is derived by dividing <see cref="QtyReceivedBase"/> by the
/// factor, so it can drift. Never branch on it; use <see cref="QtyReceivedBase"/>.
/// </summary>
public decimal QtyReceived { get; set; }
/// <summary>
/// Authoritative received-to-date in the item's base UOM. GRN confirm accrues here and
/// the PO close condition compares this against <see cref="QtyBase"/>, so receipts in a
/// UOM other than the PO's still add up correctly.
/// </summary>
public decimal QtyReceivedBase { get; set; }
}
@@ -0,0 +1,59 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// One execution instance of a template (FR-MFG-08), numbered <c>PRD-2026-00001</c>.
/// Every stage, input, output and edge is <b>copied</b> from the template at creation
/// with quantities scaled by <see cref="ScaleFactor"/>, so a completed run stays
/// readable even if the template is later edited (FR-MFG-06).
/// <para>The run's <b>cost pool</b> is derived, never stored:
/// <c>Σ RunStageInput.ConsumedValue Σ RunStageInput.ReturnedValue</c>. The terminal
/// approve divides it by the good quantity to cost the finished layer, then closes it
/// (FR-MFG-13, <c>409 RUN_COST_CLOSED</c>).</para>
/// Mutable aggregate with a <see cref="RowVersion"/> token. Model: docs/30 Part C.
/// </summary>
public class ProductionRun
{
public int RunId { get; set; }
public string DocNo { get; set; } = string.Empty;
public int TemplateId { get; set; }
public ProductionTemplate? Template { get; set; }
/// <summary>Stock inputs are consumed from, and the finished good received into, this warehouse.</summary>
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
/// <summary>Optional destination bin for the finished goods. Reaches the ledger only — stock layers carry no bin.</summary>
public int? OutputBinId { get; set; }
public Bin? OutputBin { get; set; }
/// <summary>Target quantity of the finished item; drives <see cref="ScaleFactor"/>.</summary>
public decimal TargetQty { get; set; }
/// <summary><c>TargetQty / terminalOutput.QtyPerBatch</c>, applied to every copied quantity.</summary>
public decimal ScaleFactor { get; set; }
public ProductionRunStatus Status { get; set; } = ProductionRunStatus.InProgress;
/// <summary>Incremented by each terminal reject (FR-MFG-16); prior figures live in the event history.</summary>
public int ReworkCount { get; set; }
public int? CancelReasonCodeId { get; set; }
public ReasonCode? CancelReason { get; set; }
public int CreatedBy { get; set; }
public User? Creator { get; set; }
public DateTime CreatedAt { get; set; }
/// <summary>Set by the terminal approve only. A cancelled run leaves this null.</summary>
public DateTime? CompletedAt { get; set; }
public uint RowVersion { get; set; }
public ICollection<RunStage> Stages { get; set; } = new List<RunStage>();
public ICollection<RunEdge> Edges { get; set; } = new List<RunEdge>();
public ICollection<RunStageEvent> Events { get; set; } = new List<RunStageEvent>();
}
@@ -0,0 +1,43 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// A reusable production-line definition — the stage graph designed on the canvas
/// (FR-MFG-01). Never hard-deleted once referenced by a run; deactivated instead
/// (FR-MD-08 posture). Editing is locked while any run of it is InProgress
/// (FR-MFG-06, <c>409 TEMPLATE_IN_USE</c>) — edit-lock replaces versioning, which is
/// why runs copy display fields at creation. Mutable aggregate with a
/// <see cref="RowVersion"/> token. Model: docs/30 Part C.
/// </summary>
public class ProductionTemplate
{
public int TemplateId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
/// <summary>
/// Canvas-only annotations (grouping boxes and divider lines) as a jsonb array, stored
/// verbatim and never interpreted server-side.
/// </summary>
/// <remarks>
/// Not in docs/30 Part C — added because the builder canvas already draws these and
/// without somewhere to keep them a save would silently discard the user's layout notes.
/// They carry no graph semantics: no ports, no edges, and the validator never sees them.
/// Nullable so a template that has none stores nothing rather than an empty array.
/// </remarks>
public string? Annotations { get; set; }
public int CreatedBy { get; set; }
public User? Creator { get; set; }
public DateTime CreatedAt { get; set; }
public uint RowVersion { get; set; }
public ICollection<TemplateStage> Stages { get; set; } = new List<TemplateStage>();
public ICollection<StageEdge> Edges { get; set; } = new List<StageEdge>();
public ICollection<ProductionRun> Runs { get; set; } = new List<ProductionRun>();
}
@@ -0,0 +1,28 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// A parent → child arrow copied from the template's <see cref="StageEdge"/> set at run
/// creation.
/// <para><b>Addition to docs/30 Part C (recorded).</b> The doc's entity model has no run
/// edge table, but the run graph needs its own copy: deriving edges at read time through
/// <c>RunStage.TemplateStageId → STAGE_EDGE</c> would let a later template edit silently
/// rewrite completed-run history — the exact thing FR-MFG-06 exists to prevent — and
/// breaks outright once that link is nulled by a stage deletion.</para>
/// <para>Used for the run canvas, child-readiness evaluation and reject-intake's
/// "delivering parents". Note that <b>WIP delivery is routed by
/// <c>RunStageInput.FromRunOutputId</c>, not by these edges</b> — an edge is display and
/// validation only.</para>
/// </summary>
public class RunEdge
{
public int RunEdgeId { get; set; }
public int RunId { get; set; }
public ProductionRun? Run { get; set; }
public int ParentRunStageId { get; set; }
public RunStage? ParentRunStage { get; set; }
public int ChildRunStageId { get; set; }
public RunStage? ChildRunStage { get; set; }
}
@@ -0,0 +1,59 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// One stage of a run — a copy of a <see cref="TemplateStage"/> taken at run creation
/// (FR-MFG-06), carrying its own live status and actual timings. Model: docs/30 Part C.
/// <para><b>Whether this stage is terminal is derived</b>, never stored: a stage is
/// terminal when it has no outbound <see cref="RunEdge"/>. Storing it would let it
/// drift from the edge set.</para>
/// </summary>
public class RunStage
{
public int RunStageId { get; set; }
public int RunId { get; set; }
public ProductionRun? Run { get; set; }
/// <summary>
/// Provenance link back to the template stage. <b>Nullable</b>: a template PUT may
/// delete a stage while completed/cancelled runs still reference it (the edit-lock
/// only blocks edits during InProgress runs), so the FK is <c>SET NULL</c> rather
/// than blocking the edit forever. Everything needed to display a historical run is
/// copied below, which is exactly what FR-MFG-06 anticipates.
/// </summary>
public int? TemplateStageId { get; set; }
public TemplateStage? TemplateStage { get; set; }
// --- copied from the template at run creation (FR-MFG-06) ---
public string Name { get; set; } = string.Empty;
public string? RoleLabel { get; set; }
public int EstimatedMinutes { get; set; }
public decimal PosX { get; set; }
public decimal PosY { get; set; }
public ProductionStageStatus Status { get; set; } = ProductionStageStatus.Waiting;
/// <summary>Stamped at start; preserved across a reject-intake rework so the original start stands (FR-MFG-19).</summary>
public DateTime? ActualStartAt { get; set; }
public DateTime? ActualEndAt { get; set; }
/// <summary>Copied from the template stage; definitions survive a rework.</summary>
public string FieldDefs { get; set; } = "[]";
/// <summary>Captured at complete as a jsonb object; cleared by a terminal reject so required fields are re-answered.</summary>
public string? FieldValues { get; set; }
/// <summary>
/// Concurrency token. Stage actions re-read the stage inside their transaction and
/// let this xmin check serialize concurrent requests — it is what stops two
/// simultaneous terminal approves from both reading <c>Done</c> and posting two
/// receipts. See the idempotency note in <c>ProductionRunService</c>.
/// </summary>
public uint RowVersion { get; set; }
public ICollection<RunStageInput> Inputs { get; set; } = new List<RunStageInput>();
public ICollection<RunStageOutput> Outputs { get; set; } = new List<RunStageOutput>();
public ICollection<RunStageEvent> Events { get; set; } = new List<RunStageEvent>();
}
@@ -0,0 +1,40 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Immutable history of everything that happened to a run (docs/30 Part C). Written by
/// every mutating action and never updated or deleted, so a run's story — including the
/// figures discarded by each rework — survives in full.
/// <para><b>Addition to docs/30 Part C (recorded):</b> <see cref="RunId"/>. The doc hangs
/// events off the stage only, which leaves run-level events (cancel, terminal reject)
/// with no home and forces the detail timeline to join through stages. Keeping both
/// links makes <see cref="RunStageId"/> optional and the timeline a single query.</para>
/// </summary>
public class RunStageEvent
{
public int EventId { get; set; }
public int RunId { get; set; }
public ProductionRun? Run { get; set; }
/// <summary>Null for run-level events (Cancel).</summary>
public int? RunStageId { get; set; }
public RunStage? RunStage { get; set; }
public RunStageEventType EventType { get; set; }
public string? Note { get; set; }
/// <summary>
/// Event-specific detail as jsonb — consumed layers on a Start, pulled-back
/// quantities on a RejectIntake, the full pre-rework snapshot on a TerminalReject.
/// Pre-serialized string, written only through <c>ProductionJson</c>.
/// </summary>
public string? Payload { get; set; }
public int UserId { get; set; }
public User? User { get; set; }
public DateTime CreatedAt { get; set; }
}
@@ -0,0 +1,55 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// One input line of a run stage — a copy of a <see cref="StageInput"/> with its
/// quantity scaled at creation, plus the live consumption/delivery figures.
/// Model: docs/30 Part C.
/// <para><b>Stock inputs</b> accumulate <see cref="ConsumedQty"/>/<see cref="ConsumedValue"/>
/// at each start and <see cref="ReturnedQty"/>/<see cref="ReturnedValue"/> on leftover
/// return or run cancel. Those four columns are the whole cost pool
/// (<c>Σ consumed Σ returned</c>) and are deliberately <b>not</b> reset by a terminal
/// reject — already-consumed material stays in the pool (FR-MFG-16).</para>
/// <para><b>Upstream inputs</b> accumulate <see cref="DeliveredQty"/> as parent stages
/// transfer WIP in. The stage becomes Ready only when every upstream input has
/// <c>DeliveredQty &gt;= PlannedQty</c> (FR-MFG-09, an all-parents join).</para>
/// </summary>
public class RunStageInput
{
public int RunInputId { get; set; }
public int RunStageId { get; set; }
public RunStage? RunStage { get; set; }
public StageInputSource Source { get; set; }
public int? ItemId { get; set; }
public Item? Item { get; set; }
/// <summary>The parent output feeding this input. This — not <see cref="RunEdge"/> — is what routes a transfer.</summary>
public int? FromRunOutputId { get; set; }
public RunStageOutput? FromRunOutput { get; set; }
public int UomId { get; set; }
public Uom? Uom { get; set; }
/// <summary>Scaled at creation; per-run editable until the stage starts (FR-MFG-08, <c>409 STAGE_NOT_EDITABLE</c>).</summary>
public decimal PlannedQty { get; set; }
/// <summary>
/// Stock inputs only, in the item's <b>base</b> UOM. A start consumes
/// <c>max(0, PlannedQty ConsumedQty)</c> and adds to these, so a rework restart
/// with an unchanged planned quantity consumes nothing and one with a raised planned
/// quantity consumes only the delta (FR-MFG-16).
/// </summary>
public decimal ConsumedQty { get; set; }
public decimal ConsumedValue { get; set; }
/// <summary>Upstream inputs only: accumulated by parent transfers.</summary>
public decimal DeliveredQty { get; set; }
/// <summary>Leftover returns (FR-MFG-14) and cancel returns (FR-MFG-17), at the consumed weighted cost.</summary>
public decimal ReturnedQty { get; set; }
public decimal ReturnedValue { get; set; }
}
@@ -0,0 +1,43 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// One output line of a run stage — a copy of a <see cref="StageOutput"/> with its
/// quantity scaled at creation, plus the live produced/scrapped/transferred figures.
/// Model: docs/30 Part C.
/// <para><b>Available to transfer is derived, never stored:</b>
/// <c>ProducedQty ScrappedQty TransferredQty</c>. Every transfer path checks it and
/// raises <c>422 TRANSFER_EXCEEDS_AVAILABLE</c> (FR-MFG-12).</para>
/// <para>Scrap cost is <b>absorbed</b> into the run cost pool as normal yield loss — no
/// write-off ledger entry is posted (FR-MFG-11).</para>
/// </summary>
public class RunStageOutput
{
public int RunOutputId { get; set; }
public int RunStageId { get; set; }
public RunStage? RunStage { get; set; }
/// <summary>Null on intermediate (WIP) outputs; set on the terminal output — the finished good.</summary>
public int? ItemId { get; set; }
public Item? Item { get; set; }
public string Name { get; set; } = string.Empty;
public int UomId { get; set; }
public Uom? Uom { get; set; }
/// <summary>Scaled at creation; per-run editable until the stage starts.</summary>
public decimal PlannedQty { get; set; }
/// <summary>Recorded at complete. A re-complete after a rework <b>overwrites</b> this, never adds to it.</summary>
public decimal ProducedQty { get; set; }
public decimal ScrappedQty { get; set; }
/// <summary>Mandatory when <see cref="ScrappedQty"/> &gt; 0, context <c>Production</c> (FR-MFG-11).</summary>
public int? ScrapReasonCodeId { get; set; }
public ReasonCode? ScrapReason { get; set; }
/// <summary>Total WIP handed to children so far, across approve and any later partial transfers.</summary>
public decimal TransferredQty { get; set; }
}
@@ -0,0 +1,41 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
public class SalesInvoice
{
public int SalesInvoiceId { get; set; }
public string InvoiceNo { get; set; } = string.Empty;
public DateTime InvoiceDate { get; set; }
public int CustomerId { get; set; }
public Customer? Customer { get; set; }
public string CustomerSnapshotName { get; set; } = string.Empty;
public string? CustomerSnapshotTaxNo { get; set; }
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public SalesInvoiceType InvoiceType { get; set; } = SalesInvoiceType.B2C;
public SalesInvoiceStatus Status { get; set; } = SalesInvoiceStatus.Draft;
public decimal Subtotal { get; set; }
public decimal DiscountTotal { get; set; }
public decimal TaxTotal { get; set; }
public decimal GrandTotal { get; set; }
public decimal RoundOff { get; set; }
public decimal NetPayable { get; set; }
public decimal PaidAmount { get; set; }
public decimal BalanceAmount { get; set; }
public int CreatedBy { get; set; }
public User? Creator { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
public ICollection<SalesInvoiceLine> Lines { get; set; } = new List<SalesInvoiceLine>();
}
@@ -0,0 +1,52 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
public class SalesInvoiceLine
{
public int SalesInvoiceLineId { get; set; }
public int SalesInvoiceId { get; set; }
public SalesInvoice? SalesInvoice { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public string Description { get; set; } = string.Empty;
/// <summary>Quantity sold, in <see cref="UomId"/> — what the user entered and what prints.</summary>
public decimal Qty { get; set; }
public decimal FreeQty { get; set; }
public int UomId { get; set; }
public Uom? Uom { get; set; }
/// <summary>
/// <see cref="Qty"/> restated in the item's base UOM, resolved once at save. Posting
/// consumes stock against this snapshot — the FIFO engine is base-UOM only, so passing
/// the entered quantity would deplete the wrong amount whenever the line is not in base UOM.
/// </summary>
public decimal QtyBase { get; set; }
/// <summary><see cref="FreeQty"/> restated in the item's base UOM.</summary>
public decimal FreeQtyBase { get; set; }
/// <summary>The factor used to derive the base quantities; 1 when the line is in base UOM.</summary>
public decimal ConversionFactor { get; set; } = 1m;
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public decimal UnitPrice { get; set; }
public decimal BaseCost { get; set; }
public string PriceSource { get; set; } = string.Empty;
public SalesDiscountMode DiscountMode { get; set; } = SalesDiscountMode.Percentage;
public decimal DiscountPct { get; set; }
public decimal DiscountAmount { get; set; }
public decimal NetUnitPrice { get; set; }
public decimal LineTotal { get; set; }
public decimal TaxPct { get; set; }
public decimal TaxAmount { get; set; }
public bool IsFreeIssue { get; set; }
public int? ParentLineId { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,36 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
public class SalesSlip
{
public int SalesSlipId { get; set; }
public string SlipNo { get; set; } = string.Empty;
public DateTime SlipDate { get; set; }
public int CustomerId { get; set; }
public Customer? Customer { get; set; }
public string CustomerSnapshotName { get; set; } = string.Empty;
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public int CashierUserId { get; set; }
public User? CashierUser { get; set; }
public SalesSlipStatus Status { get; set; } = SalesSlipStatus.Draft;
public decimal Subtotal { get; set; }
public decimal DiscountTotal { get; set; }
public decimal TaxTotal { get; set; }
public decimal GrandTotal { get; set; }
public decimal PaidAmount { get; set; }
public decimal BalanceAmount { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
public ICollection<SalesSlipLine> Lines { get; set; } = new List<SalesSlipLine>();
}
@@ -0,0 +1,52 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
public class SalesSlipLine
{
public int SalesSlipLineId { get; set; }
public int SalesSlipId { get; set; }
public SalesSlip? SalesSlip { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public string Description { get; set; } = string.Empty;
/// <summary>Quantity sold, in <see cref="UomId"/> — what the user entered and what prints.</summary>
public decimal Qty { get; set; }
public decimal FreeQty { get; set; }
public int UomId { get; set; }
public Uom? Uom { get; set; }
/// <summary>
/// <see cref="Qty"/> restated in the item's base UOM, resolved once at save. Posting
/// consumes stock against this snapshot — the FIFO engine is base-UOM only, so passing
/// the entered quantity would deplete the wrong amount whenever the line is not in base UOM.
/// </summary>
public decimal QtyBase { get; set; }
/// <summary><see cref="FreeQty"/> restated in the item's base UOM.</summary>
public decimal FreeQtyBase { get; set; }
/// <summary>The factor used to derive the base quantities; 1 when the line is in base UOM.</summary>
public decimal ConversionFactor { get; set; } = 1m;
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public decimal UnitPrice { get; set; }
public decimal BaseCost { get; set; }
public string PriceSource { get; set; } = string.Empty;
public SalesDiscountMode DiscountMode { get; set; } = SalesDiscountMode.Percentage;
public decimal DiscountPct { get; set; }
public decimal DiscountAmount { get; set; }
public decimal NetUnitPrice { get; set; }
public decimal LineTotal { get; set; }
public decimal TaxPct { get; set; }
public decimal TaxAmount { get; set; }
public bool IsFreeIssue { get; set; }
public int? ParentLineId { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,21 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// A parent → child arrow on the template canvas. The edge set must form a DAG with
/// at least one entry stage and exactly one terminal stage; that is enforced in
/// <c>ProductionGraphValidator</c> on every save, not by the database (FR-MFG-02).
/// Model: docs/30 Part C.
/// </summary>
public class StageEdge
{
public int EdgeId { get; set; }
public int TemplateId { get; set; }
public ProductionTemplate? Template { get; set; }
public int ParentStageId { get; set; }
public TemplateStage? ParentStage { get; set; }
public int ChildStageId { get; set; }
public TemplateStage? ChildStage { get; set; }
}
@@ -0,0 +1,39 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// One line of a stage's input formula (FR-MFG-04). Exactly one of the two source
/// shapes applies, enforced by <c>ProductionGraphValidator</c>:
/// <list type="bullet">
/// <item><see cref="StageInputSource.Stock"/> — <see cref="ItemId"/> set,
/// <see cref="FromOutputId"/> null. FIFO-consumed from the run warehouse at stage
/// start. Allowed on <i>any</i> stage, e.g. packaging added late.</item>
/// <item><see cref="StageInputSource.Upstream"/> — <see cref="FromOutputId"/> set to
/// an output of a <b>direct parent</b> stage, <see cref="ItemId"/> null. Flows as
/// internal WIP and never touches stock or the ledger.</item>
/// </list>
/// Model: docs/30 Part C.
/// </summary>
public class StageInput
{
public int InputId { get; set; }
public int StageId { get; set; }
public TemplateStage? Stage { get; set; }
public StageInputSource Source { get; set; }
/// <summary>Required when <see cref="Source"/> is Stock; null when Upstream.</summary>
public int? ItemId { get; set; }
public Item? Item { get; set; }
/// <summary>Required when <see cref="Source"/> is Upstream; must belong to a direct parent.</summary>
public int? FromOutputId { get; set; }
public StageOutput? FromOutput { get; set; }
public int UomId { get; set; }
public Uom? Uom { get; set; }
public decimal QtyPerBatch { get; set; }
}
@@ -0,0 +1,27 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// A named quantity produced by a stage (FR-MFG-05). Intermediate outputs are
/// <b>internal WIP only</b> — <see cref="ItemId"/> is null, no stock and no ledger row
/// is ever written for them. The terminal stage is the exception: it has exactly one
/// output and that output <b>must</b> reference a real Item (the finished good), which
/// is what the production receipt creates a layer for. Model: docs/30 Part C.
/// </summary>
public class StageOutput
{
public int OutputId { get; set; }
public int StageId { get; set; }
public TemplateStage? Stage { get; set; }
/// <summary>Null on intermediate stages; required on the terminal stage.</summary>
public int? ItemId { get; set; }
public Item? Item { get; set; }
public string Name { get; set; } = string.Empty;
public int UomId { get; set; }
public Uom? Uom { get; set; }
public decimal QtyPerBatch { get; set; }
}
@@ -0,0 +1,36 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// One box on the template canvas (FR-MFG-03): a named step with a role label, an
/// estimated duration, a formula (<see cref="Inputs"/> + <see cref="Outputs"/>) and
/// custom field definitions. Model: docs/30 Part C.
/// </summary>
public class TemplateStage
{
public int StageId { get; set; }
public int TemplateId { get; set; }
public ProductionTemplate? Template { get; set; }
public string Name { get; set; } = string.Empty;
/// <summary>Free text (e.g. "QA"). Informational only this phase — never enforced (FR-X-01).</summary>
public string? RoleLabel { get; set; }
public int EstimatedMinutes { get; set; }
/// <summary>Canvas coordinates — stored verbatim, never interpreted server-side (FR-MFG-03).</summary>
public decimal PosX { get; set; }
public decimal PosY { get; set; }
/// <summary>
/// Custom field definitions as jsonb: <c>[{ key, label, type, options?, required }]</c>
/// (FR-MFG-07). Held as a pre-serialized string, matching the <c>AuditLog.ChangeSet</c>
/// precedent; always written through <c>ProductionJson</c> so the column can only ever
/// hold canonical JSON.
/// </summary>
public string FieldDefs { get; set; } = "[]";
public ICollection<StageInput> Inputs { get; set; } = new List<StageInput>();
public ICollection<StageOutput> Outputs { get; set; } = new List<StageOutput>();
}
@@ -0,0 +1,8 @@
namespace ERPCore.Domain.Enums;
public enum BundleSaleStatus
{
Draft = 0,
Posted = 1,
Cancelled = 2
}
@@ -0,0 +1,16 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Input type of a stage custom field (FR-MFG-07; docs/30 §D.5 <c>fieldType</c>).
/// Lives inside the <c>field_defs</c> jsonb rather than a column, but is modelled as an
/// enum so a bad value is rejected at the DTO boundary instead of reaching the database.
/// <see cref="Select"/> is the only type that reads <c>options</c>.
/// </summary>
public enum CustomFieldType
{
Text,
Number,
Checkbox,
Date,
Select
}
@@ -0,0 +1,8 @@
namespace ERPCore.Domain.Enums;
public enum CustomerType
{
B2B = 1,
B2C = 2,
WalkIn = 3
}
@@ -0,0 +1,14 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Lifecycle of a production run (docs/30 §B.4, §D.5). A run is created
/// <see cref="InProgress"/> and reaches exactly one final state: it completes at the
/// terminal stage's approve (production receipt, FR-MFG-13) or is cancelled with a
/// stock return (FR-MFG-17). Stored as a string.
/// </summary>
public enum ProductionRunStatus
{
InProgress,
Completed,
Cancelled
}
@@ -0,0 +1,22 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Status of one stage within a production run (docs/30 §B.4, §D.5).
/// <code>
/// Waiting ──(all upstream inputs fully delivered)──▶ Ready
/// Ready ──start (FIFO-consume Stock inputs)──▶ InProgress
/// InProgress ──complete (produced/scrap/fields)──▶ Done
/// Done ──approve──▶ Approved (non-terminal: WIP transfers out; terminal: receipt)
/// </code>
/// Rejection is not a resting state (FR-MFG-15/16): a reject immediately produces a
/// rework transition back into this set and is recorded as a
/// <see cref="RunStageEventType"/> instead. Stored as a string.
/// </summary>
public enum ProductionStageStatus
{
Waiting,
Ready,
InProgress,
Done,
Approved
}
@@ -5,5 +5,8 @@ public enum ReasonContext
{
Adjustment,
Return,
Count
Count,
/// <summary>Manufacturing: scrap, leftover return, run cancel (docs/30 §A.2).</summary>
Production
}
@@ -0,0 +1,20 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Kind of entry in a run's immutable history (docs/30 Part C, <c>RUN_STAGE_EVENT</c>).
/// Every mutating action on a run writes exactly one event carrying who/when plus a
/// jsonb payload; <see cref="TerminalReject"/> additionally snapshots the whole run's
/// figures for the rework pass being discarded (FR-MFG-16). Stored as a string.
/// </summary>
public enum RunStageEventType
{
Start,
Complete,
Approve,
Transfer,
RejectIntake,
TerminalReject,
LeftoverReturn,
Cancel,
QuantityEdit
}
@@ -0,0 +1,7 @@
namespace ERPCore.Domain.Enums;
public enum SalesDiscountMode
{
Percentage = 1,
Amount = 2
}
@@ -0,0 +1,8 @@
namespace ERPCore.Domain.Enums;
public enum SalesInvoiceStatus
{
Draft = 1,
Posted = 2,
Cancelled = 3
}
@@ -0,0 +1,9 @@
namespace ERPCore.Domain.Enums;
public enum SalesInvoiceType
{
B2B = 1,
B2C = 2,
Cash = 3,
Credit = 4
}
@@ -0,0 +1,8 @@
namespace ERPCore.Domain.Enums;
public enum SalesSlipStatus
{
Draft = 1,
Posted = 2,
Cancelled = 3
}
@@ -0,0 +1,14 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Where a stage input's material comes from (FR-MFG-04; docs/30 §D.5).
/// <see cref="Stock"/> inputs reference an Item and are FIFO-consumed from the run
/// warehouse when the stage starts. <see cref="Upstream"/> inputs reference a direct
/// parent stage's output and flow as internal WIP — they never touch the ledger.
/// Stored as a string.
/// </summary>
public enum StageInputSource
{
Stock,
Upstream
}
@@ -0,0 +1,34 @@
namespace ERPCore.Domain;
/// <summary>
/// Additional <c>STOCK_LEDGER.source_doc_type</c> values for manufacturing movements
/// (docs/30 §A.2). <c>source_doc_id</c> is always the <c>run_id</c>, so
/// <c>LIKE 'PRD%'</c> traces every stock movement a run caused.
/// </summary>
/// <remarks>
/// <para><b>Deviation from docs/30 §A.2 (recorded).</b> The doc proposes the long names
/// <c>ProductionIssue</c>/<c>ProductionReceipt</c>/<c>ProductionReturn</c>/
/// <c>ProductionCancelReturn</c>, but both <c>stock_ledger.SourceDocType</c> and
/// <c>journal_entry_stubs.SourceDocType</c> are <c>varchar(10)</c> and every existing
/// value is a short prefix (<c>GRN</c>, <c>TRF</c>, <c>ADJ</c>, <c>PRET</c>). Widening
/// those columns would be a second Phase-1 schema change beyond the single deviation
/// §A.1 declares (NFR-08), so the short codes below extend the existing convention
/// instead. docs/30 §A.2 and §D.5 are amended to match.</para>
/// <para>These are <b>not</b> <see cref="DocumentTypes"/> entries — production issues no
/// document per movement. The run's own document number uses
/// <see cref="DocumentTypes.Production"/> (<c>PRD-2026-00001</c>).</para>
/// </remarks>
public static class LedgerSourceTypes
{
/// <summary>Stock consumed by a stage start (FR-MFG-10). Outbound.</summary>
public const string ProductionIssue = "PRDI";
/// <summary>Finished goods received at the terminal approve (FR-MFG-13). Inbound.</summary>
public const string ProductionReceipt = "PRDR";
/// <summary>Unconsumed material returned before receipt (FR-MFG-14). Inbound.</summary>
public const string ProductionReturn = "PRDL";
/// <summary>Net consumed stock returned when a run is cancelled (FR-MFG-17). Inbound.</summary>
public const string ProductionCancelReturn = "PRDC";
}
@@ -0,0 +1,66 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Customers;
/// <summary>Customer resource used by sales documents.</summary>
public sealed record CustomerDto(
int CustomerId,
string CustomerCode,
CustomerType CustomerType,
string Name,
string? DisplayName,
string? Phone,
string? Email,
string? AddressLine1,
string? AddressLine2,
string? City,
string? Country,
string? TaxRegistrationNo,
decimal CreditLimit,
int CreditDays,
int? DefaultWarehouseId,
EntityStatus Status,
DateTime CreatedAt,
DateTime? UpdatedAt);
public sealed class CreateCustomerRequest
{
[Required, StringLength(50)] public string CustomerCode { get; set; } = string.Empty;
[Required, EnumDataType(typeof(CustomerType))] public CustomerType CustomerType { get; set; } = CustomerType.B2C;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(200)] public string? DisplayName { get; set; }
[StringLength(30)] public string? Phone { get; set; }
[StringLength(100)] public string? Email { get; set; }
[StringLength(250)] public string? AddressLine1 { get; set; }
[StringLength(250)] public string? AddressLine2 { get; set; }
[StringLength(100)] public string? City { get; set; }
[StringLength(100)] public string? Country { get; set; }
[StringLength(50)] public string? TaxRegistrationNo { get; set; }
[Range(0, double.MaxValue)] public decimal CreditLimit { get; set; }
[Range(0, int.MaxValue)] public int CreditDays { get; set; }
public int? DefaultWarehouseId { get; set; }
}
public sealed class UpdateCustomerRequest
{
[Required, StringLength(50)] public string CustomerCode { get; set; } = string.Empty;
[Required, EnumDataType(typeof(CustomerType))] public CustomerType CustomerType { get; set; } = CustomerType.B2C;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(200)] public string? DisplayName { get; set; }
[StringLength(30)] public string? Phone { get; set; }
[StringLength(100)] public string? Email { get; set; }
[StringLength(250)] public string? AddressLine1 { get; set; }
[StringLength(250)] public string? AddressLine2 { get; set; }
[StringLength(100)] public string? City { get; set; }
[StringLength(100)] public string? Country { get; set; }
[StringLength(50)] public string? TaxRegistrationNo { get; set; }
[Range(0, double.MaxValue)] public decimal CreditLimit { get; set; }
[Range(0, int.MaxValue)] public int CreditDays { get; set; }
public int? DefaultWarehouseId { get; set; }
}
public sealed class UpdateCustomerStatusRequest
{
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
}
@@ -5,9 +5,18 @@ namespace ERPCore.Dtos.Procurement;
// Responses (docs/11 §3.3) ------------------------------------------------------
/// <summary>
/// A PO line. <paramref name="Qty"/> and <paramref name="QtyReceived"/> are in
/// <paramref name="UomId"/> and are what the user sees; <paramref name="QtyBase"/> and
/// <paramref name="QtyReceivedBase"/> are the item's base UOM and are what the server
/// actually enforces — GRN over-receipt and the PO close condition both run on the base
/// pair, because goods may legitimately be received in a different UOM from the one
/// ordered. A client showing remaining/outstanding quantity should read the base pair.
/// </summary>
public sealed record PoLineDto(
int PoLineId, int ItemId, int UomId, int WarehouseId,
decimal Qty, decimal UnitPrice, decimal Tax, decimal QtyReceived);
decimal Qty, decimal UnitPrice, decimal Tax, decimal QtyReceived,
decimal QtyBase = 0m, decimal QtyReceivedBase = 0m, decimal ConversionFactor = 1m);
public sealed record PoTotalsDto(decimal SubTotal, decimal Tax, decimal GrandTotal, string Currency);
+113
View File
@@ -0,0 +1,113 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Production;
// Production run contract (docs/30-BACKEND-PHASE2.md §D.2D.3).
//
// Statuses are never client-settable (02-SECURITY §B.6): a run's status and every stage
// status move only through the stage-action endpoints. Requests here carry quantities and
// references, nothing else.
// --- responses ---------------------------------------------------------------
/// <summary>Per-status stage counts driving the board's progress strip (FR-MFG-18, docs/21 §3).</summary>
public sealed record StageSummaryDto(int Waiting, int Ready, int InProgress, int Done, int Approved);
/// <summary>Row on the run board (docs/30 §D.2 <c>GET /production-runs</c>).</summary>
public sealed record RunSummaryDto(
int RunId, string DocNo, int TemplateId, string TemplateName,
decimal TargetQty, ProductionRunStatus Status, int ReworkCount,
StageSummaryDto StageSummary, int WarehouseId,
int? FinishedItemId, string? FinishedItemName,
int CreatedBy, DateTime CreatedAt, DateTime? CompletedAt);
/// <summary>
/// The run cost pool (FR-MFG-13). Always derived from the stage inputs, never stored —
/// surfaced so the UI can preview the finished unit cost before approving the terminal.
/// </summary>
public sealed record CostPoolDto(decimal Consumed, decimal Returned, decimal Net);
public sealed record RunStageInputDto(
int RunInputId, StageInputSource Source, int? ItemId, int? FromRunOutputId, int UomId,
decimal PlannedQty, decimal ConsumedQty, decimal ConsumedValue,
decimal DeliveredQty, decimal ReturnedQty, decimal ReturnedValue);
/// <summary>
/// One output of a run stage. <c>AvailableToTransfer</c> is derived — produced scrapped
/// transferred (FR-MFG-12) — and never stored.
/// </summary>
public sealed record RunStageOutputDto(
int RunOutputId, int? ItemId, string Name, int UomId,
decimal PlannedQty, decimal ProducedQty, decimal ScrappedQty, int? ScrapReasonCodeId,
decimal TransferredQty, decimal AvailableToTransfer);
/// <summary>
/// One stage of a run. <c>IsTerminal</c> (no outbound edge) and <c>IsEntry</c> (no inbound
/// edge) are derived from the run edge set rather than stored, so they cannot drift from it.
/// <c>ActualMinutes</c> is the elapsed whole minutes once the stage has finished, null while
/// it is still running (FR-MFG-19). <c>FieldValues</c> is the raw jsonb captured at complete,
/// passed through verbatim.
/// </summary>
public sealed record RunStageDto(
int RunStageId, int? TemplateStageId, string Name, string? RoleLabel,
int EstimatedMinutes, decimal PosX, decimal PosY,
ProductionStageStatus Status, bool IsTerminal, bool IsEntry,
DateTime? ActualStartAt, DateTime? ActualEndAt, int? ActualMinutes,
IReadOnlyList<FieldDefDto> FieldDefs, JsonElement? FieldValues,
IReadOnlyList<RunStageInputDto> Inputs, IReadOnlyList<RunStageOutputDto> Outputs);
public sealed record RunEdgeDto(int RunEdgeId, int ParentRunStageId, int ChildRunStageId);
public sealed record RunEventDto(
int EventId, int? RunStageId, RunStageEventType EventType, string? Note,
JsonElement? Payload, int UserId, DateTime CreatedAt);
/// <summary>Full run graph (docs/30 §D.2 <c>GET /production-runs/{id}</c>).</summary>
public sealed record RunGraphDto(
int RunId, string DocNo, int TemplateId, string TemplateName,
int WarehouseId, int? OutputBinId,
decimal TargetQty, decimal ScaleFactor,
ProductionRunStatus Status, int ReworkCount, int? CancelReasonCodeId,
CostPoolDto CostPool,
IReadOnlyList<RunStageDto> Stages, IReadOnlyList<RunEdgeDto> Edges, IReadOnlyList<RunEventDto> Events,
int CreatedBy, DateTime CreatedAt, DateTime? CompletedAt);
// --- requests ----------------------------------------------------------------
public sealed class CreateRunRequest
{
[Range(1, int.MaxValue)]
public int TemplateId { get; set; }
/// <summary>Quantity of the finished item; drives the whole graph's scale factor (FR-MFG-08).</summary>
[Range(0.0001, double.MaxValue)]
public decimal TargetQty { get; set; }
[Range(1, int.MaxValue)]
public int WarehouseId { get; set; }
/// <summary>Optional bin for the finished goods; must belong to <see cref="WarehouseId"/>.</summary>
public int? OutputBinId { get; set; }
}
/// <summary>
/// Per-run scaling override (FR-MFG-08). Only accepted while the stage has not started
/// (<c>409 STAGE_NOT_EDITABLE</c>).
/// </summary>
public sealed class UpdateStageQuantitiesRequest
{
public List<StageQuantityLine> Inputs { get; set; } = new();
public List<StageQuantityLine> Outputs { get; set; } = new();
}
public sealed class StageQuantityLine
{
/// <summary>The <c>runInputId</c> or <c>runOutputId</c> being adjusted.</summary>
[Range(1, int.MaxValue)]
public int Id { get; set; }
[Range(0.0001, double.MaxValue)]
public decimal PlannedQty { get; set; }
}
@@ -0,0 +1,177 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Production;
// Stage-action contract (docs/30-BACKEND-PHASE2.md §D.3). Every action is transactional,
// guards on the stage's current status, and returns the refreshed stage plus whatever
// stock effects it caused.
// --- start -------------------------------------------------------------------
/// <summary>One FIFO layer a stage start drew from, at that layer's cost.</summary>
public sealed record ConsumedLayerDto(int LayerId, decimal Qty, decimal UnitCost);
/// <summary>What a single Stock input consumed at start (FR-MFG-10).</summary>
public sealed record ConsumedInputDto(
int RunInputId, int ItemId, decimal Qty, decimal Value, IReadOnlyList<ConsumedLayerDto> ConsumedLayers);
public sealed record StartStageResultDto(
int RunStageId, ProductionStageStatus Status, DateTime? ActualStartAt,
IReadOnlyList<ConsumedInputDto> Consumed, IReadOnlyList<int> LedgerRefs,
RunStageDto Stage);
// --- complete ----------------------------------------------------------------
public sealed class CompleteStageRequest
{
[Required, MinLength(1)]
public List<CompleteOutputLine> Outputs { get; set; } = new();
/// <summary>
/// Values for the stage's custom fields, keyed by <c>fieldDefs[].key</c>. Every field
/// marked required must be present and non-empty (<c>400 REQUIRED_FIELD_MISSING</c>).
/// </summary>
public JsonElement? FieldValues { get; set; }
}
public sealed class CompleteOutputLine
{
[Range(1, int.MaxValue)]
public int RunOutputId { get; set; }
[Range(0, double.MaxValue)]
public decimal ProducedQty { get; set; }
[Range(0, double.MaxValue)]
public decimal ScrappedQty { get; set; }
/// <summary>Mandatory once <see cref="ScrappedQty"/> &gt; 0; must be a Production reason.</summary>
public int? ScrapReasonCodeId { get; set; }
}
// --- approve / transfer ------------------------------------------------------
/// <summary>
/// One WIP hand-off from a parent output to a child input. Deliveries route by
/// <c>fromRunOutputId</c>, not by edge — see the note on <c>RunEdge</c>.
/// </summary>
public sealed record TransferDto(
int RunOutputId, int RunInputId, int ChildRunStageId, decimal Qty,
decimal ChildDeliveredQty, ProductionStageStatus ChildStatus);
/// <summary>The finished-goods layer a terminal approve created (FR-MFG-13).</summary>
public sealed record ReceiptDto(
int LayerId, int ItemId, int WarehouseId, int? BinId,
decimal QtyReceived, decimal UnitCost, decimal Value);
public sealed record ApproveStageResultDto(
int RunStageId, ProductionStageStatus Status, ProductionRunStatus RunStatus,
IReadOnlyList<TransferDto> Transfers,
ReceiptDto? Receipt, CostPoolDto? CostPool, IReadOnlyList<int> LedgerRefs,
RunStageDto Stage);
public sealed class ApproveStageRequest
{
/// <summary>
/// Optional partial transfers. Omitted or empty means transfer the full available
/// quantity of every output (FR-MFG-12).
/// </summary>
public List<TransferLine> Transfers { get; set; } = new();
}
public sealed class TransferLine
{
[Range(1, int.MaxValue)]
public int RunOutputId { get; set; }
[Range(0.0001, double.MaxValue)]
public decimal Qty { get; set; }
/// <summary>
/// Optional explicit target. When an output feeds several child inputs the server
/// otherwise fills them in <c>runInputId</c> order; naming one removes the ambiguity.
/// </summary>
public int? RunInputId { get; set; }
}
public sealed class TransferRemainderRequest
{
[Range(1, int.MaxValue)]
public int RunOutputId { get; set; }
[Range(0.0001, double.MaxValue)]
public decimal Qty { get; set; }
public int? RunInputId { get; set; }
}
public sealed record TransferResultDto(
int RunStageId, IReadOnlyList<TransferDto> Transfers, RunStageDto Stage);
// --- leftover return ---------------------------------------------------------
public sealed class ReturnLeftoverRequest
{
/// <summary>
/// Quantity to return, in the item's <b>base</b> UOM — the same unit
/// <c>consumedQty</c>/<c>returnedQty</c> are stored in, since the return posts straight
/// to stock. Cannot exceed consumed already returned.
/// </summary>
[Range(0.0001, double.MaxValue)]
public decimal Qty { get; set; }
/// <summary>Mandatory, and must be a Production-context reason (FR-MFG-14).</summary>
public int? ReasonCodeId { get; set; }
}
public sealed record ProductionCreatedLayerDto(int LayerId, decimal UnitCost);
public sealed record ReturnLeftoverResultDto(
int RunInputId, decimal ReturnedQty, decimal ReturnedValue,
ProductionCreatedLayerDto CreatedLayer, IReadOnlyList<int> LedgerRefs, CostPoolDto CostPool);
// --- rejection / rework ------------------------------------------------------
public sealed class RejectRequest
{
[StringLength(500)]
public string? Note { get; set; }
}
/// <summary>One parent whose delivered work was pulled back by a reject-intake (FR-MFG-15).</summary>
public sealed record PulledBackDto(
int RunInputId, int ParentRunStageId, int ParentRunOutputId,
decimal Qty, ProductionStageStatus PriorParentStatus, ProductionStageStatus ParentStatus);
public sealed record RejectIntakeResultDto(
int RunStageId, ProductionStageStatus Status,
IReadOnlyList<PulledBackDto> PulledBack, RunGraphDto Run);
public sealed record TerminalRejectResultDto(
int RunStageId, int ReworkCount, RunGraphDto Run);
// --- cancel ------------------------------------------------------------------
public sealed class CancelRunRequest
{
public int? ReasonCodeId { get; set; }
[StringLength(500)]
public string? Note { get; set; }
}
public sealed record CancelReturnDto(int ItemId, decimal Qty, decimal UnitCost, int LayerId);
/// <summary>
/// Scrapped output quantities written off by a cancel. Recorded on the event only — scrap
/// sits on outputs, which never entered stock, so there is nothing to return (FR-MFG-17).
/// </summary>
public sealed record ScrapWriteOffDto(int RunOutputId, string Name, decimal Qty);
public sealed record CancelRunResultDto(
int RunId, ProductionRunStatus Status,
IReadOnlyList<CancelReturnDto> Returns,
IReadOnlyList<ScrapWriteOffDto> ScrappedWrittenOff,
IReadOnlyList<int> LedgerRefs);
@@ -0,0 +1,177 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Production;
// Production template contract (docs/30-BACKEND-PHASE2.md §D.1).
//
// The `Key` vocabulary: every stage and every output carries a client-facing string key
// alongside its database id. On a GET the key IS the stringified id; on a save the client
// echoes those keys back for rows it kept and mints "tmp-<uuid>" keys for rows it just
// drew. Edges and Upstream inputs then reference stages/outputs *by key* only, which is
// what lets one payload shape — and one validator — serve both POST (nothing has an id
// yet) and PUT (most things do).
// --- responses ---------------------------------------------------------------
/// <summary>
/// Row on the template list (docs/30 §D.1 <c>GET /production-templates</c>).
/// </summary>
/// <remarks>
/// <c>StageNames</c> is an addition to the documented shape. The template overview is a
/// canvas showing every template as a production line with its stages left-to-right
/// (docs/21 §1), so it needs the names for every listed row — without them the client would
/// have to fetch each template's full graph just to label the boxes. Ordered by stage id,
/// matching the graph endpoint.
/// </remarks>
public sealed record TemplateSummaryDto(
int TemplateId, string Code, string Name, EntityStatus Status,
int StageCount, IReadOnlyList<string> StageNames,
int ActiveRunCount, int CreatedBy, DateTime CreatedAt);
/// <summary>One custom field definition, serialized verbatim into the stage's <c>field_defs</c> jsonb.</summary>
public sealed record FieldDefDto(
string Key, string Label, CustomFieldType Type, IReadOnlyList<string>? Options, bool Required);
public sealed record StageInputDto(
int InputId, StageInputSource Source, int? ItemId,
int? FromOutputId, string? FromOutputKey, int UomId, decimal QtyPerBatch);
public sealed record StageOutputDto(
int OutputId, string Key, int? ItemId, string Name, int UomId, decimal QtyPerBatch);
public sealed record TemplateStageDto(
int StageId, string Key, string Name, string? RoleLabel, int EstimatedMinutes,
decimal PosX, decimal PosY, IReadOnlyList<FieldDefDto> FieldDefs,
IReadOnlyList<StageInputDto> Inputs, IReadOnlyList<StageOutputDto> Outputs);
public sealed record TemplateEdgeDto(
int EdgeId, int ParentStageId, int ChildStageId, string ParentKey, string ChildKey);
/// <summary>
/// A canvas-only grouping box or divider line. Round-tripped verbatim: no server-side
/// meaning whatsoever, and invisible to the graph validator.
/// </summary>
public sealed record CanvasAnnotationDto(
string Kind, decimal PosX, decimal PosY, decimal Width, decimal Height,
string? Label, decimal? Rotation);
/// <summary>Full graph (docs/30 §D.1 <c>GET /production-templates/{id}</c>).</summary>
/// <remarks>
/// <c>ActiveRunCount</c> and <c>Annotations</c> are additions to the documented shape.
/// The first is what puts the builder into its edit-locked state (docs/21 §2) and mirrors
/// the condition <c>UpdateAsync</c> enforces — without it the builder would need a second
/// request to the list endpoint just to know whether to disable itself.
/// </remarks>
public sealed record TemplateGraphDto(
int TemplateId, string Code, string Name, string? Description, EntityStatus Status,
IReadOnlyList<TemplateStageDto> Stages, IReadOnlyList<TemplateEdgeDto> Edges,
IReadOnlyList<CanvasAnnotationDto> Annotations, int ActiveRunCount,
int CreatedBy, DateTime CreatedAt);
// --- requests ----------------------------------------------------------------
// Narrow by design (02-SECURITY §B.6): no status, no ids, no createdBy, no timestamps.
// POST and PUT share this shape; PUT replaces the whole graph.
public sealed class SaveTemplateRequest
{
[Required, StringLength(30, MinimumLength = 1)]
public string Code { get; set; } = string.Empty;
[Required, StringLength(150, MinimumLength = 1)]
public string Name { get; set; } = string.Empty;
[StringLength(500)]
public string? Description { get; set; }
[Required, MinLength(1)]
public List<SaveStageRequest> Stages { get; set; } = new();
/// <summary>Empty is legal — a single-stage template is both entry and terminal.</summary>
public List<SaveEdgeRequest> Edges { get; set; } = new();
/// <summary>
/// Canvas boxes/lines. Capped because this is free-form client state going into a jsonb
/// column — a hand-rolled request should not be able to store an unbounded blob.
/// </summary>
[MaxLength(200)]
public List<CanvasAnnotationDto> Annotations { get; set; } = new();
}
public sealed class SaveStageRequest
{
/// <summary>Existing stage id as a string, or a client-minted <c>tmp-*</c> key for a new stage.</summary>
[Required, StringLength(60, MinimumLength = 1)]
public string Key { get; set; } = string.Empty;
[Required, StringLength(150, MinimumLength = 1)]
public string Name { get; set; } = string.Empty;
[StringLength(60)]
public string? RoleLabel { get; set; }
[Range(0, 1_000_000)]
public int EstimatedMinutes { get; set; }
/// <summary>Canvas coordinates, stored verbatim and never interpreted server-side.</summary>
public decimal PosX { get; set; }
public decimal PosY { get; set; }
public List<FieldDefDto> FieldDefs { get; set; } = new();
public List<SaveInputRequest> Inputs { get; set; } = new();
public List<SaveOutputRequest> Outputs { get; set; } = new();
}
public sealed class SaveInputRequest
{
[Required]
public StageInputSource Source { get; set; }
/// <summary>Required when <see cref="Source"/> is Stock; must be null when Upstream.</summary>
public int? ItemId { get; set; }
/// <summary>Required when <see cref="Source"/> is Upstream; must name an output of a direct parent.</summary>
[StringLength(60)]
public string? FromOutputKey { get; set; }
[Range(1, int.MaxValue)]
public int UomId { get; set; }
[Range(0.0001, double.MaxValue)]
public decimal QtyPerBatch { get; set; }
}
public sealed class SaveOutputRequest
{
/// <summary>Existing output id as a string, or a <c>tmp-*</c> key. Referenced by Upstream inputs.</summary>
[Required, StringLength(60, MinimumLength = 1)]
public string Key { get; set; } = string.Empty;
/// <summary>Required on the terminal stage's single output; must be null on every other output.</summary>
public int? ItemId { get; set; }
[Required, StringLength(150, MinimumLength = 1)]
public string Name { get; set; } = string.Empty;
[Range(1, int.MaxValue)]
public int UomId { get; set; }
[Range(0.0001, double.MaxValue)]
public decimal QtyPerBatch { get; set; }
}
public sealed class SaveEdgeRequest
{
[Required, StringLength(60, MinimumLength = 1)]
public string ParentKey { get; set; } = string.Empty;
[Required, StringLength(60, MinimumLength = 1)]
public string ChildKey { get; set; } = string.Empty;
}
/// <summary>Body of <c>PATCH /production-templates/{id}/status</c> (docs/30 §D.1).</summary>
public sealed class UpdateTemplateStatusRequest
{
[Required]
public EntityStatus Status { get; set; }
}
@@ -0,0 +1,84 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Sales;
public sealed record BundleSaleLineDto(
int BundleSaleLineId, int ItemId, string Description, decimal Qty, int UomId, int WarehouseId,
decimal UnitPrice, decimal LineTotal, bool IncludeInBundle, bool IsComponent, int? ParentLineId);
public sealed record BundleSaleDto(
int BundleSaleId, string BundleNo, DateTime BundleDate, int CustomerId, string CustomerSnapshotName,
int WarehouseId, int CashierUserId, int BundleSaleTemplateId, string BundleName, string BundleCode,
BundleSaleStatus Status, decimal ComponentSubtotal, decimal BundlePrice, decimal MarginAmount,
decimal DiscountTotal, decimal TaxTotal, decimal GrandTotal, DateTime CreatedAt, DateTime? UpdatedAt,
IReadOnlyList<BundleSaleLineDto> Lines);
public sealed record BundleSaleSummaryDto(
int BundleSaleId, string BundleNo, DateTime BundleDate, int CustomerId, string CustomerSnapshotName,
int WarehouseId, string BundleName, string BundleCode, BundleSaleStatus Status,
decimal ComponentSubtotal, decimal BundlePrice, decimal GrandTotal, DateTime CreatedAt);
public sealed record BundleSaleTemplateLineDto(
int BundleSaleTemplateLineId, int ItemId, int UomId, int WarehouseId, decimal Qty,
decimal UnitPrice, bool IncludeInBundle, int SortOrder);
public sealed record BundleSaleTemplateDto(
int BundleSaleTemplateId, string TemplateCode, string TemplateName, string? Description,
EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt, IReadOnlyList<BundleSaleTemplateLineDto> Lines);
public sealed record BundleSaleTemplateSummaryDto(
int BundleSaleTemplateId, string TemplateCode, string TemplateName, string? Description,
EntityStatus Status, int LineCount, DateTime CreatedAt, DateTime? UpdatedAt);
/// <summary>Quantities are in the item's base UOM — see <c>SalesInvoicePostingIssueDto</c>.</summary>
public sealed record BundleSalePostingIssueDto(
int BundleSaleLineId, int ItemId, string ItemSku, string ItemName, int WarehouseId,
decimal RequestedQty, decimal AvailableQty, decimal ShortQty, string BaseUomName = "");
public sealed record BundleSalePostingCheckDto(
int BundleSaleId, string BundleNo, BundleSaleStatus Status, bool CanPost,
IReadOnlyList<BundleSalePostingIssueDto> Issues);
public sealed class CreateBundleSaleTemplateLineRequest
{
[Required] public int ItemId { get; set; }
[Required] public int UomId { get; set; }
[Required] public int WarehouseId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
[Range(0, double.MaxValue)] public decimal UnitPrice { get; set; }
public bool IncludeInBundle { get; set; } = true;
public int SortOrder { get; set; }
}
public sealed class CreateBundleSaleTemplateRequest
{
[Required] public string TemplateCode { get; set; } = string.Empty;
[Required] public string TemplateName { get; set; } = string.Empty;
public string? Description { get; set; }
[Required, MinLength(1)] public List<CreateBundleSaleTemplateLineRequest> Lines { get; set; } = new();
}
public sealed class CreateBundleSaleRequest
{
[Required] public int CustomerId { get; set; }
[Required] public int WarehouseId { get; set; }
[Required] public int CashierUserId { get; set; }
[Required] public int BundleSaleTemplateId { get; set; }
[Required] public string BundleName { get; set; } = string.Empty;
[Range(0, double.MaxValue)] public decimal BundlePrice { get; set; }
public bool AllowPriceOverride { get; set; }
[Required, MinLength(1)] public List<CreateBundleSaleTemplateLineRequest> Lines { get; set; } = new();
}
public sealed class UpdateBundleSaleRequest
{
[Required] public int CustomerId { get; set; }
[Required] public int WarehouseId { get; set; }
[Required] public int CashierUserId { get; set; }
[Required] public int BundleSaleTemplateId { get; set; }
[Required] public string BundleName { get; set; } = string.Empty;
[Range(0, double.MaxValue)] public decimal BundlePrice { get; set; }
public bool AllowPriceOverride { get; set; }
[Required, MinLength(1)] public List<CreateBundleSaleTemplateLineRequest> Lines { get; set; } = new();
}
@@ -0,0 +1,74 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Sales;
public sealed record SalesInvoiceLineDto(
int SalesInvoiceLineId, int ItemId, string Description, decimal Qty, decimal FreeQty, int UomId,
int WarehouseId, decimal UnitPrice, decimal BaseCost, string PriceSource, decimal DiscountPct,
decimal DiscountAmount, SalesDiscountMode DiscountMode, decimal NetUnitPrice, decimal LineTotal,
decimal TaxPct, decimal TaxAmount, bool IsFreeIssue, int? ParentLineId);
public sealed record SalesInvoiceTotalsDto(
decimal Subtotal, decimal DiscountTotal, decimal FreeQtyTotal, decimal TaxTotal, decimal GrandTotal,
decimal RoundOff, decimal NetPayable, decimal PaidAmount, decimal BalanceAmount);
public sealed record SalesInvoiceDto(
int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId,
string CustomerSnapshotName, string? CustomerSnapshotTaxNo, int WarehouseId,
SalesInvoiceType InvoiceType, SalesInvoiceStatus Status, int CreatedBy, DateTime CreatedAt,
DateTime? UpdatedAt, SalesInvoiceTotalsDto Totals, IReadOnlyList<SalesInvoiceLineDto> Lines);
public sealed record SalesInvoiceSummaryDto(
int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId,
string CustomerSnapshotName, int WarehouseId, SalesInvoiceType InvoiceType,
SalesInvoiceStatus Status, SalesInvoiceTotalsDto Totals, DateTime CreatedAt);
/// <summary>
/// A line that cannot be posted for lack of stock. The three quantities are in the item's
/// <b>base</b> UOM (on-hand only exists in base), which may differ from the UOM shown on the
/// line — hence <paramref name="BaseUomName"/>: without it a line reading "2 BOX" produces
/// an unexplained "requested 24, available 10".
/// </summary>
public sealed record SalesInvoicePostingIssueDto(
int SalesInvoiceLineId, int ItemId, string ItemSku, string ItemName, int WarehouseId,
decimal RequestedQty, decimal AvailableQty, decimal ShortQty, bool IsFreeIssue,
string BaseUomName = "");
public sealed record SalesInvoicePostingCheckDto(
int SalesInvoiceId, string InvoiceNo, SalesInvoiceStatus Status, bool CanPost,
IReadOnlyList<SalesInvoicePostingIssueDto> Issues);
public sealed class CreateSalesInvoiceLineRequest
{
[Required] public int ItemId { get; set; }
[Required] public int UomId { get; set; }
[Required] public int WarehouseId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
[Range(0, double.MaxValue)] public decimal FreeQty { get; set; }
[Range(0, double.MaxValue)] public decimal? UnitPrice { get; set; }
public bool AllowManualPriceOverride { get; set; }
[Required, EnumDataType(typeof(SalesDiscountMode))] public SalesDiscountMode DiscountMode { get; set; } = SalesDiscountMode.Percentage;
[Range(0, 100)] public decimal DiscountPct { get; set; }
[Range(0, double.MaxValue)] public decimal DiscountAmount { get; set; }
[Range(0, double.MaxValue)] public decimal DiscountValue { get; set; }
[Range(0, 100)] public decimal TaxPct { get; set; }
public bool IsFreeIssue { get; set; }
public int? ParentLineId { get; set; }
}
public sealed class CreateSalesInvoiceRequest
{
[Required] public int CustomerId { get; set; }
[Required] public int WarehouseId { get; set; }
[Required, EnumDataType(typeof(SalesInvoiceType))] public SalesInvoiceType InvoiceType { get; set; } = SalesInvoiceType.B2C;
[Required, MinLength(1)] public List<CreateSalesInvoiceLineRequest> Lines { get; set; } = new();
}
public sealed class UpdateSalesInvoiceRequest
{
[Required] public int CustomerId { get; set; }
[Required] public int WarehouseId { get; set; }
[Required, EnumDataType(typeof(SalesInvoiceType))] public SalesInvoiceType InvoiceType { get; set; } = SalesInvoiceType.B2C;
[Required, MinLength(1)] public List<CreateSalesInvoiceLineRequest> Lines { get; set; } = new();
}
@@ -0,0 +1,23 @@
namespace ERPCore.Dtos.Sales;
public sealed record SalesFreeIssueRewardOptionDto(
int ItemId,
string Sku,
string Name,
decimal? SalePrice);
public sealed record SalesFreeIssueSuggestionLineDto(
int SalesSlipLineId,
int ItemId,
string ItemSku,
string ItemName,
decimal Qty,
decimal SuggestedFreeQty,
decimal TriggerQty,
IReadOnlyList<SalesFreeIssueRewardOptionDto> RewardOptions);
public sealed record SalesFreeIssueSuggestionDto(
int SalesSlipId,
string SlipNo,
DateTime SlipDate,
IReadOnlyList<SalesFreeIssueSuggestionLineDto> Lines);
@@ -0,0 +1,68 @@
namespace ERPCore.Dtos.Sales;
public sealed record SalesDailySummaryRowDto(
DateOnly Date,
int InvoiceCount,
int SlipCount,
decimal InvoiceSubtotal,
decimal SlipSubtotal,
decimal DiscountTotal,
decimal FreeQtyTotal,
decimal TaxTotal,
decimal GrandTotal);
public sealed record SalesItemSummaryRowDto(
int ItemId,
string ItemName,
decimal SoldQty,
decimal FreeQty,
decimal GrossAmount,
decimal DiscountTotal,
decimal TaxTotal,
decimal NetAmount);
public sealed record SalesCustomerSummaryRowDto(
int CustomerId,
string CustomerName,
int InvoiceCount,
int SlipCount,
decimal SoldQty,
decimal FreeQty,
decimal GrossAmount,
decimal DiscountTotal,
decimal TaxTotal,
decimal NetAmount);
public sealed record SalesWarehouseSummaryRowDto(
int WarehouseId,
string WarehouseName,
int InvoiceCount,
int SlipCount,
decimal SoldQty,
decimal FreeQty,
decimal GrossAmount,
decimal DiscountTotal,
decimal TaxTotal,
decimal NetAmount);
public sealed record SalesDiscountSummaryRowDto(
string DocumentType,
string DocumentNo,
DateTime DocumentDate,
string CustomerName,
decimal Subtotal,
decimal DiscountTotal,
decimal TaxTotal,
decimal NetAmount);
public sealed record SalesFreeIssueSummaryRowDto(
string DocumentType,
string DocumentNo,
DateTime DocumentDate,
string CustomerName,
int ItemId,
string ItemName,
decimal FreeQty,
decimal FreeValue,
int WarehouseId,
string WarehouseName);
@@ -0,0 +1,21 @@
namespace ERPCore.Dtos.Sales;
public sealed record SalesReportDefinitionDto(
string Id,
string Name,
string Description,
IReadOnlyList<string> SupportedFilters);
public sealed record SalesReportQueryRequest(
string ReportType,
DateOnly From,
DateOnly To,
int? ItemId = null,
int? CustomerId = null,
int? WarehouseId = null);
public sealed record SalesReportQueryResponse(
string ReportType,
DateOnly From,
DateOnly To,
IReadOnlyList<object> Rows);
@@ -0,0 +1,79 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Sales;
public sealed record SalesSlipLineDto(
int SalesSlipLineId, int ItemId, string Description, decimal Qty, decimal FreeQty, int UomId,
int WarehouseId, decimal UnitPrice, decimal BaseCost, string PriceSource, decimal DiscountPct,
decimal DiscountAmount, SalesDiscountMode DiscountMode, decimal NetUnitPrice, decimal LineTotal,
decimal TaxPct, decimal TaxAmount, bool IsFreeIssue, int? ParentLineId);
public sealed record SalesSlipTotalsDto(
decimal Subtotal, decimal DiscountTotal, decimal FreeQtyTotal, decimal TaxTotal, decimal GrandTotal,
decimal PaidAmount, decimal BalanceAmount);
public sealed record SalesSlipDto(
int SalesSlipId, string SlipNo, DateTime SlipDate, int CustomerId,
string CustomerSnapshotName, int WarehouseId, int CashierUserId, SalesSlipStatus Status,
DateTime CreatedAt, DateTime? UpdatedAt, SalesSlipTotalsDto Totals, IReadOnlyList<SalesSlipLineDto> Lines);
public sealed record SalesSlipSummaryDto(
int SalesSlipId, string SlipNo, DateTime SlipDate, int CustomerId,
string CustomerSnapshotName, int WarehouseId, SalesSlipStatus Status,
SalesSlipTotalsDto Totals, DateTime CreatedAt);
/// <summary>Quantities are in the item's base UOM — see <c>SalesInvoicePostingIssueDto</c>.</summary>
public sealed record SalesSlipPostingIssueDto(
int SalesSlipLineId, int ItemId, string ItemSku, string ItemName, int WarehouseId,
decimal RequestedQty, decimal AvailableQty, decimal ShortQty, bool IsFreeIssue,
string BaseUomName = "");
public sealed record SalesSlipPostingCheckDto(
int SalesSlipId, string SlipNo, SalesSlipStatus Status, bool CanPost,
IReadOnlyList<SalesSlipPostingIssueDto> Issues);
public sealed record FreeIssueSummaryDto(
int SalesSlipId, string SlipNo, SalesSlipStatus Status, DateTime CreatedAt,
int WarehouseId, string WarehouseName, int ItemId, string ItemSku, string ItemName,
int UomId, string UomName, decimal Qty, decimal FreeQty, string SchemeLabel);
public sealed record FreeIssueDto(
int SalesSlipId, string SlipNo, DateTime SlipDate, SalesSlipStatus Status,
int CustomerId, string CustomerSnapshotName, int WarehouseId, string WarehouseName,
int CashierUserId, DateTime CreatedAt, DateTime? UpdatedAt, FreeIssueSummaryDto Summary,
IReadOnlyList<SalesSlipLineDto> Lines);
public sealed class CreateSalesSlipLineRequest
{
[Required] public int ItemId { get; set; }
[Required] public int UomId { get; set; }
[Required] public int WarehouseId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
[Range(0, double.MaxValue)] public decimal FreeQty { get; set; }
[Range(0, double.MaxValue)] public decimal? UnitPrice { get; set; }
public bool AllowManualPriceOverride { get; set; }
[Required, EnumDataType(typeof(SalesDiscountMode))] public SalesDiscountMode DiscountMode { get; set; } = SalesDiscountMode.Percentage;
[Range(0, 100)] public decimal DiscountPct { get; set; }
[Range(0, double.MaxValue)] public decimal DiscountAmount { get; set; }
[Range(0, double.MaxValue)] public decimal DiscountValue { get; set; }
[Range(0, 100)] public decimal TaxPct { get; set; }
public bool IsFreeIssue { get; set; }
public int? ParentLineId { get; set; }
}
public sealed class CreateSalesSlipRequest
{
[Required] public int CustomerId { get; set; }
[Required] public int WarehouseId { get; set; }
[Required] public int CashierUserId { get; set; }
[Required, MinLength(1)] public List<CreateSalesSlipLineRequest> Lines { get; set; } = new();
}
public sealed class UpdateSalesSlipRequest
{
[Required] public int CustomerId { get; set; }
[Required] public int WarehouseId { get; set; }
[Required] public int CashierUserId { get; set; }
[Required, MinLength(1)] public List<CreateSalesSlipLineRequest> Lines { get; set; } = new();
}
+11 -3
View File
@@ -2,16 +2,23 @@ using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Stock;
// Every quantity a stock endpoint returns is in the item's base UOM — stock, layers and the
// ledger are base-only by construction. The BaseUomId/BaseUomName pair on each of these DTOs
// exists so a client can *label* those figures; without it every stock screen renders a bare
// number the user has to guess the unit of. They are never a conversion instruction.
/// <summary>Stock enquiry (docs/11 §5.1). available = onHand onHold reserved inTransit(out).</summary>
public sealed record StockOnHandDto(
int ItemId, int WarehouseId, decimal OnHand, decimal Available,
decimal OnHold, decimal InTransit, decimal Reserved, DateTime AsOf);
decimal OnHold, decimal InTransit, decimal Reserved, DateTime AsOf,
int BaseUomId = 0, string BaseUomName = "");
/// <summary>A stock-ledger row (docs/11 §5.2).</summary>
public sealed record StockLedgerRowDto(
int LedgerId, int ItemId, int WarehouseId, int? BinId, int? BatchId, int? SerialId,
Direction Direction, decimal QtyBase, decimal UnitCost, decimal Value, decimal RunningBalance,
string SourceDocType, int SourceDocId, int UserId, DateTime CreatedAt);
string SourceDocType, int SourceDocId, int UserId, DateTime CreatedAt,
int BaseUomId = 0, string BaseUomName = "");
/// <summary>An open FIFO layer in a valuation (docs/11 §5.3).</summary>
public sealed record StockValuationLayerDto(int LayerId, decimal QtyRemaining, decimal UnitCost, decimal Value, DateTime ReceiptDate);
@@ -19,4 +26,5 @@ public sealed record StockValuationLayerDto(int LayerId, decimal QtyRemaining, d
/// <summary>Valuation of on-hand stock from open FIFO layers (docs/11 §5.3).</summary>
public sealed record StockValuationDto(
int ItemId, int WarehouseId, IReadOnlyList<StockValuationLayerDto> Layers,
decimal TotalQty, decimal TotalValue, string Currency, string CostingMethod);
decimal TotalQty, decimal TotalValue, string Currency, string CostingMethod,
int BaseUomId = 0, string BaseUomName = "");
+9
View File
@@ -9,3 +9,12 @@ public sealed class CreateUomRequest
{
[Required, StringLength(50)] public string Name { get; set; } = string.Empty;
}
/// <summary>
/// A UOM an item may actually be transacted in: its base UOM (<paramref name="Factor"/> 1,
/// <paramref name="IsBase"/> true) plus every UOM it has a conversion from. Backs both
/// entry-time validation and <c>GET /items/{itemId}/uoms</c>, so the client can offer only
/// units that will survive posting instead of the whole global list.
/// </summary>
/// <param name="Factor">Multiply a quantity in this UOM by <paramref name="Factor"/> to get base UOM.</param>
public sealed record AllowedUomDto(int UomId, string Name, decimal Factor, bool IsBase);
@@ -0,0 +1,55 @@
using ERPCore.System.Errors;
namespace ERPCore.Infra.Gl;
/// <summary>
/// HTTP implementation of <see cref="IGeneralLedgerClient"/>. Registered as a typed
/// client (`AddHttpClient&lt;IGeneralLedgerClient, GeneralLedgerClient&gt;`) with its
/// `BaseAddress` bound from `GeneralLedgerService:BaseUrl`. Every call attaches the
/// shared `GeneralLedgerService:ApiKey` as `X-Api-Key` and streams the request/response
/// body straight through, unparsed — GL's own response (status, content-type, body) is
/// returned exactly as received; nothing here reshapes it.
/// </summary>
public sealed class GeneralLedgerClient(HttpClient http, IConfiguration configuration) : IGeneralLedgerClient
{
private readonly HttpClient _http = http;
private readonly string _apiKey = configuration["GeneralLedgerService:ApiKey"] ?? string.Empty;
public async Task<GeneralLedgerResponse> SendAsync(
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct)
{
var relativeUri = path.TrimStart('/') + queryString;
using var request = new HttpRequestMessage(method, relativeUri);
request.Headers.TryAddWithoutValidation("X-Api-Key", _apiKey);
if (body is not null && method != HttpMethod.Get)
{
var content = new StreamContent(body);
if (!string.IsNullOrEmpty(contentType))
content.Headers.TryAddWithoutValidation("Content-Type", contentType);
request.Content = content;
}
HttpResponseMessage response;
try
{
response = await _http.SendAsync(request, ct);
}
catch (HttpRequestException)
{
throw new DomainException(ErrorCodes.GlServiceUnavailable, "The General Ledger service is unreachable.", 503);
}
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
{
throw new DomainException(ErrorCodes.GlServiceUnavailable, "The General Ledger service timed out.", 503);
}
var responseBody = await response.Content.ReadAsStringAsync(ct);
return new GeneralLedgerResponse
{
StatusCode = (int)response.StatusCode,
ContentType = response.Content.Headers.ContentType?.ToString(),
Body = responseBody
};
}
}
@@ -0,0 +1,15 @@
namespace ERPCore.Infra.Gl;
/// <summary>
/// Raw HTTP result from the external General Ledger service — status code, content
/// type, and body exactly as GL returned them. Deliberately un-reshaped: GL's own
/// envelope (see the GL service's own API reference) is passed through byte-for-byte
/// so its camelCase-success/PascalCase-error inconsistency and full decimal precision
/// survive the hop unchanged (docs/12-GENERAL-LEDGER-INTEGRATION.md).
/// </summary>
public sealed class GeneralLedgerResponse
{
public int StatusCode { get; init; }
public string? ContentType { get; init; }
public string Body { get; init; } = string.Empty;
}
@@ -0,0 +1,13 @@
namespace ERPCore.Infra.Gl;
/// <summary>
/// Typed HTTP transport to the external General Ledger service. Injects the shared
/// `X-Api-Key` secret and forwards method/path/query/body/content-type verbatim —
/// see docs/12-GENERAL-LEDGER-INTEGRATION.md. Internal: only
/// <see cref="ERPCore.Services.Interfaces.IGeneralLedgerService"/> consumes this.
/// </summary>
public interface IGeneralLedgerClient
{
Task<GeneralLedgerResponse> SendAsync(
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct);
}
@@ -0,0 +1,46 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class BundleSaleConfiguration : IEntityTypeConfiguration<BundleSale>
{
public void Configure(EntityTypeBuilder<BundleSale> builder)
{
builder.ToTable("bundle_sales");
builder.HasKey(x => x.BundleSaleId);
builder.Property(x => x.BundleNo).IsRequired().HasMaxLength(50);
builder.HasIndex(x => x.BundleNo).IsUnique();
builder.Property(x => x.BundleDate).IsRequired();
builder.Property(x => x.CustomerSnapshotName).IsRequired().HasMaxLength(200);
builder.Property(x => x.BundleName).IsRequired().HasMaxLength(200);
builder.Property(x => x.BundleCode).IsRequired().HasMaxLength(50);
builder.Property(x => x.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(BundleSaleStatus.Draft);
builder.Property(x => x.ComponentSubtotal).HasPrecision(18, 4);
builder.Property(x => x.BundlePrice).HasPrecision(18, 4);
builder.Property(x => x.MarginAmount).HasPrecision(18, 4);
builder.Property(x => x.DiscountTotal).HasPrecision(18, 4);
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);
builder.HasOne(x => x.CashierUser).WithMany().HasForeignKey(x => x.CashierUserId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.BundleSaleTemplate).WithMany().HasForeignKey(x => x.BundleSaleTemplateId).OnDelete(DeleteBehavior.Restrict);
builder.HasMany(x => x.Lines)
.WithOne(x => x.BundleSale)
.HasForeignKey(x => x.BundleSaleId)
.OnDelete(DeleteBehavior.Cascade);
}
}
@@ -0,0 +1,26 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class BundleSaleLineConfiguration : IEntityTypeConfiguration<BundleSaleLine>
{
public void Configure(EntityTypeBuilder<BundleSaleLine> builder)
{
builder.ToTable("bundle_sale_lines");
builder.HasKey(x => x.BundleSaleLineId);
builder.Property(x => x.Description).IsRequired().HasMaxLength(200);
builder.Property(x => x.Qty).HasPrecision(18, 4);
builder.Property(x => x.QtyBase).HasPrecision(18, 4);
builder.Property(x => x.ConversionFactor).HasPrecision(18, 6).HasDefaultValue(1m);
builder.Property(x => x.UnitPrice).HasPrecision(18, 4);
builder.Property(x => x.LineTotal).HasPrecision(18, 4);
builder.Property(x => x.IncludeInBundle).HasDefaultValue(true);
builder.Property(x => x.IsComponent).HasDefaultValue(true);
builder.HasOne(x => x.Item).WithMany().HasForeignKey(x => x.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Uom).WithMany().HasForeignKey(x => x.UomId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Warehouse).WithMany().HasForeignKey(x => x.WarehouseId).OnDelete(DeleteBehavior.Restrict);
}
}
@@ -0,0 +1,34 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class BundleSaleTemplateConfiguration : IEntityTypeConfiguration<BundleSaleTemplate>
{
public void Configure(EntityTypeBuilder<BundleSaleTemplate> builder)
{
builder.ToTable("bundle_sale_templates");
builder.HasKey(x => x.BundleSaleTemplateId);
builder.Property(x => x.TemplateCode).IsRequired().HasMaxLength(50);
builder.HasIndex(x => x.TemplateCode).IsUnique();
builder.Property(x => x.TemplateName).IsRequired().HasMaxLength(200);
builder.Property(x => x.Description).HasMaxLength(1000);
builder.Property(x => x.Status)
.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)
.HasForeignKey(x => x.BundleSaleTemplateId)
.OnDelete(DeleteBehavior.Cascade);
}
}
@@ -0,0 +1,21 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class BundleSaleTemplateLineConfiguration : IEntityTypeConfiguration<BundleSaleTemplateLine>
{
public void Configure(EntityTypeBuilder<BundleSaleTemplateLine> builder)
{
builder.ToTable("bundle_sale_template_lines");
builder.HasKey(x => x.BundleSaleTemplateLineId);
builder.Property(x => x.Qty).HasPrecision(18, 4);
builder.Property(x => x.UnitPrice).HasPrecision(18, 4);
builder.Property(x => x.SortOrder).HasDefaultValue(0);
builder.HasOne(x => x.Item).WithMany().HasForeignKey(x => x.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Uom).WithMany().HasForeignKey(x => x.UomId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Warehouse).WithMany().HasForeignKey(x => x.WarehouseId).OnDelete(DeleteBehavior.Restrict);
}
}
@@ -0,0 +1,50 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class CustomerConfiguration : IEntityTypeConfiguration<Customer>
{
public void Configure(EntityTypeBuilder<Customer> builder)
{
builder.ToTable("customers");
builder.HasKey(c => c.CustomerId);
builder.Property(c => c.CustomerCode).IsRequired().HasMaxLength(50);
builder.HasIndex(c => c.CustomerCode).IsUnique();
builder.Property(c => c.CustomerType)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(CustomerType.B2C);
builder.Property(c => c.Name).IsRequired().HasMaxLength(200);
builder.Property(c => c.DisplayName).HasMaxLength(200);
builder.Property(c => c.Phone).HasMaxLength(30);
builder.Property(c => c.Email).HasMaxLength(100);
builder.Property(c => c.AddressLine1).HasMaxLength(250);
builder.Property(c => c.AddressLine2).HasMaxLength(250);
builder.Property(c => c.City).HasMaxLength(100);
builder.Property(c => c.Country).HasMaxLength(100);
builder.Property(c => c.TaxRegistrationNo).HasMaxLength(50);
builder.Property(c => c.CreditLimit).HasPrecision(18, 4);
builder.Property(c => c.CreditDays).IsRequired();
builder.HasOne(c => c.DefaultWarehouse)
.WithMany()
.HasForeignKey(c => c.DefaultWarehouseId)
.OnDelete(DeleteBehavior.SetNull);
builder.Property(c => c.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.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);
}
}
@@ -36,6 +36,8 @@ public sealed class GrnLineConfiguration : IEntityTypeConfiguration<GrnLine>
builder.HasKey(l => l.GrnLineId);
builder.Property(l => l.Qty).HasPrecision(18, 4);
builder.Property(l => l.QtyBase).HasPrecision(18, 4);
builder.Property(l => l.ConversionFactor).HasPrecision(18, 6).HasDefaultValue(1m);
builder.Property(l => l.UnitCost).HasPrecision(18, 6);
builder.Property(l => l.PoUnitPrice).HasPrecision(18, 6);
builder.Property(l => l.DiscountPct).HasPrecision(9, 4);
@@ -36,7 +36,10 @@ public sealed class NavItemConfiguration : IEntityTypeConfiguration<NavItem>
new NavItem { NavItemId = 7, Code = "warehouses", Label = "Warehouses", Href = "/dashboard/warehouse", SortOrder = 7 },
new NavItem { NavItemId = 8, Code = "orders", Label = "Orders", Href = "/dashboard/orders", SortOrder = 8 },
new NavItem { NavItemId = 9, Code = "settings", Label = "Settings", Href = "/dashboard/settings", SortOrder = 9 },
new NavItem { NavItemId = 10, Code = "help", Label = "Help", Href = "/dashboard/help", SortOrder = 10 }
new NavItem { NavItemId = 10, Code = "help", Label = "Help", Href = "/dashboard/help", SortOrder = 10 },
new NavItem { NavItemId = 11, Code = "ledgers", Label = "Ledgers", Href = "/dashboard/ledgers", SortOrder = 11 },
new NavItem { NavItemId = 12, Code = "accounts", Label = "Accounts", Href = "/dashboard/accounts", SortOrder = 12 },
new NavItem { NavItemId = 13, Code = "sales", Label = "Sales", Href = "/dashboard/sales", SortOrder = 13 }
);
}
}
@@ -42,10 +42,26 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration<Permissio
new Permission { PermissionId = 16, Code = "NAV:products.configuration", SubNavItemId = 6 },
new Permission { PermissionId = 17, Code = "NAV:settings.roles", SubNavItemId = 7 },
new Permission { PermissionId = 18, Code = "NAV:settings.users", SubNavItemId = 8 },
new Permission { PermissionId = 19, Code = "NAV:procurement.requisitions", SubNavItemId = 9 },
new Permission { PermissionId = 20, Code = "NAV:procurement.rfqs", SubNavItemId = 10 },
new Permission { PermissionId = 21, Code = "NAV:procurement.purchase-orders", SubNavItemId = 11 },
new Permission { PermissionId = 22, Code = "NAV:procurement.purchase-returns", SubNavItemId = 12 }
// IDs 28-31 (not 19-22): 19-22 were already claimed by the Ledgers permissions below;
// these procurement rows were never actually migrated into the database before now.
new Permission { PermissionId = 28, Code = "NAV:procurement.requisitions", SubNavItemId = 17 },
new Permission { PermissionId = 29, Code = "NAV:procurement.rfqs", SubNavItemId = 18 },
new Permission { PermissionId = 30, Code = "NAV:procurement.purchase-orders", SubNavItemId = 19 },
new Permission { PermissionId = 31, Code = "NAV:procurement.purchase-returns", SubNavItemId = 20 },
new Permission { PermissionId = 19, Code = "NAV:ledgers", NavItemId = 11 },
new Permission { PermissionId = 20, Code = "NAV:ledgers.trial-balance", SubNavItemId = 9 },
new Permission { PermissionId = 21, Code = "NAV:ledgers.balance-sheet", SubNavItemId = 10 },
new Permission { PermissionId = 22, Code = "NAV:ledgers.general-ledger", SubNavItemId = 11 },
new Permission { PermissionId = 23, Code = "NAV:ledgers.profit-and-loss", SubNavItemId = 12 },
new Permission { PermissionId = 24, Code = "NAV:ledgers.cash-flow", SubNavItemId = 13 },
new Permission { PermissionId = 25, Code = "NAV:ledgers.budget-vs-actual", SubNavItemId = 14 },
new Permission { PermissionId = 27, Code = "NAV:ledgers.tax-report", SubNavItemId = 16 },
// Moved under the new Accounts nav item (2026-07-31) — same PermissionId (26), just a
// renamed Code, so any role already granted this permission keeps it.
new Permission { PermissionId = 26, Code = "NAV:accounts.bank-accounts", SubNavItemId = 15 },
new Permission { PermissionId = 32, Code = "NAV:accounts", NavItemId = 12 },
new Permission { PermissionId = 33, Code = "NAV:accounts.cheque-books", SubNavItemId = 21 },
new Permission { PermissionId = 34, Code = "NAV:accounts.received-cheques", SubNavItemId = 22 }
);
}
}
@@ -0,0 +1,277 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
// Manufacturing / Production Lines (docs/30 Part C). All eleven tables live in this one
// file, following the StockConfiguration.cs precedent of grouping an aggregate's
// configurations together.
//
// Cascade shape (deliberate): the two aggregate roots cascade to their own children —
// template → stages/edges → inputs/outputs, run → stages/edges/events → inputs/outputs.
// Every *cross* reference is Restrict, because making them cascade would give EF two
// delete paths to the same table and the model validator rejects that. The consequence
// is an ordering rule for the service layer: when replacing a graph, delete edges before
// stages and inputs before outputs.
public sealed class ProductionTemplateConfiguration : IEntityTypeConfiguration<ProductionTemplate>
{
public void Configure(EntityTypeBuilder<ProductionTemplate> builder)
{
builder.ToTable("production_templates");
builder.HasKey(t => t.TemplateId);
builder.Property(t => t.Code).IsRequired().HasMaxLength(30);
builder.HasIndex(t => t.Code).IsUnique();
builder.Property(t => t.Name).IsRequired().HasMaxLength(150);
builder.Property(t => t.Description).HasMaxLength(500);
builder.Property(t => t.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
// 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.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);
}
}
public sealed class TemplateStageConfiguration : IEntityTypeConfiguration<TemplateStage>
{
public void Configure(EntityTypeBuilder<TemplateStage> builder)
{
builder.ToTable("template_stages");
builder.HasKey(s => s.StageId);
builder.Property(s => s.Name).IsRequired().HasMaxLength(150);
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.HasOne(s => s.Template).WithMany(t => t.Stages)
.HasForeignKey(s => s.TemplateId).OnDelete(DeleteBehavior.Cascade);
}
}
public sealed class StageEdgeConfiguration : IEntityTypeConfiguration<StageEdge>
{
public void Configure(EntityTypeBuilder<StageEdge> builder)
{
builder.ToTable("stage_edges");
builder.HasKey(e => e.EdgeId);
builder.HasOne(e => e.Template).WithMany(t => t.Edges)
.HasForeignKey(e => e.TemplateId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(e => e.ParentStage).WithMany()
.HasForeignKey(e => e.ParentStageId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(e => e.ChildStage).WithMany()
.HasForeignKey(e => e.ChildStageId).OnDelete(DeleteBehavior.Restrict);
// One arrow per ordered pair; self-loops and cycles are rejected by the validator.
builder.HasIndex(e => new { e.ParentStageId, e.ChildStageId }).IsUnique();
}
}
public sealed class StageInputConfiguration : IEntityTypeConfiguration<StageInput>
{
public void Configure(EntityTypeBuilder<StageInput> builder)
{
builder.ToTable("stage_inputs");
builder.HasKey(i => i.InputId);
builder.Property(i => i.Source).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(i => i.QtyPerBatch).HasPrecision(18, 4);
builder.HasOne(i => i.Stage).WithMany(s => s.Inputs)
.HasForeignKey(i => i.StageId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(i => i.Item).WithMany().HasForeignKey(i => i.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(i => i.Uom).WithMany().HasForeignKey(i => i.UomId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(i => i.FromOutput).WithMany()
.HasForeignKey(i => i.FromOutputId).OnDelete(DeleteBehavior.Restrict);
}
}
public sealed class StageOutputConfiguration : IEntityTypeConfiguration<StageOutput>
{
public void Configure(EntityTypeBuilder<StageOutput> builder)
{
builder.ToTable("stage_outputs");
builder.HasKey(o => o.OutputId);
builder.Property(o => o.Name).IsRequired().HasMaxLength(150);
builder.Property(o => o.QtyPerBatch).HasPrecision(18, 4);
builder.HasOne(o => o.Stage).WithMany(s => s.Outputs)
.HasForeignKey(o => o.StageId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(o => o.Item).WithMany().HasForeignKey(o => o.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(o => o.Uom).WithMany().HasForeignKey(o => o.UomId).OnDelete(DeleteBehavior.Restrict);
}
}
public sealed class ProductionRunConfiguration : IEntityTypeConfiguration<ProductionRun>
{
public void Configure(EntityTypeBuilder<ProductionRun> builder)
{
builder.ToTable("production_runs");
builder.HasKey(r => r.RunId);
builder.Property(r => r.DocNo).IsRequired().HasMaxLength(30);
builder.HasIndex(r => r.DocNo).IsUnique();
builder.Property(r => r.TargetQty).HasPrecision(18, 4);
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);
builder.HasOne(r => r.Warehouse).WithMany().HasForeignKey(r => r.WarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(r => r.OutputBin).WithMany().HasForeignKey(r => r.OutputBinId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(r => r.CancelReason).WithMany().HasForeignKey(r => r.CancelReasonCodeId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(r => r.Creator).WithMany().HasForeignKey(r => r.CreatedBy).OnDelete(DeleteBehavior.Restrict);
// Run board filters (docs/30 §D.2) and the template edit-lock count (FR-MFG-06).
builder.HasIndex(r => r.Status);
builder.HasIndex(r => new { r.TemplateId, r.Status });
}
}
public sealed class RunStageConfiguration : IEntityTypeConfiguration<RunStage>
{
public void Configure(EntityTypeBuilder<RunStage> builder)
{
builder.ToTable("run_stages");
builder.HasKey(s => s.RunStageId);
builder.Property(s => s.Name).IsRequired().HasMaxLength(150);
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.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.HasOne(s => s.Run).WithMany(r => r.Stages)
.HasForeignKey(s => s.RunId).OnDelete(DeleteBehavior.Cascade);
// SetNull, not Restrict: a template edit may delete a stage that completed or
// cancelled runs still point at. Everything needed to render such a run is copied
// onto this row, so losing the provenance link is the intended trade (FR-MFG-06).
builder.HasOne(s => s.TemplateStage).WithMany()
.HasForeignKey(s => s.TemplateStageId).OnDelete(DeleteBehavior.SetNull);
builder.HasIndex(s => new { s.RunId, s.Status });
}
}
public sealed class RunEdgeConfiguration : IEntityTypeConfiguration<RunEdge>
{
public void Configure(EntityTypeBuilder<RunEdge> builder)
{
builder.ToTable("run_edges");
builder.HasKey(e => e.RunEdgeId);
builder.HasOne(e => e.Run).WithMany(r => r.Edges)
.HasForeignKey(e => e.RunId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(e => e.ParentRunStage).WithMany()
.HasForeignKey(e => e.ParentRunStageId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(e => e.ChildRunStage).WithMany()
.HasForeignKey(e => e.ChildRunStageId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(e => new { e.ParentRunStageId, e.ChildRunStageId }).IsUnique();
}
}
public sealed class RunStageInputConfiguration : IEntityTypeConfiguration<RunStageInput>
{
public void Configure(EntityTypeBuilder<RunStageInput> builder)
{
builder.ToTable("run_stage_inputs");
builder.HasKey(i => i.RunInputId);
builder.Property(i => i.Source).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(i => i.PlannedQty).HasPrecision(18, 4);
builder.Property(i => i.ConsumedQty).HasPrecision(18, 4);
builder.Property(i => i.ConsumedValue).HasPrecision(18, 4);
builder.Property(i => i.DeliveredQty).HasPrecision(18, 4);
builder.Property(i => i.ReturnedQty).HasPrecision(18, 4);
builder.Property(i => i.ReturnedValue).HasPrecision(18, 4);
builder.HasOne(i => i.RunStage).WithMany(s => s.Inputs)
.HasForeignKey(i => i.RunStageId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(i => i.Item).WithMany().HasForeignKey(i => i.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(i => i.Uom).WithMany().HasForeignKey(i => i.UomId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(i => i.FromRunOutput).WithMany()
.HasForeignKey(i => i.FromRunOutputId).OnDelete(DeleteBehavior.Restrict);
// Transfers route by the source output, so this is the hot lookup.
builder.HasIndex(i => i.FromRunOutputId);
}
}
public sealed class RunStageOutputConfiguration : IEntityTypeConfiguration<RunStageOutput>
{
public void Configure(EntityTypeBuilder<RunStageOutput> builder)
{
builder.ToTable("run_stage_outputs");
builder.HasKey(o => o.RunOutputId);
builder.Property(o => o.Name).IsRequired().HasMaxLength(150);
builder.Property(o => o.PlannedQty).HasPrecision(18, 4);
builder.Property(o => o.ProducedQty).HasPrecision(18, 4);
builder.Property(o => o.ScrappedQty).HasPrecision(18, 4);
builder.Property(o => o.TransferredQty).HasPrecision(18, 4);
builder.HasOne(o => o.RunStage).WithMany(s => s.Outputs)
.HasForeignKey(o => o.RunStageId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(o => o.Item).WithMany().HasForeignKey(o => o.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(o => o.Uom).WithMany().HasForeignKey(o => o.UomId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(o => o.ScrapReason).WithMany().HasForeignKey(o => o.ScrapReasonCodeId).OnDelete(DeleteBehavior.Restrict);
}
}
public sealed class RunStageEventConfiguration : IEntityTypeConfiguration<RunStageEvent>
{
public void Configure(EntityTypeBuilder<RunStageEvent> builder)
{
// Append-only history: the app never updates or deletes these rows.
builder.ToTable("run_stage_events");
builder.HasKey(e => e.EventId);
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.CreatedAt).IsRequired();
builder.HasOne(e => e.Run).WithMany(r => r.Events)
.HasForeignKey(e => e.RunId).OnDelete(DeleteBehavior.Cascade);
// SetNull rather than Cascade: the run-level cascade above already removes these
// rows, and a second cascade path (run → stage → event) is what EF rejects.
builder.HasOne(e => e.RunStage).WithMany(s => s.Events)
.HasForeignKey(e => e.RunStageId).OnDelete(DeleteBehavior.SetNull);
builder.HasOne(e => e.User).WithMany().HasForeignKey(e => e.UserId).OnDelete(DeleteBehavior.Restrict);
// The run-detail timeline reads the whole run's history in one ordered pass.
builder.HasIndex(e => new { e.RunId, e.EventId });
}
}
@@ -49,6 +49,9 @@ public sealed class PoLineConfiguration : IEntityTypeConfiguration<PoLine>
builder.HasKey(l => l.PoLineId);
builder.Property(l => l.Qty).HasPrecision(18, 4);
builder.Property(l => l.QtyBase).HasPrecision(18, 4);
builder.Property(l => l.QtyReceivedBase).HasPrecision(18, 4);
builder.Property(l => l.ConversionFactor).HasPrecision(18, 6).HasDefaultValue(1m);
builder.Property(l => l.UnitPrice).HasPrecision(18, 4);
builder.Property(l => l.Tax).HasPrecision(9, 4);
builder.Property(l => l.QtyReceived).HasPrecision(18, 4);
@@ -0,0 +1,98 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class SalesInvoiceConfiguration : IEntityTypeConfiguration<SalesInvoice>
{
public void Configure(EntityTypeBuilder<SalesInvoice> builder)
{
builder.ToTable("sales_invoices");
builder.HasKey(x => x.SalesInvoiceId);
builder.Property(x => x.InvoiceNo).IsRequired().HasMaxLength(50);
builder.HasIndex(x => x.InvoiceNo).IsUnique();
builder.Property(x => x.InvoiceDate).IsRequired();
builder.HasOne(x => x.Customer)
.WithMany()
.HasForeignKey(x => x.CustomerId)
.OnDelete(DeleteBehavior.Restrict);
builder.Property(x => x.CustomerSnapshotName).IsRequired().HasMaxLength(200);
builder.Property(x => x.CustomerSnapshotTaxNo).HasMaxLength(50);
builder.HasOne(x => x.Warehouse)
.WithMany()
.HasForeignKey(x => x.WarehouseId)
.OnDelete(DeleteBehavior.Restrict);
builder.Property(x => x.InvoiceType)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(SalesInvoiceType.B2C);
builder.Property(x => x.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(SalesInvoiceStatus.Draft);
foreach (var p in new[] { nameof(SalesInvoice.Subtotal), nameof(SalesInvoice.DiscountTotal), nameof(SalesInvoice.TaxTotal), nameof(SalesInvoice.GrandTotal), nameof(SalesInvoice.RoundOff), nameof(SalesInvoice.NetPayable), nameof(SalesInvoice.PaidAmount), nameof(SalesInvoice.BalanceAmount) })
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.InvoiceDate);
builder.HasMany(x => x.Lines)
.WithOne(x => x.SalesInvoice)
.HasForeignKey(x => x.SalesInvoiceId)
.OnDelete(DeleteBehavior.Cascade);
}
}
public sealed class SalesInvoiceLineConfiguration : IEntityTypeConfiguration<SalesInvoiceLine>
{
public void Configure(EntityTypeBuilder<SalesInvoiceLine> builder)
{
builder.ToTable("sales_invoice_lines");
builder.HasKey(x => x.SalesInvoiceLineId);
builder.HasOne(x => x.Item)
.WithMany()
.HasForeignKey(x => x.ItemId)
.OnDelete(DeleteBehavior.Restrict);
builder.Property(x => x.Description).IsRequired().HasMaxLength(200);
builder.Property(x => x.Qty).HasPrecision(18, 4);
builder.Property(x => x.FreeQty).HasPrecision(18, 4);
builder.Property(x => x.QtyBase).HasPrecision(18, 4);
builder.Property(x => x.FreeQtyBase).HasPrecision(18, 4);
builder.Property(x => x.ConversionFactor).HasPrecision(18, 6).HasDefaultValue(1m);
builder.Property(x => x.UnitPrice).HasPrecision(18, 4);
builder.Property(x => x.BaseCost).HasPrecision(18, 4);
builder.Property(x => x.DiscountPct).HasPrecision(9, 4);
builder.Property(x => x.DiscountAmount).HasPrecision(18, 4);
builder.Property(x => x.NetUnitPrice).HasPrecision(18, 4);
builder.Property(x => x.LineTotal).HasPrecision(18, 4);
builder.Property(x => x.TaxPct).HasPrecision(9, 4);
builder.Property(x => x.TaxAmount).HasPrecision(18, 4);
builder.HasOne(x => x.Uom)
.WithMany()
.HasForeignKey(x => x.UomId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Warehouse)
.WithMany()
.HasForeignKey(x => x.WarehouseId)
.OnDelete(DeleteBehavior.Restrict);
builder.Property(x => x.PriceSource).HasMaxLength(50);
builder.Property(x => x.RowVersion).IsRowVersion();
}
}
@@ -0,0 +1,98 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class SalesSlipConfiguration : IEntityTypeConfiguration<SalesSlip>
{
public void Configure(EntityTypeBuilder<SalesSlip> builder)
{
builder.ToTable("sales_slips");
builder.HasKey(x => x.SalesSlipId);
builder.Property(x => x.SlipNo).IsRequired().HasMaxLength(50);
builder.HasIndex(x => x.SlipNo).IsUnique();
builder.Property(x => x.SlipDate).IsRequired();
builder.HasOne(x => x.Customer)
.WithMany()
.HasForeignKey(x => x.CustomerId)
.OnDelete(DeleteBehavior.Restrict);
builder.Property(x => x.CustomerSnapshotName).IsRequired().HasMaxLength(200);
builder.HasOne(x => x.Warehouse)
.WithMany()
.HasForeignKey(x => x.WarehouseId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.CashierUser)
.WithMany()
.HasForeignKey(x => x.CashierUserId)
.OnDelete(DeleteBehavior.Restrict);
builder.Property(x => x.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(SalesSlipStatus.Draft);
foreach (var p in new[] { nameof(SalesSlip.Subtotal), nameof(SalesSlip.DiscountTotal), nameof(SalesSlip.TaxTotal), nameof(SalesSlip.GrandTotal), nameof(SalesSlip.PaidAmount), nameof(SalesSlip.BalanceAmount) })
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);
builder.HasMany(x => x.Lines)
.WithOne(x => x.SalesSlip)
.HasForeignKey(x => x.SalesSlipId)
.OnDelete(DeleteBehavior.Cascade);
}
}
public sealed class SalesSlipLineConfiguration : IEntityTypeConfiguration<SalesSlipLine>
{
public void Configure(EntityTypeBuilder<SalesSlipLine> builder)
{
builder.ToTable("sales_slip_lines");
builder.HasKey(x => x.SalesSlipLineId);
builder.HasOne(x => x.Item)
.WithMany()
.HasForeignKey(x => x.ItemId)
.OnDelete(DeleteBehavior.Restrict);
builder.Property(x => x.Description).IsRequired().HasMaxLength(200);
builder.Property(x => x.Qty).HasPrecision(18, 4);
builder.Property(x => x.FreeQty).HasPrecision(18, 4);
builder.Property(x => x.QtyBase).HasPrecision(18, 4);
builder.Property(x => x.FreeQtyBase).HasPrecision(18, 4);
builder.Property(x => x.ConversionFactor).HasPrecision(18, 6).HasDefaultValue(1m);
builder.Property(x => x.UnitPrice).HasPrecision(18, 4);
builder.Property(x => x.BaseCost).HasPrecision(18, 4);
builder.Property(x => x.DiscountPct).HasPrecision(9, 4);
builder.Property(x => x.DiscountAmount).HasPrecision(18, 4);
builder.Property(x => x.NetUnitPrice).HasPrecision(18, 4);
builder.Property(x => x.LineTotal).HasPrecision(18, 4);
builder.Property(x => x.TaxPct).HasPrecision(9, 4);
builder.Property(x => x.TaxAmount).HasPrecision(18, 4);
builder.HasOne(x => x.Uom)
.WithMany()
.HasForeignKey(x => x.UomId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Warehouse)
.WithMany()
.HasForeignKey(x => x.WarehouseId)
.OnDelete(DeleteBehavior.Restrict);
builder.Property(x => x.PriceSource).HasMaxLength(50);
builder.Property(x => x.RowVersion).IsRowVersion();
}
}
@@ -33,11 +33,27 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
new SubNavItem { SubNavItemId = 6, NavItemId = 2, Code = "products.configuration", Label = "Configuration", Href = "/dashboard/products/settings", SortOrder = 6 },
new SubNavItem { SubNavItemId = 7, NavItemId = 9, Code = "settings.roles", Label = "Roles", Href = "/dashboard/settings/roles", SortOrder = 1 },
new SubNavItem { SubNavItemId = 8, NavItemId = 9, Code = "settings.users", Label = "Users", Href = "/dashboard/settings/users", SortOrder = 2 },
new SubNavItem { SubNavItemId = 23, NavItemId = 13, Code = "sales.bundle-sales", Label = "Bundle Sales", Href = "/dashboard/sales/bundles", SortOrder = 1 },
// Procurement (NavItemId 4) children — mirror the hub page order.
new SubNavItem { SubNavItemId = 9, NavItemId = 4, Code = "procurement.requisitions", Label = "Requisitions", Href = "/dashboard/procurement/requisitions", SortOrder = 1 },
new SubNavItem { SubNavItemId = 10, NavItemId = 4, Code = "procurement.rfqs", Label = "RFQs", Href = "/dashboard/procurement/rfqs", SortOrder = 2 },
new SubNavItem { SubNavItemId = 11, NavItemId = 4, Code = "procurement.purchase-orders", Label = "Purchase Orders", Href = "/dashboard/procurement/purchase-orders", SortOrder = 3 },
new SubNavItem { SubNavItemId = 12, NavItemId = 4, Code = "procurement.purchase-returns", Label = "Purchase Returns", Href = "/dashboard/procurement/purchase-returns", SortOrder = 4 }
// IDs 17-20 (not 9-12): 9-12 were already claimed by the Ledgers sub-items below;
// these procurement rows were never actually migrated into the database before now.
new SubNavItem { SubNavItemId = 17, NavItemId = 4, Code = "procurement.requisitions", Label = "Requisitions", Href = "/dashboard/procurement/requisitions", SortOrder = 1 },
new SubNavItem { SubNavItemId = 18, NavItemId = 4, Code = "procurement.rfqs", Label = "RFQs", Href = "/dashboard/procurement/rfqs", SortOrder = 2 },
new SubNavItem { SubNavItemId = 19, NavItemId = 4, Code = "procurement.purchase-orders", Label = "Purchase Orders", Href = "/dashboard/procurement/purchase-orders", SortOrder = 3 },
new SubNavItem { SubNavItemId = 20, NavItemId = 4, Code = "procurement.purchase-returns", Label = "Purchase Returns", Href = "/dashboard/procurement/purchase-returns", SortOrder = 4 },
new SubNavItem { SubNavItemId = 9, NavItemId = 11, Code = "ledgers.trial-balance", Label = "Trial Balance", Href = "/dashboard/ledgers/trial-balance", SortOrder = 1 },
new SubNavItem { SubNavItemId = 10, NavItemId = 11, Code = "ledgers.balance-sheet", Label = "Balance Sheet", Href = "/dashboard/ledgers/balance-sheet", SortOrder = 2 },
new SubNavItem { SubNavItemId = 11, NavItemId = 11, Code = "ledgers.general-ledger", Label = "General Ledger", Href = "/dashboard/ledgers/general-ledger", SortOrder = 3 },
new SubNavItem { SubNavItemId = 12, NavItemId = 11, Code = "ledgers.profit-and-loss", Label = "Profit & Loss", Href = "/dashboard/ledgers/profit-and-loss", SortOrder = 4 },
new SubNavItem { SubNavItemId = 13, NavItemId = 11, Code = "ledgers.cash-flow", Label = "Cash Flow", Href = "/dashboard/ledgers/cash-flow", SortOrder = 5 },
new SubNavItem { SubNavItemId = 14, NavItemId = 11, Code = "ledgers.budget-vs-actual", Label = "Budget vs Actual", Href = "/dashboard/ledgers/budget-vs-actual", SortOrder = 6 },
new SubNavItem { SubNavItemId = 16, NavItemId = 11, Code = "ledgers.tax-report", Label = "Tax Report", Href = "/dashboard/ledgers/tax-report", SortOrder = 7 },
// Moved under the new Accounts nav item (2026-07-31) — kept the same SubNavItemId (15)
// rather than delete+recreate, so any role that already had this permission granted
// doesn't silently lose it just because the section it lives under changed.
new SubNavItem { SubNavItemId = 15, NavItemId = 12, Code = "accounts.bank-accounts", Label = "Cash / Bank Accounts", Href = "/dashboard/accounts/bank-accounts", SortOrder = 1 },
new SubNavItem { SubNavItemId = 21, NavItemId = 12, Code = "accounts.cheque-books", Label = "Cheque Books", Href = "/dashboard/accounts/cheque-books", SortOrder = 2 },
new SubNavItem { SubNavItemId = 22, NavItemId = 12, Code = "accounts.received-cheques", Label = "Received Cheques", Href = "/dashboard/accounts/received-cheques", SortOrder = 3 }
);
}
}
@@ -30,5 +30,9 @@ public sealed class UomConversionConfiguration : IEntityTypeConfiguration<UomCon
// One conversion per (item, from, to) triple.
builder.HasIndex(c => new { c.ItemId, c.FromUomId, c.ToUomId }).IsUnique();
// The API guards this too, but UomConverter divides unit cost by the factor — a zero
// reaching the table from a seeder or direct SQL would be a divide-by-zero at post time.
builder.ToTable(t => t.HasCheckConstraint("ck_uom_conversions_factor_positive", "\"Factor\" > 0"));
}
}
@@ -1,4 +1,5 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
@@ -30,13 +31,23 @@ public static class DataSeeder
("WRONG", "Wrong Item", ReasonContext.Return),
("OVER", "Over-supply", ReasonContext.Return),
("QREJ", "Quality Reject", ReasonContext.Return),
// Manufacturing (docs/30 §A.2) — scrap at stage complete, leftover return before
// receipt, and the mandatory reason on a run cancel.
("PRD-SCRAP", "Production Scrap", ReasonContext.Production),
("PRD-LEFTOVER", "Production Leftover Return", ReasonContext.Production),
("PRD-CANCEL", "Production Run Cancelled", ReasonContext.Production),
("PRD-REWORK-LOSS", "Production Rework Loss", ReasonContext.Production),
];
public static async Task SeedAsync(ErpDbContext db, CancellationToken ct = default)
{
var dirty = await SeedReasonCodesAsync(db, ct);
dirty |= await SeedItemTypesAsync(db, ct);
// dirty |= await SeedCompanyProfileAsync(db, ct);
dirty |= await SeedProductConfigAsync(db, ct);
dirty |= await SeedSalesMastersAsync(db, ct);
dirty |= await SeedSalesStockAsync(db, ct);
dirty |= await SeedSalesAsync(db, ct);
if (dirty) await db.SaveChangesAsync(ct);
}
@@ -93,4 +104,754 @@ 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.SalePrice.GetValueOrDefault() > 0m ? item.SalePrice.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.SalePrice.GetValueOrDefault() > 0m ? secondItem.SalePrice.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,
SalePrice = 100m,
Status = EntityStatus.Active,
CreatedAt = now
},
new Item
{
Sku = "SKU-DEMO-002",
Name = "Demo Item 2",
Description = "Secondary seeded sample item for sales documents",
CategoryId = category.CategoryId,
BaseUomId = uom.UomId,
StockNature = StockNature.Stocked,
TrackingMode = TrackingMode.None,
SalePrice = 50m,
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,
UomId = uom.UomId,
WarehouseId = warehouse.WarehouseId,
Qty = 1m,
UnitPrice = items[0].SalePrice.GetValueOrDefault(),
IncludeInBundle = true,
SortOrder = 1
},
new BundleSaleTemplateLine
{
ItemId = items[1].ItemId,
UomId = uom.UomId,
WarehouseId = warehouse.WarehouseId,
Qty = 1m,
UnitPrice = items[1].SalePrice.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].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault(),
BundlePrice = 0m,
MarginAmount = -(items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault()),
DiscountTotal = items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault(),
TaxTotal = 0m,
GrandTotal = 0m,
CreatedAt = now.AddDays(-2),
Lines =
[
new BundleSaleLine
{
ItemId = items[0].ItemId,
Description = items[0].Name,
Qty = 1m,
UomId = uom.UomId,
WarehouseId = warehouse.WarehouseId,
UnitPrice = items[0].SalePrice.GetValueOrDefault(),
LineTotal = items[0].SalePrice.GetValueOrDefault(),
IncludeInBundle = true,
IsComponent = true
},
new BundleSaleLine
{
ItemId = items[1].ItemId,
Description = items[1].Name,
Qty = 1m,
UomId = uom.UomId,
WarehouseId = warehouse.WarehouseId,
UnitPrice = items[1].SalePrice.GetValueOrDefault(),
LineTotal = items[1].SalePrice.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].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault(),
BundlePrice = (items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault()) - 10m,
MarginAmount = -10m,
DiscountTotal = 10m,
TaxTotal = 0m,
GrandTotal = (items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault()) - 10m,
CreatedAt = now.AddDays(-1),
UpdatedAt = now.AddHours(-2),
Lines =
[
new BundleSaleLine
{
ItemId = items[0].ItemId,
Description = items[0].Name,
Qty = 1m,
UomId = uom.UomId,
WarehouseId = warehouse.WarehouseId,
UnitPrice = items[0].SalePrice.GetValueOrDefault(),
LineTotal = items[0].SalePrice.GetValueOrDefault(),
IncludeInBundle = true,
IsComponent = true
},
new BundleSaleLine
{
ItemId = items[1].ItemId,
Description = items[1].Name,
Qty = 1m,
UomId = uom.UomId,
WarehouseId = warehouse.WarehouseId,
UnitPrice = items[1].SalePrice.GetValueOrDefault(),
LineTotal = items[1].SalePrice.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].SalePrice.GetValueOrDefault(),
BundlePrice = items[0].SalePrice.GetValueOrDefault(),
MarginAmount = 0m,
DiscountTotal = 0m,
TaxTotal = 0m,
GrandTotal = items[0].SalePrice.GetValueOrDefault(),
CreatedAt = now,
UpdatedAt = now,
Lines =
[
new BundleSaleLine
{
ItemId = items[0].ItemId,
Description = items[0].Name,
Qty = 1m,
UomId = uom.UomId,
WarehouseId = secondaryWarehouse.WarehouseId,
UnitPrice = items[0].SalePrice.GetValueOrDefault(),
LineTotal = items[0].SalePrice.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,
UomId = uom.UomId,
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,
UomId = uom.UomId,
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,
UomId = uom.UomId,
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,
UomId = uom.UomId,
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,
UomId = uom.UomId,
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;
}
}
@@ -15,6 +15,7 @@ namespace ERPCore.Infra.Persistence;
public class ErpDbContext : DbContext
{
private readonly ICurrentUser _currentUser;
private bool _writingAuditLogs;
public ErpDbContext(DbContextOptions<ErpDbContext> options, ICurrentUser currentUser) : base(options)
{
@@ -22,6 +23,7 @@ public class ErpDbContext : DbContext
}
// --- Master Data (docs/10 Part C.1) ---
public DbSet<Customer> Customers => Set<Customer>();
public DbSet<Category> Categories => Set<Category>();
public DbSet<SubCategory> SubCategories => Set<SubCategory>();
public DbSet<Brand> Brands => Set<Brand>();
@@ -34,6 +36,7 @@ public class ErpDbContext : DbContext
public DbSet<Vendor> Vendors => Set<Vendor>();
public DbSet<Warehouse> Warehouses => Set<Warehouse>();
public DbSet<Bin> Bins => Set<Bin>();
/// <summary>Singleton row (FR-MD-11).</summary>
public DbSet<ProductConfig> ProductConfig => Set<ProductConfig>();
@@ -82,6 +85,16 @@ public class ErpDbContext : DbContext
public DbSet<PurchaseReturn> PurchaseReturns => Set<PurchaseReturn>();
public DbSet<PurchaseReturnLine> PurchaseReturnLines => Set<PurchaseReturnLine>();
// --- Sales (Phase 1) ---
public DbSet<SalesInvoice> SalesInvoices => Set<SalesInvoice>();
public DbSet<SalesInvoiceLine> SalesInvoiceLines => Set<SalesInvoiceLine>();
public DbSet<SalesSlip> SalesSlips => Set<SalesSlip>();
public DbSet<SalesSlipLine> SalesSlipLines => Set<SalesSlipLine>();
public DbSet<BundleSaleTemplate> BundleSaleTemplates => Set<BundleSaleTemplate>();
public DbSet<BundleSaleTemplateLine> BundleSaleTemplateLines => Set<BundleSaleTemplateLine>();
public DbSet<BundleSale> BundleSales => Set<BundleSale>();
public DbSet<BundleSaleLine> BundleSaleLines => Set<BundleSaleLine>();
// --- Reference data (docs/10 Part C.7) ---
public DbSet<ReasonCode> ReasonCodes => Set<ReasonCode>();
@@ -126,6 +139,21 @@ public class ErpDbContext : DbContext
public DbSet<PayrollLineComponent> PayrollLineComponents => Set<PayrollLineComponent>();
public DbSet<Payslip> Payslips => Set<Payslip>();
// --- Manufacturing: production templates (docs/30-BACKEND-PHASE2.md Part C) ---
public DbSet<ProductionTemplate> ProductionTemplates => Set<ProductionTemplate>();
public DbSet<TemplateStage> TemplateStages => Set<TemplateStage>();
public DbSet<StageEdge> StageEdges => Set<StageEdge>();
public DbSet<StageInput> StageInputs => Set<StageInput>();
public DbSet<StageOutput> StageOutputs => Set<StageOutput>();
// --- Manufacturing: production runs (docs/30-BACKEND-PHASE2.md Part C) ---
public DbSet<ProductionRun> ProductionRuns => Set<ProductionRun>();
public DbSet<RunStage> RunStages => Set<RunStage>();
public DbSet<RunEdge> RunEdges => Set<RunEdge>();
public DbSet<RunStageInput> RunStageInputs => Set<RunStageInput>();
public DbSet<RunStageOutput> RunStageOutputs => Set<RunStageOutput>();
public DbSet<RunStageEvent> RunStageEvents => Set<RunStageEvent>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
@@ -163,24 +191,44 @@ public class ErpDbContext : DbContext
// persists the logs without re-auditing them.
public override async Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken ct = default)
{
var pending = AuditScribe.Capture(ChangeTracker);
IReadOnlyList<PendingAudit> pending = _writingAuditLogs
? Array.Empty<PendingAudit>()
: AuditScribe.Capture(ChangeTracker);
var result = await base.SaveChangesAsync(acceptAllChangesOnSuccess, ct);
if (pending.Count > 0)
{
WriteAuditLogs(pending);
await base.SaveChangesAsync(acceptAllChangesOnSuccess, ct);
try
{
_writingAuditLogs = true;
WriteAuditLogs(pending);
await base.SaveChangesAsync(acceptAllChangesOnSuccess, ct);
}
finally
{
_writingAuditLogs = false;
}
}
return result;
}
public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
var pending = AuditScribe.Capture(ChangeTracker);
IReadOnlyList<PendingAudit> pending = _writingAuditLogs
? Array.Empty<PendingAudit>()
: AuditScribe.Capture(ChangeTracker);
var result = base.SaveChanges(acceptAllChangesOnSuccess);
if (pending.Count > 0)
{
WriteAuditLogs(pending);
base.SaveChanges(acceptAllChangesOnSuccess);
try
{
_writingAuditLogs = true;
WriteAuditLogs(pending);
base.SaveChanges(acceptAllChangesOnSuccess);
}
finally
{
_writingAuditLogs = false;
}
}
return result;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,442 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ERPCore.Infra.Persistence.Migrations
{
/// <summary>
/// Adds the Brand / SubCategory / ItemType masters and the singleton product config,
/// and converts CATEGORY from a self-nesting tree into a fixed two-level
/// Category → SubCategory hierarchy (docs/10 Part C.1).
/// <para>
/// <b>This migration carries data, not just DDL.</b> The scaffolded version dropped
/// <c>categories.ParentId</c> outright, which would have silently flattened every
/// child category into a root and left items pointing at what is now a top-level
/// category — losing the parent entirely. The hand-written steps below (marked
/// "data migration") move child categories into <c>subcategories</c> and repoint items
/// onto the correct (category, subcategory) pair before the column goes away.
/// </para>
/// </summary>
public partial class AddBrandsSubcategoriesItemTypesAndProductConfig : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// NOTE: the ParentId drop is deliberately deferred to the bottom of this method —
// the data migration reads it. Order here is load-bearing.
migrationBuilder.RenameColumn(
name: "ItemType",
table: "items",
newName: "StockNature");
migrationBuilder.AddColumn<int>(
name: "BrandId",
table: "items",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "SubCategoryId",
table: "items",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "CreatedAt",
table: "categories",
type: "timestamp with time zone",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
migrationBuilder.AddColumn<string>(
name: "Status",
table: "categories",
type: "character varying(20)",
maxLength: 20,
nullable: false,
defaultValue: "Active");
migrationBuilder.AddColumn<DateTime>(
name: "UpdatedAt",
table: "categories",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<uint>(
name: "xmin",
table: "categories",
type: "xid",
rowVersion: true,
nullable: false,
defaultValue: 0u);
migrationBuilder.CreateTable(
name: "brands",
columns: table => new
{
BrandId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_brands", x => x.BrandId);
});
migrationBuilder.CreateTable(
name: "item_types",
columns: table => new
{
ItemTypeId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_item_types", x => x.ItemTypeId);
});
migrationBuilder.CreateTable(
name: "product_config",
columns: table => new
{
ConfigId = table.Column<int>(type: "integer", nullable: false),
SubcategoriesEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
BrandsEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
ItemTypesEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
UpdatedBy = table.Column<int>(type: "integer", nullable: true),
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_product_config", x => x.ConfigId);
table.CheckConstraint("ck_product_config_singleton", "\"ConfigId\" = 1");
table.ForeignKey(
name: "FK_product_config_users_UpdatedBy",
column: x => x.UpdatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "subcategories",
columns: table => new
{
SubCategoryId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
CategoryId = table.Column<int>(type: "integer", nullable: false),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_subcategories", x => x.SubCategoryId);
table.ForeignKey(
name: "FK_subcategories_categories_CategoryId",
column: x => x.CategoryId,
principalTable: "categories",
principalColumn: "CategoryId",
onDelete: ReferentialAction.Restrict);
});
// ---------------------------------------------------------------------------
// DATA MIGRATION — must run before ParentId is dropped.
// ---------------------------------------------------------------------------
// Existing categories predate CreatedAt; the added column defaulted them to
// 0001-01-01. Stamp them with the migration time instead of a sentinel date.
migrationBuilder.Sql(@"
UPDATE categories SET ""CreatedAt"" = NOW() AT TIME ZONE 'utc';
");
// Carry the old category id alongside each new subcategory so items can be
// repointed by join below. Dropped again once the repoint is done.
migrationBuilder.Sql(@"
ALTER TABLE subcategories ADD COLUMN legacy_category_id integer;
");
// Walk the old tree to its roots. The previous model allowed unlimited nesting,
// but the new one is exactly two levels — so a category at any depth below the
// root collapses into a subcategory of its ROOT ancestor (a grandchild cannot
// become a subcategory of its immediate parent, since that parent is itself
// ceasing to be a category).
migrationBuilder.Sql(@"
WITH RECURSIVE tree AS (
SELECT ""CategoryId"", ""ParentId"", ""Name"", ""CategoryId"" AS root_id
FROM categories
WHERE ""ParentId"" IS NULL
UNION ALL
SELECT c.""CategoryId"", c.""ParentId"", c.""Name"", t.root_id
FROM categories c
JOIN tree t ON c.""ParentId"" = t.""CategoryId""
)
INSERT INTO subcategories (""Name"", ""CategoryId"", ""Status"", ""CreatedAt"", legacy_category_id)
SELECT t.""Name"", t.root_id, 'Active', NOW() AT TIME ZONE 'utc', t.""CategoryId""
FROM tree t
WHERE t.""ParentId"" IS NOT NULL;
");
// Repoint items: an item that pointed at a child category now carries the root
// category plus the subcategory it actually meant.
migrationBuilder.Sql(@"
UPDATE items i
SET ""SubCategoryId"" = s.""SubCategoryId"",
""CategoryId"" = s.""CategoryId""
FROM subcategories s
WHERE s.legacy_category_id = i.""CategoryId"";
");
// The self-FK must go before the delete, or RESTRICT rejects removing a parent
// whose own child row is still present.
migrationBuilder.DropForeignKey(
name: "FK_categories_categories_ParentId",
table: "categories");
// Every non-root category now lives in `subcategories`, and no item references
// one any more (repointed above), so the rows can go.
migrationBuilder.Sql(@"
DELETE FROM categories WHERE ""ParentId"" IS NOT NULL;
ALTER TABLE subcategories DROP COLUMN legacy_category_id;
");
migrationBuilder.DropIndex(
name: "IX_categories_ParentId",
table: "categories");
migrationBuilder.DropColumn(
name: "ParentId",
table: "categories");
// Seed the singleton config (FR-MD-11) — all features on. Item writes read this
// row, so it must exist before the app serves a single request.
migrationBuilder.Sql(@"
INSERT INTO product_config (""ConfigId"", ""SubcategoriesEnabled"", ""BrandsEnabled"", ""ItemTypesEnabled"")
VALUES (1, TRUE, TRUE, TRUE)
ON CONFLICT (""ConfigId"") DO NOTHING;
");
// ---------------------------------------------------------------------------
migrationBuilder.CreateIndex(
name: "IX_items_BrandId",
table: "items",
column: "BrandId");
migrationBuilder.CreateIndex(
name: "IX_items_SubCategoryId",
table: "items",
column: "SubCategoryId");
migrationBuilder.CreateIndex(
name: "IX_categories_Name",
table: "categories",
column: "Name",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_categories_Status",
table: "categories",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_brands_Name",
table: "brands",
column: "Name",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_brands_Status",
table: "brands",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_item_types_Name",
table: "item_types",
column: "Name",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_item_types_Status",
table: "item_types",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_product_config_UpdatedBy",
table: "product_config",
column: "UpdatedBy");
migrationBuilder.CreateIndex(
name: "IX_subcategories_CategoryId_Name",
table: "subcategories",
columns: new[] { "CategoryId", "Name" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_subcategories_Status",
table: "subcategories",
column: "Status");
migrationBuilder.AddForeignKey(
name: "FK_items_brands_BrandId",
table: "items",
column: "BrandId",
principalTable: "brands",
principalColumn: "BrandId",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_items_subcategories_SubCategoryId",
table: "items",
column: "SubCategoryId",
principalTable: "subcategories",
principalColumn: "SubCategoryId",
onDelete: ReferentialAction.Restrict);
}
/// <summary>
/// Reverses the schema change and puts the subcategory data back where it came from.
/// <para>
/// The scaffolded version simply dropped <c>subcategories</c>, which would have
/// discarded exactly what <see cref="Up"/> preserved. Instead each subcategory is
/// restored as a child category and its items are repointed back onto it. This is
/// not perfectly lossless: the old tree's depth is gone (a former grandchild comes
/// back as a direct child of its root), and Brand data cannot survive a schema that
/// has nowhere to put it.
/// </para>
/// </summary>
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_items_brands_BrandId",
table: "items");
migrationBuilder.DropForeignKey(
name: "FK_items_subcategories_SubCategoryId",
table: "items");
// Restore the parent column + self-FK first so subcategories have somewhere to
// land, then move them back before the table is dropped.
migrationBuilder.AddColumn<int>(
name: "ParentId",
table: "categories",
type: "integer",
nullable: true);
// ---------------------------------------------------------------------------
// DATA MIGRATION (reverse) — must run before `subcategories` is dropped.
// ---------------------------------------------------------------------------
migrationBuilder.Sql(@"
ALTER TABLE categories ADD COLUMN legacy_subcategory_id integer;
");
// Each subcategory becomes a child category again under the same parent.
migrationBuilder.Sql(@"
INSERT INTO categories (""Name"", ""ParentId"", ""CreatedAt"", ""Status"", legacy_subcategory_id)
SELECT s.""Name"", s.""CategoryId"", s.""CreatedAt"", s.""Status"", s.""SubCategoryId""
FROM subcategories s;
");
// Items that carried a subcategory point back at the restored child category.
migrationBuilder.Sql(@"
UPDATE items i
SET ""CategoryId"" = c.""CategoryId""
FROM categories c
WHERE c.legacy_subcategory_id = i.""SubCategoryId"";
");
migrationBuilder.Sql(@"
ALTER TABLE categories DROP COLUMN legacy_subcategory_id;
");
// ---------------------------------------------------------------------------
migrationBuilder.DropTable(
name: "brands");
migrationBuilder.DropTable(
name: "item_types");
migrationBuilder.DropTable(
name: "product_config");
migrationBuilder.DropTable(
name: "subcategories");
migrationBuilder.DropIndex(
name: "IX_items_BrandId",
table: "items");
migrationBuilder.DropIndex(
name: "IX_items_SubCategoryId",
table: "items");
migrationBuilder.DropIndex(
name: "IX_categories_Name",
table: "categories");
migrationBuilder.DropIndex(
name: "IX_categories_Status",
table: "categories");
migrationBuilder.DropColumn(
name: "BrandId",
table: "items");
migrationBuilder.DropColumn(
name: "SubCategoryId",
table: "items");
migrationBuilder.DropColumn(
name: "CreatedAt",
table: "categories");
migrationBuilder.DropColumn(
name: "Status",
table: "categories");
migrationBuilder.DropColumn(
name: "UpdatedAt",
table: "categories");
migrationBuilder.DropColumn(
name: "xmin",
table: "categories");
migrationBuilder.RenameColumn(
name: "StockNature",
table: "items",
newName: "ItemType");
// ParentId itself was re-added at the top of this method, ahead of the reverse
// data migration that populates it.
migrationBuilder.CreateIndex(
name: "IX_categories_ParentId",
table: "categories",
column: "ParentId");
migrationBuilder.AddForeignKey(
name: "FK_categories_categories_ParentId",
table: "categories",
column: "ParentId",
principalTable: "categories",
principalColumn: "CategoryId",
onDelete: ReferentialAction.Restrict);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,22 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Infra.Persistence.Migrations
{
/// <inheritdoc />
public partial class ini2 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,303 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace ERPCore.Infra.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddRolesNavPermissions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "RoleId",
table: "users",
type: "integer",
nullable: true);
migrationBuilder.CreateTable(
name: "nav_items",
columns: table => new
{
NavItemId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
Label = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
Icon = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
Href = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
SortOrder = table.Column<int>(type: "integer", nullable: false),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active")
},
constraints: table =>
{
table.PrimaryKey("PK_nav_items", x => x.NavItemId);
});
migrationBuilder.CreateTable(
name: "roles",
columns: table => new
{
RoleId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
auth_role_id = table.Column<Guid>(type: "uuid", nullable: false),
Code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
IsSystemRole = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_roles", x => x.RoleId);
});
migrationBuilder.CreateTable(
name: "sub_nav_items",
columns: table => new
{
SubNavItemId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
NavItemId = table.Column<int>(type: "integer", nullable: false),
Code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
Label = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
Icon = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
Href = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
SortOrder = table.Column<int>(type: "integer", nullable: false),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active")
},
constraints: table =>
{
table.PrimaryKey("PK_sub_nav_items", x => x.SubNavItemId);
table.ForeignKey(
name: "FK_sub_nav_items_nav_items_NavItemId",
column: x => x.NavItemId,
principalTable: "nav_items",
principalColumn: "NavItemId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "permissions",
columns: table => new
{
PermissionId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Code = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
NavItemId = table.Column<int>(type: "integer", nullable: true),
SubNavItemId = table.Column<int>(type: "integer", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_permissions", x => x.PermissionId);
table.ForeignKey(
name: "FK_permissions_nav_items_NavItemId",
column: x => x.NavItemId,
principalTable: "nav_items",
principalColumn: "NavItemId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_permissions_sub_nav_items_SubNavItemId",
column: x => x.SubNavItemId,
principalTable: "sub_nav_items",
principalColumn: "SubNavItemId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "role_permissions",
columns: table => new
{
RoleId = table.Column<int>(type: "integer", nullable: false),
PermissionId = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_role_permissions", x => new { x.RoleId, x.PermissionId });
table.ForeignKey(
name: "FK_role_permissions_permissions_PermissionId",
column: x => x.PermissionId,
principalTable: "permissions",
principalColumn: "PermissionId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_role_permissions_roles_RoleId",
column: x => x.RoleId,
principalTable: "roles",
principalColumn: "RoleId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.InsertData(
table: "nav_items",
columns: new[] { "NavItemId", "Code", "Href", "Icon", "Label", "SortOrder" },
values: new object[,]
{
{ 1, "dashboard", "/dashboard", null, "Dashboard", 1 },
{ 2, "products", "/dashboard/products", null, "Products", 2 },
{ 3, "vendors", "/dashboard/vendors", null, "Vendors", 3 },
{ 4, "procurement", "/dashboard/procurement", null, "Procurement", 4 },
{ 5, "receiving", "/dashboard/receiving/grn", null, "Receiving", 5 },
{ 6, "stock", "/dashboard/stock", null, "Stock", 6 },
{ 7, "warehouses", "/dashboard/warehouse", null, "Warehouses", 7 },
{ 8, "orders", "/dashboard/orders", null, "Orders", 8 },
{ 9, "settings", "/dashboard/settings", null, "Settings", 9 },
{ 10, "help", "/dashboard/help", null, "Help", 10 }
});
migrationBuilder.UpdateData(
table: "users",
keyColumn: "UserId",
keyValue: 1,
column: "RoleId",
value: null);
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[,]
{
{ 1, "NAV:dashboard", 1, null },
{ 2, "NAV:products", 2, null },
{ 3, "NAV:vendors", 3, null },
{ 4, "NAV:procurement", 4, null },
{ 5, "NAV:receiving", 5, null },
{ 6, "NAV:stock", 6, null },
{ 7, "NAV:warehouses", 7, null },
{ 8, "NAV:orders", 8, null },
{ 9, "NAV:settings", 9, null },
{ 10, "NAV:help", 10, null }
});
migrationBuilder.InsertData(
table: "sub_nav_items",
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
values: new object[,]
{
{ 1, "products.item", "/dashboard/products", null, "Item", 2, 1 },
{ 2, "products.category", "/dashboard/products/categories", null, "Category", 2, 2 },
{ 3, "products.brand", "/dashboard/products/brands", null, "Brand", 2, 3 },
{ 4, "products.item-type", "/dashboard/products/item-types", null, "Item Type", 2, 4 },
{ 5, "products.uom", "/dashboard/products/uoms", null, "UOM", 2, 5 },
{ 6, "products.configuration", "/dashboard/products/settings", null, "Configuration", 2, 6 },
{ 7, "settings.roles", "/dashboard/settings/roles", null, "Roles", 9, 1 },
{ 8, "settings.users", "/dashboard/settings/users", null, "Users", 9, 2 }
});
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[,]
{
{ 11, "NAV:products.item", null, 1 },
{ 12, "NAV:products.category", null, 2 },
{ 13, "NAV:products.brand", null, 3 },
{ 14, "NAV:products.item-type", null, 4 },
{ 15, "NAV:products.uom", null, 5 },
{ 16, "NAV:products.configuration", null, 6 },
{ 17, "NAV:settings.roles", null, 7 },
{ 18, "NAV:settings.users", null, 8 }
});
migrationBuilder.CreateIndex(
name: "IX_users_RoleId",
table: "users",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "IX_nav_items_Code",
table: "nav_items",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_permissions_Code",
table: "permissions",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_permissions_NavItemId",
table: "permissions",
column: "NavItemId");
migrationBuilder.CreateIndex(
name: "IX_permissions_SubNavItemId",
table: "permissions",
column: "SubNavItemId");
migrationBuilder.CreateIndex(
name: "IX_role_permissions_PermissionId",
table: "role_permissions",
column: "PermissionId");
migrationBuilder.CreateIndex(
name: "IX_roles_auth_role_id",
table: "roles",
column: "auth_role_id",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_roles_Code",
table: "roles",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_sub_nav_items_Code",
table: "sub_nav_items",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_sub_nav_items_NavItemId",
table: "sub_nav_items",
column: "NavItemId");
migrationBuilder.AddForeignKey(
name: "FK_users_roles_RoleId",
table: "users",
column: "RoleId",
principalTable: "roles",
principalColumn: "RoleId",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_users_roles_RoleId",
table: "users");
migrationBuilder.DropTable(
name: "role_permissions");
migrationBuilder.DropTable(
name: "permissions");
migrationBuilder.DropTable(
name: "roles");
migrationBuilder.DropTable(
name: "sub_nav_items");
migrationBuilder.DropTable(
name: "nav_items");
migrationBuilder.DropIndex(
name: "IX_users_RoleId",
table: "users");
migrationBuilder.DropColumn(
name: "RoleId",
table: "users");
}
}
}
File diff suppressed because it is too large Load Diff
+55 -4
View File
@@ -1,20 +1,24 @@
using System.Text.Json.Serialization;
using ERPCore.Infra.Auth;
using ERPCore.Infra.Auth.AuthHex;
using ERPCore.Infra.Gl;
using ERPCore.Infra.Persistence;
using ERPCore.Infra.Storage;
using ERPCore.Infra.UoW;
using ERPCore.Repositories;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services;
using ERPCore.Services.Interfaces;
using ERPCore.Services.Auth;
using ERPCore.Services.Hrm;
using ERPCore.Services.Interfaces;
using ERPCore.Services.Production;
using ERPCore.Services.Stock;
using ERPCore.System.Errors;
using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.OpenApi;
using Npgsql;
using Serilog;
var builder = WebApplication.CreateBuilder(args);
@@ -30,7 +34,10 @@ builder.Services.AddControllers()
// EF Core + PostgreSQL
builder.Services.AddDbContext<ErpDbContext>(o =>
o.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
{
o.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"));
o.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning));
});
// ProblemDetails (RFC 7807) + domain-exception mapping
builder.Services.AddProblemDetails();
@@ -50,6 +57,15 @@ builder.Services.AddScoped<IAuthUserService, AuthUserService>();
builder.Services.AddScoped<IAuthRecoveryService, AuthRecoveryService>();
builder.Services.AddScoped<IAuthAltService, AuthAltService>();
// General Ledger service proxy → external GL microservice (docs/12-GENERAL-LEDGER-INTEGRATION.md)
builder.Services.AddHttpClient<IGeneralLedgerClient, GeneralLedgerClient>(c =>
{
var baseUrl = builder.Configuration["GeneralLedgerService:BaseUrl"]
?? throw new InvalidOperationException("GeneralLedgerService:BaseUrl is not configured.");
c.BaseAddress = new Uri(baseUrl);
});
builder.Services.AddScoped<IGeneralLedgerService, GeneralLedgerService>();
// Current-user (audit actor). AuthHex has no sub/nameid → a claims transformation
// JIT-provisions a local shadow user and injects the local `int` id as `nameid`.
builder.Services.AddHttpContextAccessor();
@@ -61,11 +77,13 @@ builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
// Master-data services (docs/11 §2)
builder.Services.AddScoped<ICustomerService, CustomerService>();
builder.Services.AddScoped<IItemService, ItemService>();
builder.Services.AddScoped<IUomService, UomService>();
builder.Services.AddScoped<ICategoryService, CategoryService>();
builder.Services.AddScoped<IBrandService, BrandService>();
builder.Services.AddScoped<IItemTypeService, ItemTypeService>();
//builder.Services.AddScoped<ICompanyProfileService, CompanyProfileService>();
builder.Services.AddScoped<IProductConfigService, ProductConfigService>();
builder.Services.AddScoped<IVendorService, VendorService>();
builder.Services.AddScoped<IWarehouseService, WarehouseService>();
@@ -81,10 +99,23 @@ builder.Services.AddScoped<IRfqService, RfqService>();
builder.Services.AddScoped<IPurchaseOrderService, PurchaseOrderService>();
// Stock core + goods receipt (docs/11 §45)
builder.Services.AddScoped<IUomConverter, UomConverter>();
builder.Services.AddScoped<IFifoCostingService, FifoCostingService>();
builder.Services.AddScoped<IStockService, StockService>();
builder.Services.AddScoped<IGrnService, GrnService>();
// Sales (Phase 1)
builder.Services.AddScoped<ISalesPricingService, SalesPricingService>();
builder.Services.AddScoped<ISalesDomainService, SalesDomainService>();
builder.Services.AddScoped<ISalesPostingService, SalesPostingService>();
builder.Services.AddScoped<ISalesDocumentWorkflowService, SalesDocumentWorkflowService>();
builder.Services.AddScoped<ISalesMappingService, SalesMappingService>();
builder.Services.AddScoped<ISalesInvoiceService, SalesInvoiceService>();
builder.Services.AddScoped<ISalesSlipService, SalesSlipService>();
builder.Services.AddScoped<IBundleSaleService, BundleSaleService>();
builder.Services.AddScoped<ISalesPromotionSuggestionService, SalesPromotionSuggestionService>();
builder.Services.AddScoped<ISalesReportService, SalesReportService>();
// Stock transactions + reference data (docs/11 §56)
builder.Services.AddScoped<IStockMutator, StockMutator>();
builder.Services.AddScoped<IReasonCodeService, ReasonCodeService>();
@@ -136,12 +167,22 @@ builder.Services.AddScoped<IPayslipService, PayslipService>();
// HRM: Reports (docs/13-BACKEND-HRM-API.md §6) — read-only, no new entities
builder.Services.AddScoped<IHrReportService, HrReportService>();
// Manufacturing / Production Lines (docs/30-BACKEND-PHASE2.md Part A). Templates stand
// alone; runs consume FifoCostingService for all stock movement. ProductionGraphValidator
// is deliberately unregistered — it is a pure static algorithm, not an injected service.
builder.Services.AddScoped<IProductionTemplateService, ProductionTemplateService>();
builder.Services.AddScoped<IProductionRunService, ProductionRunService>();
// Health checks (EF Core DB)
builder.Services.AddHealthChecks().AddDbContextCheck<ErpDbContext>();
// Swagger / OpenAPI (Swashbuckle v10 → OpenAPI 3.1)
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(o => o.SwaggerDoc("v1", new OpenApiInfo { Title = "ERPCore API", Version = "v1" }));
builder.Services.AddSwaggerGen(o =>
{
o.SwaggerDoc("v1", new OpenApiInfo { Title = "ERPCore API", Version = "v1" });
o.CustomSchemaIds(t => t.FullName!.Replace("+", "."));
});
var app = builder.Build();
@@ -149,7 +190,16 @@ var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ErpDbContext>();
await DataSeeder.SeedAsync(db);
// await EnsureMigrationBaselineAsync(db);
await db.Database.MigrateAsync();
try
{
await DataSeeder.SeedAsync(db);
}
catch (Exception ex)
{
throw new InvalidOperationException("Database migration succeeded, but startup seeding failed.", ex);
}
}
app.UseSerilogRequestLogging();
@@ -166,3 +216,4 @@ app.UseAuthorization();
app.MapControllers();
app.MapHealthChecks("/health");
app.Run();
@@ -0,0 +1,263 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.Services.Stock;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
public sealed class BundleSaleService : IBundleSaleService
{
private readonly IRepository<BundleSaleTemplate> _templates;
private readonly IRepository<BundleSale> _bundles;
private readonly IRepository<Customer> _customers;
private readonly IRepository<Item> _items;
private readonly IRepository<Uom> _uoms;
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<User> _users;
private readonly ISalesDomainService _sales;
private readonly IUomConverter _uomConverter;
private readonly ISalesPostingService _posting;
private readonly ICurrentUser _currentUser;
private readonly INumberSequenceService _numbers;
private readonly IUnitOfWork _uow;
public BundleSaleService(
IRepository<BundleSale> bundles,
IRepository<BundleSaleTemplate> templates,
IRepository<Customer> customers,
IRepository<Item> items,
IRepository<Uom> uoms,
IRepository<Warehouse> warehouses,
IRepository<User> users,
ISalesDomainService sales,
IUomConverter uomConverter,
ISalesPostingService posting,
ICurrentUser currentUser,
INumberSequenceService numbers,
IUnitOfWork uow)
{
_templates = templates;
_bundles = bundles;
_customers = customers;
_items = items;
_uoms = uoms;
_warehouses = warehouses;
_users = users;
_sales = sales;
_uomConverter = uomConverter;
_posting = posting;
_currentUser = currentUser;
_numbers = numbers;
_uow = uow;
}
public async Task<PagedResponse<BundleSaleTemplateSummaryDto>> ListTemplatesAsync(PageQuery query, CancellationToken ct = default)
{
IQueryable<BundleSaleTemplate> q = _templates.Query().AsNoTracking().Include(x => x.Lines);
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(x => EF.Functions.ILike(x.TemplateCode, $"%{term}%") || EF.Functions.ILike(x.TemplateName, $"%{term}%") || EF.Functions.ILike(x.Description ?? "", $"%{term}%"));
}
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(x => x.BundleSaleTemplateId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
return PagedResponse<BundleSaleTemplateSummaryDto>.Create(rows.Select(x => new BundleSaleTemplateSummaryDto(
x.BundleSaleTemplateId, x.TemplateCode, x.TemplateName, x.Description, x.Status, x.Lines.Count, x.CreatedAt, x.UpdatedAt)).ToList(), query.Page, query.PageSize, total);
}
public async Task<BundleSaleTemplateDto?> GetTemplateAsync(int bundleSaleTemplateId, CancellationToken ct = default)
{
var template = await _templates.Query().AsNoTracking().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleTemplateId == bundleSaleTemplateId, ct);
return template is null ? null : new BundleSaleTemplateDto(
template.BundleSaleTemplateId,
template.TemplateCode,
template.TemplateName,
template.Description,
template.Status,
template.CreatedAt,
template.UpdatedAt,
template.Lines.OrderBy(x => x.SortOrder).Select(x => new BundleSaleTemplateLineDto(
x.BundleSaleTemplateLineId, x.ItemId, x.UomId, x.WarehouseId, x.Qty, x.UnitPrice, x.IncludeInBundle, x.SortOrder)).ToList());
}
public async Task<PagedResponse<BundleSaleSummaryDto>> ListAsync(PageQuery query, BundleSaleStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default)
{
IQueryable<BundleSale> q = _bundles.Query().AsNoTracking().Include(x => x.Lines);
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(x => EF.Functions.ILike(x.BundleNo, $"%{term}%") || EF.Functions.ILike(x.BundleName, $"%{term}%") || EF.Functions.ILike(x.BundleCode, $"%{term}%"));
}
if (status is not null) q = q.Where(x => x.Status == status);
if (customerId is not null) q = q.Where(x => x.CustomerId == customerId);
if (warehouseId is not null) q = q.Where(x => x.WarehouseId == warehouseId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(x => x.BundleSaleId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
return PagedResponse<BundleSaleSummaryDto>.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total);
}
public async Task<BundleSaleDto?> GetAsync(int bundleSaleId, CancellationToken ct = default)
{
var bundle = await _bundles.Query().AsNoTracking().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct);
return bundle is null ? null : Map(bundle);
}
public Task<BundleSalePostingCheckDto> CheckPostingAsync(int bundleSaleId, CancellationToken ct = default)
=> _posting.CheckBundleAsync(bundleSaleId, ct);
public async Task<BundleSaleDto> CreateAsync(CreateBundleSaleRequest request, CancellationToken ct = default)
{
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, true, ct);
var template = await _templates.Query().AsNoTracking().Include(x => x.Lines)
.FirstAsync(x => x.BundleSaleTemplateId == request.BundleSaleTemplateId, ct);
var bundle = new BundleSale
{
BundleNo = await _numbers.NextAsync(DocumentTypes.BundleSale, ct),
BundleDate = DateTime.UtcNow,
CustomerId = request.CustomerId,
WarehouseId = request.WarehouseId,
CashierUserId = request.CashierUserId,
BundleSaleTemplateId = request.BundleSaleTemplateId,
BundleName = request.BundleName,
BundleCode = string.Empty,
Status = BundleSaleStatus.Draft,
CreatedAt = DateTime.UtcNow
};
bundle.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
bundle.Lines = await BuildLinesAsync(template, request.WarehouseId, request.Lines, ct);
Recalculate(bundle, request.BundlePrice);
bundle.BundleCode = $"{bundle.BundleNo}-B";
await _bundles.AddAsync(bundle, ct);
bundle.ConcurrencyStamp = 1;
await _uow.SaveChangesAsync(ct);
return Map(bundle);
}
public async Task<BundleSaleDto> UpdateAsync(int bundleSaleId, UpdateBundleSaleRequest request, CancellationToken ct = default)
{
var bundle = await _bundles.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct)
?? throw new NotFoundException($"Bundle sale {bundleSaleId} was not found.");
if (bundle.Status != BundleSaleStatus.Draft)
throw new ConflictException($"Bundle sale {bundleSaleId} is {bundle.Status} and cannot be edited.");
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, true, ct);
var template = await _templates.Query().AsNoTracking().Include(x => x.Lines)
.FirstAsync(x => x.BundleSaleTemplateId == request.BundleSaleTemplateId, ct);
bundle.CustomerId = request.CustomerId;
bundle.WarehouseId = request.WarehouseId;
bundle.CashierUserId = request.CashierUserId;
bundle.BundleSaleTemplateId = request.BundleSaleTemplateId;
bundle.BundleName = request.BundleName;
bundle.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
bundle.Lines.Clear();
foreach (var line in await BuildLinesAsync(template, request.WarehouseId, request.Lines, ct)) bundle.Lines.Add(line);
Recalculate(bundle, request.BundlePrice);
bundle.UpdatedAt = DateTime.UtcNow;
bundle.ConcurrencyStamp++;
await _uow.SaveChangesAsync(ct);
return Map(bundle);
}
public async Task<BundleSaleDto> PostAsync(int bundleSaleId, CancellationToken ct = default)
{
await _posting.PostBundleAsync(bundleSaleId, ct);
var bundle = await _bundles.Query().AsNoTracking().Include(x => x.Lines)
.FirstAsync(x => x.BundleSaleId == bundleSaleId, ct);
return Map(bundle);
}
public async Task<BundleSaleDto> CancelAsync(int bundleSaleId, CancellationToken ct = default)
{
var bundle = await _bundles.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct)
?? throw new NotFoundException($"Bundle sale {bundleSaleId} was not found.");
if (bundle.Status != BundleSaleStatus.Draft)
throw new ConflictException($"Bundle sale {bundleSaleId} is {bundle.Status} and cannot be cancelled.");
bundle.Status = BundleSaleStatus.Cancelled;
bundle.UpdatedAt = DateTime.UtcNow;
bundle.ConcurrencyStamp++;
await _uow.SaveChangesAsync(ct);
return Map(bundle);
}
private async Task<List<BundleSaleLine>> BuildLinesAsync(
BundleSaleTemplate template, int warehouseId, IReadOnlyList<CreateBundleSaleTemplateLineRequest> requestLines, CancellationToken ct)
{
var lines = new List<BundleSaleLine>();
var sourceLines = requestLines.Count > 0
? requestLines.OrderBy(x => x.SortOrder).ToList()
: template.Lines.OrderBy(x => x.SortOrder).Select(x => new CreateBundleSaleTemplateLineRequest
{
ItemId = x.ItemId,
UomId = x.UomId,
WarehouseId = x.WarehouseId,
Qty = x.Qty,
UnitPrice = x.UnitPrice,
IncludeInBundle = x.IncludeInBundle,
SortOrder = x.SortOrder
}).ToList();
foreach (var r in sourceLines)
{
if (r.Qty <= 0)
throw new DomainException(ErrorCodes.Validation, "Bundle line quantity must be greater than zero.", 422);
// Bundle sales use the header warehouse as the source of truth for stock and pricing.
// Keep any per-line warehouse input from drifting away from the header.
var lineWarehouseId = warehouseId;
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
await _sales.ValidateSalesLineAsync(warehouseId, r.ItemId, r.UomId, lineWarehouseId, r.Qty, 0m, null, ct);
// Keep the entered UOM on the line and snapshot the base quantity beside it, the
// same shape as invoices and slips. Qty and UnitPrice stay a matching pair in the
// entered UOM so LineTotal — which Recalculate rolls into the header — is the
// value the user priced; only QtyBase crosses into the base-UOM stock engine.
var factor = await _uomConverter.ResolveFactorAsync(item, r.UomId, ct);
var calc = _sales.ComputeLine(r.Qty, 0m, r.UnitPrice, SalesDiscountMode.Amount, 0m, 0m, 0m, 0m, false);
lines.Add(new BundleSaleLine
{
ItemId = r.ItemId,
Description = item.Name,
Qty = r.Qty,
UomId = r.UomId,
QtyBase = UomConverter.ApplyFactor(r.Qty, factor),
ConversionFactor = factor,
WarehouseId = lineWarehouseId,
UnitPrice = r.UnitPrice,
LineTotal = calc.LineTotal,
IncludeInBundle = r.IncludeInBundle,
IsComponent = true,
ParentLineId = null
});
}
return lines;
}
private static void Recalculate(BundleSale bundle, decimal bundlePrice)
{
bundle.ComponentSubtotal = bundle.Lines.Where(x => x.IncludeInBundle).Sum(x => x.LineTotal);
bundle.BundlePrice = bundlePrice;
bundle.MarginAmount = bundle.BundlePrice - bundle.ComponentSubtotal;
bundle.DiscountTotal = Math.Max(0m, bundle.ComponentSubtotal - bundle.BundlePrice);
bundle.TaxTotal = 0m;
bundle.GrandTotal = bundle.BundlePrice + bundle.TaxTotal;
}
private static BundleSaleSummaryDto MapSummary(BundleSale x) => new(
x.BundleSaleId, x.BundleNo, x.BundleDate, x.CustomerId, x.CustomerSnapshotName, x.WarehouseId, x.BundleName, x.BundleCode, x.Status, x.ComponentSubtotal, x.BundlePrice, x.GrandTotal, x.CreatedAt);
private static BundleSaleDto Map(BundleSale x) => new(
x.BundleSaleId, x.BundleNo, x.BundleDate, x.CustomerId, x.CustomerSnapshotName, x.WarehouseId, x.CashierUserId, x.BundleSaleTemplateId,
x.BundleName, x.BundleCode, x.Status, x.ComponentSubtotal, x.BundlePrice, x.MarginAmount, x.DiscountTotal, x.TaxTotal, x.GrandTotal,
x.CreatedAt, x.UpdatedAt,
x.Lines.Select(l => new BundleSaleLineDto(l.BundleSaleLineId, l.ItemId, l.Description, l.Qty, l.UomId, l.WarehouseId, l.UnitPrice, l.LineTotal, l.IncludeInBundle, l.IsComponent, l.ParentLineId)).ToList());
}
+168
View File
@@ -0,0 +1,168 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Customers;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
public sealed class CustomerService : ICustomerService
{
private readonly IRepository<Customer> _customers;
private readonly IRepository<Warehouse> _warehouses;
private readonly IUnitOfWork _uow;
public CustomerService(IRepository<Customer> customers, IRepository<Warehouse> warehouses, IUnitOfWork uow)
{
_customers = customers;
_warehouses = warehouses;
_uow = uow;
}
public async Task<PagedResponse<CustomerDto>> ListAsync(PageQuery query, EntityStatus? status, CustomerType? customerType, CancellationToken ct = default)
{
var q = _customers.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(c => EF.Functions.ILike(c.Name, $"%{term}%")
|| EF.Functions.ILike(c.CustomerCode, $"%{term}%")
|| (c.DisplayName != null && EF.Functions.ILike(c.DisplayName, $"%{term}%")));
}
if (status is not null) q = q.Where(c => c.Status == status);
if (customerType is not null) q = q.Where(c => c.CustomerType == customerType);
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(c => c.Name)
.Skip(query.Skip).Take(query.PageSize)
.Select(c => new CustomerDto(
c.CustomerId, c.CustomerCode, c.CustomerType, c.Name, c.DisplayName, c.Phone, c.Email,
c.AddressLine1, c.AddressLine2, c.City, c.Country, c.TaxRegistrationNo,
c.CreditLimit, c.CreditDays, c.DefaultWarehouseId, c.Status, c.CreatedAt, c.UpdatedAt))
.ToListAsync(ct);
return PagedResponse<CustomerDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<ETagged<CustomerDto>?> GetAsync(int customerId, CancellationToken ct = default)
{
var customer = await _customers.Query().AsNoTracking()
.FirstOrDefaultAsync(c => c.CustomerId == customerId, ct);
return customer is null ? null : new ETagged<CustomerDto>(Map(customer), customer.RowVersion);
}
public async Task<ETagged<CustomerDto>> CreateAsync(CreateCustomerRequest request, CancellationToken ct = default)
{
var code = request.CustomerCode.Trim();
var name = request.Name.Trim();
if (await _customers.Query().AnyAsync(c => c.CustomerCode.ToLower() == code.ToLower(), ct))
throw new ConflictException($"A customer code '{code}' already exists.");
if (!string.IsNullOrWhiteSpace(request.Email))
{
var email = request.Email.Trim();
if (await _customers.Query().AnyAsync(c => c.Email != null && c.Email.ToLower() == email.ToLower(), ct))
throw new ConflictException($"A customer with email '{email}' already exists.");
}
if (request.DefaultWarehouseId is not null)
{
var exists = await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.DefaultWarehouseId, ct);
if (!exists) throw new NotFoundException($"Warehouse {request.DefaultWarehouseId} was not found.");
}
var customer = new Customer
{
CustomerCode = code,
CustomerType = request.CustomerType,
Name = name,
DisplayName = Normalize(request.DisplayName),
Phone = Normalize(request.Phone),
Email = Normalize(request.Email),
AddressLine1 = Normalize(request.AddressLine1),
AddressLine2 = Normalize(request.AddressLine2),
City = Normalize(request.City),
Country = Normalize(request.Country),
TaxRegistrationNo = Normalize(request.TaxRegistrationNo),
CreditLimit = request.CreditLimit,
CreditDays = request.CreditDays,
DefaultWarehouseId = request.DefaultWarehouseId,
Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow
};
await _customers.AddAsync(customer, ct);
await _uow.SaveChangesAsync(ct);
return new ETagged<CustomerDto>(Map(customer), customer.RowVersion);
}
public async Task<ETagged<CustomerDto>> UpdateAsync(int customerId, UpdateCustomerRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var customer = await _customers.GetByIdAsync(customerId, ct)
?? throw new NotFoundException($"Customer {customerId} was not found.");
if (customer.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The customer was modified by another request.", 412);
var code = request.CustomerCode.Trim();
var name = request.Name.Trim();
if (!string.Equals(customer.CustomerCode, code, StringComparison.Ordinal)
&& await _customers.Query().AnyAsync(c => c.CustomerCode.ToLower() == code.ToLower() && c.CustomerId != customerId, ct))
throw new ConflictException($"A customer code '{code}' already exists.");
if (!string.Equals(customer.Email, request.Email?.Trim(), StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrWhiteSpace(request.Email)
&& await _customers.Query().AnyAsync(c => c.Email != null && c.Email.ToLower() == request.Email!.Trim().ToLower() && c.CustomerId != customerId, ct))
throw new ConflictException($"A customer with email '{request.Email.Trim()}' already exists.");
if (request.DefaultWarehouseId is not null)
{
var exists = await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.DefaultWarehouseId, ct);
if (!exists) throw new NotFoundException($"Warehouse {request.DefaultWarehouseId} was not found.");
}
customer.CustomerCode = code;
customer.CustomerType = request.CustomerType;
customer.Name = name;
customer.DisplayName = Normalize(request.DisplayName);
customer.Phone = Normalize(request.Phone);
customer.Email = Normalize(request.Email);
customer.AddressLine1 = Normalize(request.AddressLine1);
customer.AddressLine2 = Normalize(request.AddressLine2);
customer.City = Normalize(request.City);
customer.Country = Normalize(request.Country);
customer.TaxRegistrationNo = Normalize(request.TaxRegistrationNo);
customer.CreditLimit = request.CreditLimit;
customer.CreditDays = request.CreditDays;
customer.DefaultWarehouseId = request.DefaultWarehouseId;
customer.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
return new ETagged<CustomerDto>(Map(customer), customer.RowVersion);
}
public async Task SetStatusAsync(int customerId, EntityStatus status, CancellationToken ct = default)
{
var customer = await _customers.GetByIdAsync(customerId, ct)
?? throw new NotFoundException($"Customer {customerId} was not found.");
customer.Status = status;
customer.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
}
private static CustomerDto Map(Customer c) => new(
c.CustomerId, c.CustomerCode, c.CustomerType, c.Name, c.DisplayName, c.Phone, c.Email,
c.AddressLine1, c.AddressLine2, c.City, c.Country, c.TaxRegistrationNo,
c.CreditLimit, c.CreditDays, c.DefaultWarehouseId, c.Status, c.CreatedAt, c.UpdatedAt);
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
@@ -0,0 +1,16 @@
using ERPCore.Infra.Gl;
using ERPCore.Services.Interfaces;
namespace ERPCore.Services;
/// <inheritdoc cref="IGeneralLedgerService"/>
public sealed class GeneralLedgerService : IGeneralLedgerService
{
private readonly IGeneralLedgerClient _client;
public GeneralLedgerService(IGeneralLedgerClient client) => _client = client;
public Task<GeneralLedgerResponse> ForwardAsync(
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct)
=> _client.SendAsync(method, path, queryString, contentType, body, ct);
}
+33 -25
View File
@@ -7,6 +7,7 @@ using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.Services.Stock;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
@@ -33,9 +34,9 @@ public sealed class GrnService : IGrnService
private readonly IRepository<Bin> _bins;
private readonly IRepository<Vendor> _vendors;
private readonly IRepository<Batch> _batches;
private readonly IRepository<UomConversion> _conversions;
private readonly IRepository<StockLayer> _layers;
private readonly IRepository<StockLedger> _ledger;
private readonly IUomConverter _uomConverter;
private readonly IFifoCostingService _fifo;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
@@ -45,7 +46,7 @@ public sealed class GrnService : IGrnService
IRepository<Grn> grns, IRepository<PurchaseOrder> pos, IRepository<PoLine> poLines,
IRepository<Item> items, IRepository<Uom> uoms, IRepository<Warehouse> warehouses,
IRepository<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches,
IRepository<UomConversion> conversions, IRepository<StockLayer> layers, IRepository<StockLedger> ledger,
IRepository<StockLayer> layers, IRepository<StockLedger> ledger, IUomConverter uomConverter,
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
{
_grns = grns;
@@ -57,9 +58,9 @@ public sealed class GrnService : IGrnService
_bins = bins;
_vendors = vendors;
_batches = batches;
_conversions = conversions;
_layers = layers;
_ledger = ledger;
_uomConverter = uomConverter;
_fifo = fifo;
_numbers = numbers;
_currentUser = currentUser;
@@ -138,6 +139,12 @@ public sealed class GrnService : IGrnService
if (input.BinId is not null && !await _bins.Query().AnyAsync(b => b.BinId == input.BinId && b.WarehouseId == request.WarehouseId, ct))
throw new DomainException(ErrorCodes.Validation, $"Bin {input.BinId} is not in warehouse {request.WarehouseId}.", 422);
// Resolve the base quantity at entry. This rejects a UOM the item cannot be
// received in while the GRN is still a draft, and snapshots the factor so confirm
// (and any later reversal) reproduces exactly this quantity.
var conversionFactor = await _uomConverter.ResolveFactorAsync(item, input.UomId, ct);
var qtyBaseEntered = UomConverter.ApplyFactor(input.Qty, conversionFactor);
// Cost: for a PO line, the PO price is used unless an override is entered (then it
// wins and a variance is recorded against the PO snapshot — docs/02-SECURITY C.3,
// revised). Direct receipts always use the entered cost.
@@ -150,10 +157,13 @@ public sealed class GrnService : IGrnService
if (poLine.ItemId != input.ItemId)
throw new DomainException(ErrorCodes.Validation, $"PO line {input.PoLineId} is for a different item.", 422);
var openQty = poLine.Qty - poLine.QtyReceived;
if (input.Qty > openQty * (1 + OverReceiptTolerance))
// Compare in base UOM: a GRN may legitimately receive in a different UOM from
// the one the PO was raised in (10 BOX ordered, 120 PCS delivered), and
// comparing the two raw numbers would reject that valid receipt.
var openQtyBase = poLine.QtyBase - poLine.QtyReceivedBase;
if (qtyBaseEntered > openQtyBase * (1 + OverReceiptTolerance))
throw new DomainException(ErrorCodes.OverReceiptTolerance,
$"Receiving {input.Qty} exceeds the open quantity {openQty} on PO line {input.PoLineId}.", 422);
$"Receiving {input.Qty} ({qtyBaseEntered} base) exceeds the open quantity {openQtyBase} base on PO line {input.PoLineId}.", 422);
poUnitPrice = poLine.UnitPrice;
unitCost = input.UnitCost > 0 ? input.UnitCost : poLine.UnitPrice;
@@ -178,6 +188,8 @@ public sealed class GrnService : IGrnService
BinId = input.BinId,
Batch = batch, // navigation so EF fixes up BatchId once the batch is inserted
Qty = input.Qty,
QtyBase = qtyBaseEntered,
ConversionFactor = conversionFactor,
UnitCost = unitCost,
PoUnitPrice = poUnitPrice,
DiscountPct = input.DiscountPct,
@@ -234,10 +246,13 @@ public sealed class GrnService : IGrnService
{
foreach (var line in grn.Lines.OrderBy(l => l.GrnLineId))
{
var item = await _items.Query().AsNoTracking().FirstAsync(i => i.ItemId == line.ItemId, token);
// FIFO layer costs at the after-discount net price; VAT is recoverable and never
// enters stock value (docs/10 FR-GRN-06, revised).
var (qtyBase, unitCostBase) = await ToBaseAsync(item, line.UomId, line.Qty, line.NetUnitCost, token);
// enters stock value (docs/10 FR-GRN-06, revised). Quantity and cost come from
// the factor snapshotted at line creation, not a fresh lookup — see GrnLine.QtyBase.
var qtyBase = line.QtyBase;
var unitCostBase = line.ConversionFactor == 1m
? line.NetUnitCost
: Math.Round(line.NetUnitCost / line.ConversionFactor, 6, MidpointRounding.AwayFromZero);
var layer = await _fifo.CreateInboundLayerAsync(
line.ItemId, grn.WarehouseId, line.BatchId, null, line.GrnLineId,
@@ -258,7 +273,13 @@ public sealed class GrnService : IGrnService
if (line.PoLineId is not null)
{
var poLine = await _poLines.GetByIdAsync(line.PoLineId.Value, token);
if (poLine is not null) poLine.QtyReceived += line.Qty;
if (poLine is not null)
{
// Accrue in base so receipts in a UOM other than the PO's still add up.
// QtyReceived is kept in the PO's own UOM for display only.
poLine.QtyReceivedBase += qtyBase;
poLine.QtyReceived = UomConverter.FromBase(poLine.QtyReceivedBase, poLine.ConversionFactor);
}
}
}
@@ -345,27 +366,14 @@ public sealed class GrnService : IGrnService
return created; // linked via GrnLine.Batch navigation; FK fixed up on SaveChanges
}
private async Task<(decimal QtyBase, decimal UnitCostBase)> ToBaseAsync(
Item item, int uomId, decimal qty, decimal unitCostPerUom, CancellationToken ct)
{
if (uomId == item.BaseUomId)
return (qty, unitCostPerUom);
var conv = await _conversions.Query().AsNoTracking()
.FirstOrDefaultAsync(c => c.ItemId == item.ItemId && c.FromUomId == uomId && c.ToUomId == item.BaseUomId, ct)
?? throw new DomainException(ErrorCodes.Validation,
$"No UOM conversion from {uomId} to base UOM {item.BaseUomId} for item {item.ItemId}.", 422);
return (qty * conv.Factor, unitCostPerUom / conv.Factor);
}
private async Task UpdatePoStatusAsync(int? poId, CancellationToken ct)
{
if (poId is null) return;
var po = await _pos.Query().Include(p => p.Lines).FirstOrDefaultAsync(p => p.PoId == poId, ct);
if (po is null) return;
po.Status = po.Lines.All(l => l.QtyReceived >= l.Qty)
// Base-vs-base: QtyReceived is a denormalized display figure and must not gate closing.
po.Status = po.Lines.All(l => l.QtyReceivedBase >= l.QtyBase)
? PurchaseOrderStatus.FullyReceived
: PurchaseOrderStatus.PartiallyReceived;
po.UpdatedAt = DateTime.UtcNow;
@@ -0,0 +1,19 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
namespace ERPCore.Services.Interfaces;
public interface IBundleSaleService
{
Task<PagedResponse<BundleSaleTemplateSummaryDto>> ListTemplatesAsync(PageQuery query, CancellationToken ct = default);
Task<BundleSaleTemplateDto?> GetTemplateAsync(int bundleSaleTemplateId, CancellationToken ct = default);
Task<PagedResponse<BundleSaleSummaryDto>> ListAsync(PageQuery query, BundleSaleStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default);
Task<BundleSaleDto?> GetAsync(int bundleSaleId, CancellationToken ct = default);
Task<BundleSalePostingCheckDto> CheckPostingAsync(int bundleSaleId, CancellationToken ct = default);
Task<BundleSaleDto> CreateAsync(CreateBundleSaleRequest request, CancellationToken ct = default);
Task<BundleSaleDto> UpdateAsync(int bundleSaleId, UpdateBundleSaleRequest request, CancellationToken ct = default);
Task<BundleSaleDto> PostAsync(int bundleSaleId, CancellationToken ct = default);
Task<BundleSaleDto> CancelAsync(int bundleSaleId, CancellationToken ct = default);
}
@@ -0,0 +1,15 @@
using ERPCore.Common.Http;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Customers;
using ERPCore.Domain.Enums;
namespace ERPCore.Services.Interfaces;
public interface ICustomerService
{
Task<PagedResponse<CustomerDto>> ListAsync(PageQuery query, EntityStatus? status, CustomerType? customerType, CancellationToken ct = default);
Task<ETagged<CustomerDto>?> GetAsync(int customerId, CancellationToken ct = default);
Task<ETagged<CustomerDto>> CreateAsync(CreateCustomerRequest request, CancellationToken ct = default);
Task<ETagged<CustomerDto>> UpdateAsync(int customerId, UpdateCustomerRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task SetStatusAsync(int customerId, EntityStatus status, CancellationToken ct = default);
}
@@ -29,11 +29,22 @@ public interface IFifoCostingService
Task<IReadOnlyList<ConsumedSegment>> ConsumeAsync(
int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default);
/// <summary>Append an immutable ledger entry (value = qtyBase × unitCost).</summary>
/// <summary>
/// Append an immutable ledger entry. Value defaults to <c>round(qtyBase × unitCost, 4)</c>.
/// </summary>
/// <param name="valueOverride">
/// Posts this exact value instead of deriving it from qty × unit cost. Needed when the
/// authoritative figure is a total rather than a rate: a production receipt must carry the
/// run's cost pool exactly, but <c>unitCost = pool / goodQty</c> rounds to 6 dp, and at
/// 100+ units that rounding error exceeds the ledger's 4 dp tick — so the derived value
/// would drift from the pool (FR-MFG-13). Also used by leftover and cancel returns, whose
/// value is the exact consumed residual. Omit for every rate-driven movement.
/// </param>
Task<StockLedger> PostLedgerAsync(
int itemId, int warehouseId, int? binId, int? batchId, int? serialId, int userId,
Direction direction, decimal qtyBase, decimal unitCost, decimal runningBalance,
string sourceDocType, int sourceDocId, DateTime createdAt, CancellationToken ct = default);
string sourceDocType, int sourceDocId, DateTime createdAt, CancellationToken ct = default,
decimal? valueOverride = null);
/// <summary>Current on-hand (Σ open-layer qtyRemaining) for an item at a warehouse.</summary>
Task<decimal> GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default);
@@ -0,0 +1,16 @@
using ERPCore.Infra.Gl;
namespace ERPCore.Services.Interfaces;
/// <summary>
/// Single entry point into the external General Ledger service — the one function
/// used both by <see cref="ERPCore.Controllers.GeneralLedgerController"/> (frontend
/// requests, forwarded verbatim) and, once wired, other ERPCore services that need to
/// post directly to GL (e.g. GRN confirm, adjustments — docs/12-GENERAL-LEDGER-INTEGRATION.md).
/// No business logic lives here yet; this pass only connects the transport.
/// </summary>
public interface IGeneralLedgerService
{
Task<GeneralLedgerResponse> ForwardAsync(
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct);
}
@@ -0,0 +1,99 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Production;
namespace ERPCore.Services.Interfaces;
/// <summary>
/// Production run lifecycle (docs/30 §D.2D.3, FR-MFG-08..19). Every stock-affecting
/// action runs inside a single <c>ExecuteInTransactionAsync</c> scope and consumes stock
/// only through <c>IFifoCostingService</c> (NFR-02/NFR-05).
/// </summary>
public interface IProductionRunService
{
Task<PagedResponse<RunSummaryDto>> ListAsync(
PageQuery query, ProductionRunStatus? status, int? templateId, int? warehouseId,
CancellationToken ct = default);
Task<ETagged<RunGraphDto>?> GetAsync(int runId, CancellationToken ct = default);
/// <summary>
/// Instantiates a template (FR-MFG-08): copies every stage, input, output and edge,
/// scales all quantities by <c>targetQty / terminalOutputQtyPerBatch</c>, issues a
/// <c>PRD-…</c> document number, and leaves entry stages Ready with the rest Waiting.
/// </summary>
Task<ETagged<RunGraphDto>> CreateAsync(CreateRunRequest request, CancellationToken ct = default);
/// <summary>
/// Per-run quantity override on a stage that has not started
/// (<c>409 STAGE_NOT_EDITABLE</c> once it has). Re-evaluates the stage's readiness,
/// since raising an upstream input's planned quantity can un-ready it.
/// </summary>
Task<RunStageDto> UpdateStageQuantitiesAsync(
int runId, int runStageId, UpdateStageQuantitiesRequest request, CancellationToken ct = default);
/// <summary>
/// <c>Ready → InProgress</c> (FR-MFG-10). FIFO-consumes every Stock input from the run
/// warehouse in one transaction and stamps <c>actualStartAt</c>.
/// <para>Consumes <c>max(0, plannedBase consumedQty)</c> per input, so a rework
/// restart with an unchanged planned quantity consumes nothing and one with a raised
/// planned quantity consumes only the delta (FR-MFG-16).</para>
/// </summary>
Task<StartStageResultDto> StartStageAsync(int runId, int runStageId, CancellationToken ct = default);
/// <summary>
/// <c>InProgress → Done</c> (FR-MFG-11). Records produced and scrapped quantities per
/// output plus the custom field values. A re-complete after a rework <b>overwrites</b>
/// the previous figures rather than adding to them.
/// </summary>
Task<RunStageDto> CompleteStageAsync(
int runId, int runStageId, CompleteStageRequest request, CancellationToken ct = default);
/// <summary>
/// <c>Done → Approved</c> (FR-MFG-12/13). Non-terminal: hands WIP to the children,
/// defaulting to the full available quantity. Terminal: posts the production receipt —
/// a finished-goods layer costed at <c>costPool / goodQty</c> — and completes the run.
/// </summary>
Task<ApproveStageResultDto> ApproveStageAsync(
int runId, int runStageId, ApproveStageRequest request, CancellationToken ct = default);
/// <summary>
/// Later partial transfer of a remainder held on an already-Approved stage (FR-MFG-12),
/// never exceeding produced scrapped already transferred.
/// </summary>
Task<TransferResultDto> TransferAsync(
int runId, int runStageId, TransferRemainderRequest request, CancellationToken ct = default);
/// <summary>
/// Returns unconsumed material to stock before the receipt closes the pool (FR-MFG-14).
/// The inbound layer is created at the <b>weighted cost actually consumed</b> for that
/// input, so the move is cost-preserving and the pool reduces by exactly what leaves it.
/// <c>409 RUN_COST_CLOSED</c> once the run has completed.
/// </summary>
Task<ReturnLeftoverResultDto> ReturnLeftoverAsync(
int runId, int runInputId, ReturnLeftoverRequest request, CancellationToken ct = default);
/// <summary>
/// Downstream reject (FR-MFG-15): this stage rejects the work it received, its delivering
/// parents revert <c>Approved → InProgress</c> with their transferred quantities pulled
/// back, and this stage returns to Waiting. Consumed stock stays consumed.
/// </summary>
Task<RejectIntakeResultDto> RejectIntakeAsync(
int runId, int runStageId, RejectRequest request, CancellationToken ct = default);
/// <summary>
/// Terminal reject (FR-MFG-16): resets the whole run to its starting stages, increments
/// <c>reworkCount</c> and snapshots the discarded figures into the event history.
/// Already-consumed material remains in the cost pool.
/// </summary>
Task<TerminalRejectResultDto> RejectTerminalAsync(
int runId, int runStageId, RejectRequest request, CancellationToken ct = default);
/// <summary>
/// Cancels an in-progress run (FR-MFG-17). Net consumed-and-not-returned stock goes back
/// at its consumed weighted cost; scrapped output quantities are written off on the event.
/// <c>409 RUN_NOT_CANCELLABLE</c> for a completed run.
/// </summary>
Task<CancelRunResultDto> CancelAsync(int runId, CancelRunRequest request, CancellationToken ct = default);
}
@@ -0,0 +1,33 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Production;
namespace ERPCore.Services.Interfaces;
/// <summary>
/// Production template CRUD and graph validation (docs/30 §D.1, FR-MFG-01..07).
/// </summary>
public interface IProductionTemplateService
{
Task<PagedResponse<TemplateSummaryDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
Task<ETagged<TemplateGraphDto>?> GetAsync(int templateId, CancellationToken ct = default);
Task<ETagged<TemplateGraphDto>> CreateAsync(SaveTemplateRequest request, CancellationToken ct = default);
/// <summary>
/// Replaces the whole graph. Requires the current row version (<c>If-Match</c>) and is
/// refused with <c>409 TEMPLATE_IN_USE</c> while any run of this template is
/// InProgress — edit-lock stands in for versioning (FR-MFG-06).
/// </summary>
Task<ETagged<TemplateGraphDto>> UpdateAsync(
int templateId, SaveTemplateRequest request, uint expectedRowVersion, CancellationToken ct = default);
/// <summary>
/// Activate/deactivate (FR-MFG-01). Deliberately <b>not</b> edit-locked: deactivating is
/// the "never delete a referenced master" path (FR-MD-08) and only stops <i>new</i> runs
/// being started, so it must stay available while runs are in flight.
/// </summary>
Task SetStatusAsync(int templateId, EntityStatus status, CancellationToken ct = default);
}

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